Python Decorators Under the Hood: Wrappers, Closures, and Function Transformation
A decorator is not a wrapper. It is a rebinding that happens once, at import time — and most decorator bugs live in the gap between that fact and the…

Key topics
A decorator is not a wrapper. It is a rebinding that happens once, at import time — and most decorator bugs live in the gap between that fact and the wrapper you thought you wrote.
Here is the failure I see most often. A function works perfectly in isolation. Then it gets decorated, and something downstream breaks: a framework reads the wrong signature, a debugger shows wrapper instead of the real name, a doc generator prints an empty docstring, or a test patches the original name and silently patches nothing. The code still runs. The behavior is still mostly right. But the object the rest of the system sees is no longer the function you defined.
Most engineers learn decorators as "a function that takes a function and returns a new one." That definition is true and nearly useless for debugging, because it hides the two facts that determine everything: decoration happens once at definition time, and the name you call afterward is bound to a different object than the one you wrote. Once you internalize those, decorators stop being syntax to memorize and become a transformation you can trace.
For function-wrapping decorators, the failure surfaces cluster into four mechanics: the wrapper's call signature, the closure it captures, the metadata it fails to copy, and the moment it runs. That four-part lens covers the common case. It does not cover everything — registration decorators whose real work is a side effect, and class-based decorators that change the replacement object's binding behavior, sit outside it. I will flag both when we reach them.
Decoration Is a Rebinding, Not a Call
Start with the desugaring, because everything else depends on it:
@d
def f():
...
is exactly:
def f():
...
f = d(f)
That is the whole trick. The original function object is created, then immediately passed to d, and the name f is rebound to whatever d returns. Nothing about the original function object changes. The name simply points somewhere new.
The critical distinction is when each piece runs. The decorator body — the code inside d — runs once, when the module is imported or the class body executes. The wrapper body runs on every call. Most decorator confusion is a category error between these two moments.
Decoration is import-time. Invocation is call-time. A decorator cannot see runtime arguments at decoration time, and it cannot be reconfigured after import.
That second consequence is why "I want to change the decorator's behavior later" is a design smell, not a bug. By the time you have a reference to the decorated function, the decoration already happened. If you need runtime configuration, the configuration has to live in a mutable object the wrapper reads on each call — not in the decorator's arguments.
Stacking follows directly from the desugaring. This:
@a
@b
def f():
...
means f = a(b(f)). So a is the outermost layer: it runs first on the way in and last on the way out. Trace one stacked example by hand before trusting your intuition — the order surprises people more often than they expect.
Decorating a method is the same mechanism with one extra fact: the wrapper receives self as its first positional argument, exactly like any other argument. This is why *args, **kwargs in a wrapper is not laziness. It is a requirement.
Here is the smallest useful decorator, deliberately broken so the next sections have something to repair:
def log_calls(func):
def wrapper():
print(f"calling {func.__name__}")
return func()
return wrapper
@log_calls
def add(a, b):
return a + b
add(2, 3)
This raises TypeError: wrapper() takes 0 positional arguments but 2 were given. Note where the failure appears: at call time, not at decoration time. The decorator accepted add without complaint. The breakage is delayed and misattributed — which is exactly why decorator bugs feel like magic.
Knowledge check
Check your understanding
Answer this question before you continue.
The Wrapper's Signature Is a Contract You Can Break
The wrapper replaces the original in the namespace, so its parameter list is the interface callers and tools actually hit. When the wrapper does not faithfully forward arguments, it silently changes the decorated function's contract.
*args, **kwargs fixes the forwarding problem:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
But it does not preserve the signature. Anything that inspects the signature — frameworks doing dependency injection, CLI builders, test runners, inspect.signature — now sees (*args, **kwargs) instead of (a, b). The wrapper is transparent to calls and opaque to inspection. Those are different properties, and conflating them is where framework integrations break.
functools.wraps is the metadata fix, and it is worth being precise about what it does. It copies __name__, __doc__, __module__, __qualname__, and __dict__ from the wrapped function onto the wrapper, and it sets __wrapped__ to point back at the original. It does not change the wrapper's actual parameter list. The wrapper still accepts *args, **kwargs.
So why does functools.wraps often appear to fix signature inspection? Because inspect.signature follows __wrapped__ by default. When it finds that attribute, it reports the original function's signature instead of the wrapper's. That is a convention tools honor, not a language guarantee. Code that reads wrapper.__code__.co_varnames directly, or that ignores __wrapped__, will still see the wrapper.
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
Compare the symptoms before and after:
| Symptom | Without wraps | With wraps |
|---|---|---|
f.__name__ | "wrapper" | "add" |
f.__doc__ | None | original docstring |
inspect.signature(f) | (*args, **kwargs) | (a, b) |
| Traceback frames | point into decorator | point at real function |
f.__wrapped__ | missing | original function |
My rule: apply functools.wraps unless you have a specific reason not to. And when a framework inspects signatures, verify the behavior rather than assuming the wrapper is transparent — the table above is what you check, not what you hope.
Knowledge check
Check your understanding
Answer this question before you continue.
Closures Are Where the Decorator Keeps Its State
The wrapper works because it is a closure. It references func from the enclosing decorator scope, so func stays alive after the decorator returns. Without that capture, the wrapper would have nothing to call.
Closure capture and nonlocal are their own topic, so I will not re-teach them here. The point that matters for decorators is narrower: the decorator's enclosing scope is the decorator call, and it is evaluated once. That single fact produces both the parameterized-decorator pattern and its most common bug.
Parameterized decorators are a three-level closure. This:
@retry(times=3)
def fetch():
...
means fetch = retry(times=3)(fetch). The outer function takes configuration, the middle takes the function, the inner is the wrapper. Trace the sequence explicitly:
retry(times=3)runs at import time and returns a decorator.- That decorator is applied to
fetch, returning the wrapper. fetchis rebound to the wrapper.- The wrapper closes over both
funcandtimes.
This is where most people lose the thread, because there are now two "decorator" functions and only one of them is the one they picture.
The late-binding trap is the classic failure. A loop that creates several wrappers capturing a loop variable will have all of them see the final value:
def make_logger(prefix):
def decorator(func):
def wrapper(*args, **kwargs):
print(f"{prefix}: {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
loggers = [make_logger(f"L{i}") for i in range(3)]
If prefix were captured by reference to a loop variable rather than bound as a parameter, every logger would print the last value. The fix is to bind the value at creation time — as a default argument or, as above, as a function parameter that closes over a fresh scope per call.
State lifetime matters too. State stored in the decorator's closure is shared across every call of every function that decorator instance wraps. If you need per-call state, it belongs in the wrapper's local scope. If you need per-function state, it belongs on the wrapper object.
Closure state mutated by the wrapper is shared mutable state. Under threads or async tasks, an unsynchronized counter or cache in a decorator is a race, not a feature.
That last point is not theoretical. A memoization decorator with a plain dict and no lock is fine under a single-threaded test suite and quietly wrong under a thread pool.
Knowledge check
Check your understanding
Answer this question before you continue.
Class-Based Decorators and the State You Actually Need
A class-based decorator works because __call__ makes an instance callable. @Cache on a function means the function is replaced by an instance whose __call__ forwards to it:
class Cache:
def __init__(self, func):
self.func = func
self.hits = 0
self.store = {}
def __call__(self, *args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
if key not in self.store:
self.store[key] = self.func(*args, **kwargs)
else:
self.hits += 1
return self.store[key]
The real advantage is explicit, named state. hits, store, and any reset method live as attributes instead of as closure variables you cannot reach. That reachability is the design win — not the syntax. When you need to inspect a cache's hit rate or clear it between tests, an attribute beats a closure every time.
The cost is that an instance is not a function. functools.wraps applied to __call__ helps with metadata, but inspect.isfunction returns False, and code that assumes a plain function will behave differently.
There is a second cost that bites harder, and it is the boundary the four-mechanics lens does not cover. When a class-based decorator wraps a method, the replacement object is an instance, and a plain instance is not a descriptor. Ordinary functions are descriptors: they implement __get__, which is what binds self when you access a method through an instance. A callable instance has no __get__, so self never gets injected. Watch it happen:
class Cache:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
class Service:
@Cache
def handle(self, request):
return f"handled {request}"
Service().handle("r1")
This raises TypeError: handle() missing 1 required positional argument: 'request'. The call Service().handle("r1") passes "r1" as the first argument, but nothing supplied self. The instance was returned by attribute lookup without binding, so self was never filled in.
The repair is to give the replacement object descriptor behavior by implementing __get__:
import functools
class Cache:
def __init__(self, func):
self.func = func
self.store = {}
def __call__(self, *args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
if key not in self.store:
self.store[key] = self.func(*args, **kwargs)
return self.store[key]
def __get__(self, obj, objtype=None):
if obj is None:
return self
return functools.partial(self.__call__, obj)
Now Service().handle("r1") binds obj as self and forwards it. Notice what just happened: you stopped solving a decorator problem and started solving a descriptor problem. That is a different layer with different rules, and it is the honest reason to prefer a function-based wrapper for methods unless you specifically need the instance's inspectable state.
The decision boundary I use:
- Function-based decorator for stateless or simple wrapping, and for methods where you do not need instance state.
- Class-based decorator when you need inspectable state, multiple configuration knobs, or a reset/inspect API — and you are willing to handle
__get__if it wraps methods. - Descriptor or metaclass when you need per-attribute or per-class behavior, because a decorator is the wrong layer for that.
And when not to use a decorator at all: if the behavior needs to be conditional at call time, composed dynamically, or if it obscures control flow in a hot path, an explicit wrapper call or a context manager is often clearer and easier to debug. A decorator is a definition-time decision. If your decision is a call-time decision, do not force it into a decorator.
Knowledge check
Check your understanding
Answer this question before you continue.
Debugging a Decorator Without Guessing
Turn the mechanism into a procedure. When a decorated function misbehaves, work these five steps in order.
Step one: identify the moment. Does the failure happen at import (decorator body, configuration, registration) or at call (wrapper body, argument forwarding, return value)? The traceback line usually answers this immediately. An error inside the decorator body means import time. An error inside the wrapper means call time.
Step two: inspect the object. Print the four attributes that reveal metadata state:
print(f.__name__)
print(f.__doc__)
print(getattr(f, "__wrapped__", None))
print(inspect.signature(f))
If __name__ is "wrapper" or __wrapped__ is missing, metadata is your bug. Fix that before chasing anything else.
Step three: check the boundary. Call the wrapper with the exact argument shapes the real caller uses — positional, keyword, defaults, self. A wrapper that only ever gets tested with *args hides signature bugs until production.
Step four: check the state. If behavior drifts across calls, look for closure or instance state that is shared when you assumed it was fresh, or fresh when you assumed it was shared.
Step five: check the stack. A traceback that points into the decorator instead of the decorated function is a metadata symptom, not a logic symptom.
Here is the compact worked example. A timing decorator that looks correct:
import time
def timed(func):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
return result
return wrapper
It works until a framework inspects the signature to build a schema. inspect.signature(timed_func) returns (*args, **kwargs), the framework cannot map parameters, and the integration fails. The repair is one line:
import functools
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
...
return wrapper
Now inspect.signature follows __wrapped__ and reports the original signature. The wrapper's parameter list never changed — only the metadata tools read did.
Metadata correctness is necessary but not sufficient. Two checks catch the boundaries the metadata drill misses. First, ordering: for stacked decorators, log entry and exit and confirm the outermost runs first in and last out.
def a(func):
def wrapper(*args, **kwargs):
print("a in")
result = func(*args, **kwargs)
print("a out")
return result
return wrapper
def b(func):
def wrapper(*args, **kwargs):
print("b in")
result = func(*args, **kwargs)
print("b out")
return result
return wrapper
@a
@b
def f():
print("f")
f()
The output is a in, b in, f, b out, a out — the desugaring made visible. Second, awaitability: a decorator that wraps an async def with a plain synchronous wrapper returns a coroutine object that is never awaited, so the function silently does nothing. If the decorated function is a coroutine, the wrapper must be async def and await func(...), or you must return the coroutine deliberately. Assert inspect.iscoroutinefunction(decorated) when that contract matters.
Where Decorators Earn Their Place in Real Systems
The mechanism matters because decorators sit at the seams of real systems, and the seams are where the constraints live.
Instrumentation and tracing. Decorators are the natural place to attach timing, logging, and span creation. But the wrapper adds a stack frame and a call per invocation. In hot paths, measure before assuming the overhead is negligible.
Retry and timeout wrappers. These need to distinguish retryable from non-retryable failures and to interact correctly with cancellation. A retry decorator that swallows exceptions or retries non-idempotent work is a correctness bug wearing a convenience costume.
Caching. A memoization decorator must decide on a key function, handle unhashable arguments, and be explicit about whether the cache is per-process, per-instance, or global. Unbounded caches in a decorator are a memory leak with a friendly name.
Registration decorators. Here the decorator's return value may be irrelevant because the side effect — adding the function to a registry — is the point. This is a different pattern from behavior-changing decorators, and it deserves its own name, because the debugging questions are different. You are not tracing a wrapper; you are tracing a registration. The four-mechanics lens does not apply cleanly here, and pretending it does will send you looking for a wrapper that was never the problem.
AI and framework tooling. Decorators are how many libraries declare tools, endpoints, and handlers. When a framework inspects the decorated callable to build a schema, the wrapper's signature and metadata become part of the public contract. That is precisely why the earlier sections matter: the contract is not the function you wrote, it is the object the framework sees.
State the boundary honestly. Decorators compose behavior around a callable. They do not compose data flow, do not express ordering constraints between independent wrappers, and do not replace explicit dependency wiring when the behavior depends on runtime context.
The Four Mechanics, and One Thing to Do Next
Before writing a decorator, decide which of the four mechanics you are touching: the rebinding at definition time, the wrapper's call signature, the closure's captured state, or the metadata that tools read. That lens covers function-wrapping decorators well. When you are writing a registration decorator or a class-based decorator on a method, step outside it: the first is a side-effect pattern, and the second is a descriptor problem in disguise.
Then do this today. Take one decorator already in your codebase and print __name__, __doc__, __wrapped__, and inspect.signature on the decorated callable. Compare that output against what the original function would report. Any mismatch is a latent bug in a framework integration, a doc generator, or a debugger — and it is far cheaper to find now than in a traceback that points at the wrong line.
Knowledge check
Final check
Finish the article by checking the ideas you just learned.
References
Research updated Sep 11, 2026


