Python lets you pass a function to another function, wrap it, and return a replacement. That is all a decorator is. And a function defined inside another function can keep using the outer function's variables after the outer function finishes — that is all a closure is. Neither is magic; both follow from functions being first-class objects with lexical scoping.
Think of a decorator like a mailing service. You hand over your envelope, the service adds a stamp and forwarding address, and hands back the same envelope with extra capability. The original letter is still inside. A closure is like a post office box: even after you walk away from the counter, the box still holds mail for you. The inner function retains access to variables from the enclosing scope, even after the outer function has returned.
A decorator runs when the function definition executes, usually during module import, and rebinds the function name to whatever object the decorator returns.
# [CURRENT - 3.10-3.14] Works on Python 3.xdef trace(fn): print("decorating", fn.__name__) return fn@tracedef run(): return "ok"A closure is a function that retains access to free variables from an enclosing scope after that outer scope has returned.
# [CURRENT - 3.10-3.14] Works on Python 3.xdef make_averager(): values = [] def averager(value): values.append(value) return sum(values) / len(values) return averageravg = make_averager()print(avg(10))print(avg(20))Python uses lexical scoping: local, enclosing, global, then built-in name resolution. When an inner function references a variable from an enclosing function, CPython stores that binding in a closure cell.
# [CURRENT - 3.10-3.14] Works on Python 3.ximport disdef outer(): x = 10 def inner(y): return x + y return innerfn = outer()print(fn.__code__.co_freevars)print(fn.__closure__[0].cell_contents)dis.dis(fn)On CPython, the bytecode for the inner function uses LOAD_DEREF to read from the closure cell. That detail is implementation-specific, but it makes the mechanism concrete: the value stays in the closure cell and is read through LOAD_DEREF — never copied into the inner function body.
nonlocal is required only when rebinding the outer name, not when mutating an object referenced by that name.
# [CURRENT - 3.10-3.14] Works on Python 3.xdef make_counter(): count = 0 def inc(): nonlocal count count += 1 return count return incDecorators have been in Python since 2.4. nonlocal was added in Python 3 by PEP 3104. Current project guidance targets Python 3.10-3.14. Python 3.9 and below are End-of-Life.
Decorators add another Python call boundary. In very hot paths, that extra frame and argument forwarding cost can matter.
Without functools.wraps, wrappers also lose metadata such as __name__, __doc__, and __wrapped__, which breaks introspection, debugging, and framework behavior.
# [CURRENT - 3.10-3.14] Works on Python 3.xfrom functools import wrapsfrom time import perf_counterdef timed(fn): @wraps(fn) def wrapper(*args, **kwargs): start = perf_counter() try: return fn(*args, **kwargs) finally: elapsed = perf_counter() - start print(f"{fn.__name__}: {elapsed:.6f}s") return wrapperUse closures for small amounts of hidden state. When the state has multiple operations, lifecycle concerns, or debugging importance, a class is often the clearer production abstraction.
Use closures for lightweight factories, accumulators, and callbacks. Use decorators for orthogonal concerns such as timing, tracing, retries, and registration. Type decorators carefully when they are public API surface; see .