python decorator
A Python decorator is a function that consumes your function and hands back a different object, which is then bound to the original name: writing `@memo` above `def f(x)` is exactly `f = memo(f)`, executed once when the module is imported. Everything people find surprising about decorators falls out of that one substitution — the vanished `__name__`, the tracebacks with three `wrapper` frames stacked in them, and the 55.23 ns that each additional layer adds to every call the process will ever make.
```svg
```
**The at-sign is an assignment, not an annotation.** PEP 318 introduced the syntax in Python 2.4 in 2004 purely as sugar for a rebinding that people were already writing by hand; PEP 3129 extended it to classes in Python 3.0, and PEP 614 relaxed the grammar in Python 3.9 so the expression after `@` can be any expression rather than a dotted name. The sugar hid the mechanism so well that a decade of confusion followed, because a name that looks like metadata attached to a function is in fact a statement that runs at import and replaces that function with whatever it returns. Stacked decorators apply bottom-up and execute top-down, which is only paradoxical until you expand them: `f = a(b(c(f)))` builds the onion from the inside and unwraps it from the outside. Nothing in the language checks that the returned object is callable, has the same arity, or has any relationship to the original at all — the substitution is unconditional, and that is simultaneously the feature and the whole risk surface.
**Every layer you stack costs one more Python frame.** On CPython 3.12.3 a bare call to a trivial function takes 23.85 ns; routing it through a single transparent wrapper takes 84.03 ns, a 3.52x increase, and stacking to two, three and five layers costs 138.36 ns, 192.39 ns and 304.95 ns respectively. The marginal cost of each added layer settles at 55.23 ns, which is not a mystery number: it is one frame push, one argument tuple build, one call, and one frame pop. Written as a budget the model is trivially predictive,
$$t_{\text{total}} \;=\; t_{\text{body}} \;+\; n\,\delta, \qquad \delta \approx 55\ \text{ns}$$
so a five-deep stack of logging, retry, caching, auth and tracing decorators — the exact stack that accretes on a request handler over two years — carries 281 ns of pure interpreter overhead before your first line of business logic runs. At a million calls that is 281 ms of wall clock spent entirely on the privilege of separation of concerns.
**Signature transparency is the most expensive convenience in the stack.** A wrapper declared as `def wrapper(x)` costs 42.06 ns, only 1.76x the bare call, while the idiomatic variadic form — `*args` plus an arbitrary keyword mapping — costs 84.03 ns. The 41.97 ns difference is larger than the frame itself: packing positional arguments into a fresh tuple and keyword arguments into a fresh dict, then unpacking them again at the inner call, costs more than the function call it enables. And the money buys a real thing — a fixed-signature wrapper breaks the moment someone adds a keyword argument to the wrapped function — but it also destroys the signature at the interface, so `inspect.signature` reports nothing but a bare variadic placeholder, IDE completion goes blank, and Pydantic or FastAPI, which read type annotations off the callable to build request models, see nothing to work with. If a decorator sits on a hot path and the wrapped function's signature is stable, writing the explicit parameters back is a free 42 ns.
**A decorator that forgets to copy identity breaks the tooling, not the logic.** Without `functools.wraps`, the wrapper reports `__name__` as `'wrapper'` and `__doc__` as `None`, so Sphinx documents nothing, pytest reports every parametrized case under the same name, pickling fails because the qualified name no longer resolves, and a profiler flame graph shows a forest of identical `wrapper` entries. Applying `@functools.wraps(fn)` copies six attributes — `__module__`, `__name__`, `__qualname__`, `__doc__`, `__annotations__` and `__type_params__` — updates `__dict__`, and sets `__wrapped__` to the original, which is the part that matters most: `inspect.signature` follows the `__wrapped__` chain and recovers the true `(x: int) -> int` even through the opaque wrapper. The price is 787.60 ns against 92.35 ns for the naked decoration, an 8.5x increase paid exactly once per decorated function at import time, and 0 ns at call time — 83.77 ns with `wraps` against 84.03 ns without is inside the measurement noise. A cost paid once at import and never again is the cheapest thing in this entire article, which is why omitting `wraps` is never an optimization, only an oversight.
**The most valuable decorators do not wrap the function at all.** `@property` returns a descriptor rather than a wrapper, turning an 8.53 ns attribute load into a 17.53 ns one — a 2.06x factor on an operation so cheap that it rarely matters, in exchange for a computed attribute that no caller has to know about. `@dataclass` returns the same class object with `__init__`, `__repr__` and `__eq__` generated into it, so there is no wrapper and no call overhead whatsoever. Flask's and FastAPI's `@app.route` register the function in a routing table and hand back the untouched original, meaning the decorated function is exactly as fast and exactly as unit-testable as the undecorated one, and the entire effect lives in a side effect you cannot see from the call site. Further out, `@ray.remote` returns a task handle that dispatches to a cluster, `@numba.njit` and `@jax.jit` return objects that compile and then bypass the interpreter entirely, and `@torch.compile` traces the function into a graph — all of them substitutions where the returned object shares only a name with what you wrote.
**The same syntax spans seven orders of magnitude of cost.** Naive recursive `fib(30)` makes 2,692,537 calls and takes 70.04 ms; one line of `@functools.lru_cache(maxsize=None)` collapses those to 31 distinct evaluations and turns the whole computation into a 30.24 ns dictionary hit, a speedup of 2,316,157x. That wrapper is not cheaper than any other wrapper — it still costs its frame — but it changes what the frame is compared against, and that is the only comparison that has ever mattered. The same reasoning explains why a retry decorator measured at 122.72 ns and a timing decorator at 168.92 ns, of which 79.66 ns is two `time.perf_counter()` reads, are both irrelevant on a network call and both indefensible on an inner-loop numeric kernel.
**Decoration happens once at import; the tax is paid on every call.** The single decision rule that resolves nearly every argument about decorator performance is a ratio between the 60.18 ns of added overhead and the body being wrapped. On a function whose body is a 23.85 ns increment that overhead is 252 percent and the decorator is a design smell; on a function whose body is a 0.376 ms loop the same overhead is 0.016 percent, sits below the run-to-run noise floor, and the decorator is free. Between those poles is a band roughly 15,800x wide where judgement is actually required, and the honest way to resolve it is to measure the body rather than to argue about the wrapper.
| Construction | ns per call | vs bare | What the rebinding buys | Where it hurts |
|---|---|---|---|---|
| Bare call | 23.85 | 1.00x | nothing | nothing |
| Fixed-signature wrapper | 42.06 | 1.76x | one hook, minimum overhead | breaks when the signature changes |
| Variadic `*args` wrapper | 84.03 | 3.52x | works on any callable | 41.97 ns, and the signature is erased |
| Wrapper with `@wraps` | 83.77 | 3.51x | name, docs, annotations, `__wrapped__` | 787.60 ns once, at import |
| Three stacked wrappers | 192.39 | 8.07x | three concerns kept apart | three frames, three tracebacks |
| `@lru_cache` hit | 30.24 | 1.27x | 70.04 ms of work never runs | unbounded memory, stale results |
```flowchart
{ "rows": [
{ "type": "nodes", "items": [
{ "title": "def f(x)", "sub": "a function object is created", "tone": "neutral" },
{ "title": "@deco runs", "sub": "once, at import time", "tone": "neutral" }
] },
{ "type": "arrow" },
{ "type": "group", "title": "The name f is rebound to whatever comes back", "note": "nothing checks that it resembles the original", "items": [
{ "title": "A wrapper closure", "sub": "one extra frame, plus 55 ns per call", "tone": "green" },
{ "title": "A descriptor", "sub": "property, staticmethod, classmethod", "tone": "green" },
{ "title": "The same class, rewritten", "sub": "dataclass, total_ordering", "tone": "green" },
{ "title": "f itself, unchanged", "sub": "app.route registers and returns it", "tone": "orange" }
] },
{ "type": "arrow" },
{ "type": "nodes", "items": [
{ "title": "Identity repair", "sub": "wraps copies 6 attributes, sets __wrapped__", "tone": "orange" },
{ "title": "Call path", "sub": "the tax, paid on every call forever", "tone": "orange" }
] }
] }
```
Debugging is where the rebinding stops being an abstraction and starts being your problem. A five-layer stack puts five `wrapper` frames between the exception and the code that raised it, and because every one of them is named `wrapper` in a different module, the traceback is genuinely harder to read than the bug. The disciplined fixes are all consequences of the same idea: always apply `functools.wraps` so the frames carry meaningful qualified names, keep the wrapper body to the minimum that the concern requires so exceptions propagate from the original rather than from the plumbing, and reach for `__wrapped__` in tests to call the undecorated function directly. Libraries that do this well are recognisable by it — tenacity exposes retry state rather than swallowing it, OpenTelemetry's instrumentation decorators re-raise after recording the span, and Click builds its command tree by attaching attributes rather than by burying the callback in three closures.
Read a decorator through a *rebinding* lens rather than a *wrapping* lens: the question is never what the wrapper does on the inside, it is what object the name now points at and what that object costs to call. Every hard problem in this space is a different instance of that single substitution — a lost `__name__` is the new object failing to impersonate the old one, a 12.78x slowdown is five substitutions each demanding its own frame, a 2,316,157x speedup is a substitution that answers without calling anything at all, and an `@app.route` handler that resists unit testing is a substitution whose real effect happened in a registry you never look at. Decide first what the name should point at, and the wrapper writes itself.