Backend / Python core / 10_decorators.md

Decorators

Updated 5 interview angles 7 min read source
On this page11
  1. The mechanism: definition time
  2. The mechanism: call time
  3. Decorators with arguments
  4. Methods, descriptors and class-based decorators
  5. Async functions
  6. Typing a decorator
  7. What a layer costs
  8. Where it breaks in production
  9. The trade not taken
  10. Related
  11. Interview angle

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

python
@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:

python
@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

python
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 + b

At 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:

python
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:

python
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:

python
    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:

python
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 above

Use 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:

python
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 wrapper

functools.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

python
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)).

Interview angle 5

  • “What does @decorator do?” — 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 --preload and 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.
  • “Write a timing decorator.” — a wrapper with *args, **kwargs that returns the result, inside try/finally, with functools.wraps.
    • Follow-up: “Make it work on async functions.” — detect with inspect.iscoroutinefunction and return an async def wrapper 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__ returning types.MethodType(self, obj); without it self is never bound and the call fails.
  • “Why does functools.wraps matter?” — it copies the name, docstring and annotations and sets __wrapped__, so inspect.signature reports the real signature. Without it, tracebacks say wrapper and FastAPI asks for query parameters called args and kwargs.
  • “How does mypy see the decorated function?” — as the wrapper, unless the decorator is typed with ParamSpec, so it takes a Callable[P, R] and returns a Callable[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.