Decorators
Article.
A decorator is a callable that takes a function and returns a replacement,
applied with @. The definition is one sentence; the questions behind it are
not. What does @decorator actually do to a function, at definition time
and at call time?
Answer that and the rest follows: why functools.wraps matters, why
class-based decorators break on methods, why decorators with side effects
break under --preload, and what each layer costs per call. Outputs below are
from Python 3.14.5.
Context managers, which the old version of this note covered too, are in Context managers: the with protocol and contextlib.
The mechanism: definition time
@d
def f():
...
# is
def f():
...
f = d(f)On 3.14 that compiles to: load d, build the function object
(MAKE_FUNCTION), CALL the decorator with it, and bind the result to the
name. The function is never bound to f undecorated. With several decorators,
the expressions are evaluated top to bottom and applied bottom to top:
@tag("outer") # evaluate outer, evaluate inner,
@tag("inner") # then apply inner, then apply outer:
def g(): ... # g = outer(inner(g))All of this runs when the module is imported, which is why decorators are
the registration mechanism of most frameworks: @app.get(...),
@celery.task, @pytest.fixture record the function at import time. Since
3.9 (PEP 614) any expression can follow @, such as @buttons[0].clicked.connect.
The mechanism: call time
import functools, time
def timed(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs): # any signature
start = time.perf_counter()
try:
return fn(*args, **kwargs) # pass everything through, return the result
finally:
print(f"{fn.__qualname__}: {time.perf_counter() - start:.3f}s")
return wrapper
@timed
def add(a, b):
return a + bAt call time, add(1, 2) calls wrapper, which reaches the original through
a closure: add.__closure__[0].cell_contents is the original function, and
add.__code__.co_freevars is ('fn',). Three things every wrapper must do,
and that the textbook first example usually skips: accept *args, **kwargs,
return the result, and use functools.wraps.
What functools.wraps copies
It copies __module__, __name__, __qualname__, __doc__,
__type_params__ and the annotations (__annotate__ on 3.14, where
annotations are lazy, __annotations__ before), merges __dict__, and sets
__wrapped__ to the original. That last attribute is what makes the wrapper
introspectable:
inspect.signature(add)follows__wrapped__and reports(a, b), not(*args, **kwargs).inspect.unwrap(add)walks the whole__wrapped__chain back to the original, which is how tests can call a function without its decorators.
Frameworks read signatures at import time. FastAPI builds request parsing
from inspect.signature; without wraps, it sees *args, **kwargs and asks
the client for query parameters called args and kwargs.
Decorators with arguments
@retry(times=3) is a call that returns a decorator, so it needs one more
level:
def retry(times: int, on: type[Exception] = Exception):
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return fn(*args, **kwargs)
except on:
if attempt == times:
raise
return wrapper
return decorator
@retry(times=3, on=ConnectionError)
def fetch(url): ...The full set of patterns, including decorators usable with and without arguments, is in Decorator with arguments.
Methods, descriptors and class-based decorators
A function-based wrapper works on methods for free: functions are
descriptors, so obj.method binds self to the wrapper as it would to the
original. An instance of a class is not a descriptor, so a class-based
decorator breaks on methods:
class Counted:
def __init__(self, fn):
self.fn, self.calls = fn, 0
def __call__(self, *args, **kwargs):
self.calls += 1
return self.fn(*args, **kwargs)
class A:
@Counted
def m(self, x): ...
A().m(1) # TypeError: A.m() missing 1 required positional argument: 'x'A().m is the Counted instance itself, so 1 lands in self and x is
missing. The fix is to make it a descriptor that binds like a function:
def __get__(self, obj, objtype=None):
return self if obj is None else types.MethodType(self, obj)Order matters when stacking with the built-in descriptors. @staticmethod
and @classmethod go outermost, above your decorator, so your wrapper wraps
the plain function. @classmethod above @property used to make a class
property; it was deprecated in 3.11 and on 3.13+ it simply returns a bound
method (Descriptors).
Async functions
A sync wrapper around an async def returns the coroutine without awaiting
it, so the timing above would measure nothing and exceptions would escape
the try. Wrap coroutines with a coroutine:
def timed(fn):
if inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def async_wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return await fn(*args, **kwargs)
finally:
print(f"{fn.__qualname__}: {time.perf_counter() - start:.3f}s")
return async_wrapper
... # the sync wrapper from aboveUse inspect.iscoroutinefunction: asyncio.iscoroutinefunction is
deprecated in 3.14 and slated for removal in 3.16. A sync function that
returns an awaitable can be marked with inspect.markcoroutinefunction
(3.12) so frameworks detect it.
Typing a decorator
Without help, a type checker sees a *args, **kwargs wrapper and the
decorated function loses its signature. ParamSpec (PEP 612, 3.10) carries
it through, and 3.12’s syntax (PEP 695) makes it short:
def timed[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(*args, **kwargs)
return wrapperfunctools.wraps alone does not do this for a type checker; it is a runtime
copy. The standard library now ships the decorators people used to hand-roll:
typing.override (3.12) and warnings.deprecated (3.13), which type checkers
also understand (ParamSpec and Concatenate — typing decorators).
What a layer costs
| Call | Per call on 3.14.5 |
|---|---|
| plain function | 55 ns |
| one wrapper | 191 ns |
| two wrappers | 282 ns |
Each *args, **kwargs layer is another Python call plus packing and
unpacking a tuple and a dict. In a request handler that is noise; on a
per-row or per-attribute hot path, a stack of decorators can dominate. Apply
them at the outer boundary, not inside tight loops.
Where it breaks in production
Side effects at import time
A decorator that opens a database connection, reads config or starts a
thread when the module is imported works in development and breaks under
gunicorn --preload (the connection is opened in the master and shared by
forked workers) and in tests (imports touch real infrastructure). Decorators
should register, not connect; do the work lazily at first call.
lru_cache on a method
class Service:
@functools.lru_cache
def lookup(self, key): ...The cache key includes self, so the cache keeps every instance alive for
as long as the class exists: a weakref to a deleted instance still resolves.
Cache at module level on the arguments that matter, use
functools.cached_property for per-instance values, or keep a cache on the
instance.
Decorators in the wrong order
@cache above @require_auth caches the result of the first caller and
serves it to everyone after, auth check skipped. The decorator closest to the
function runs innermost; authorization belongs outside anything that can
short-circuit the call.
Retrying what must not run twice
A generic @retry on a function that charges a card or sends an email turns
a timeout into a duplicate. Retry only idempotent calls, or pair the retry
with an idempotency key.
A missing wraps
Logs and tracebacks show wrapper for every decorated function, pickling
fails, and frameworks that read signatures (FastAPI, Typer, pytest fixtures)
see *args, **kwargs.
The trade not taken
A closure is the default decorator. A class-based decorator earns its place
when the wrapper needs state you want to inspect (a call counter, a circuit
breaker), at the cost of the __get__ above. The wrapt library handles
binding, signatures and introspection correctly in every combination, for
decorators that must work on functions, methods, classmethods and classes
alike.
Sometimes the right answer is no decorator: when the behaviour depends on request context, middleware or an explicit call composes more visibly. The Python decorator is not the GoF Decorator pattern, which wraps objects, not functions (Structural Design Patterns (GoF)).
Related
Interview angle 5
- “What does
@decoratordo?” — at definition time,f = decorator(f): the function is built, passed in, and the name is bound to whatever comes back. That runs at import time, which is why frameworks register routes and tasks with decorators.- Follow-up: “A decorator that opens a DB connection works in dev but
breaks under gunicorn
--preloadand in tests. Why?” — it connects at import time: in the preloading master, shared by forked workers, and in every test that imports the module. Register at import, connect lazily.
- Follow-up: “A decorator that opens a DB connection works in dev but
breaks under gunicorn
- “Write a timing decorator.” — a wrapper with
*args, **kwargsthat returns the result, insidetry/finally, withfunctools.wraps.- Follow-up: “Make it work on async functions.” — detect with
inspect.iscoroutinefunctionand return anasync defwrapper that awaits; a sync wrapper would time only the coroutine’s creation. - Follow-up: “Make it a class with a call counter that works on
methods.” — add
__get__returningtypes.MethodType(self, obj); without itselfis never bound and the call fails.
- Follow-up: “Make it work on async functions.” — detect with
- “Why does
functools.wrapsmatter?” — it copies the name, docstring and annotations and sets__wrapped__, soinspect.signaturereports the real signature. Without it, tracebacks saywrapperand FastAPI asks for query parameters calledargsandkwargs. - “How does mypy see the decorated function?” — as the wrapper, unless the
decorator is typed with
ParamSpec, so it takes aCallable[P, R]and returns aCallable[P, R]with the same parameters. - “What does a decorator cost?” — one Python call plus argument packing per layer: 55 ns for a plain call against 191 ns with one wrapper on 3.14.5. Irrelevant per request, significant per row.