Decorators: expressions top-down, application bottom-up
A decorator is an ordinary call that happens once, the moment def runs. The expressions are read top-down and applied bottom-up, and nearly everything else follows from that pair: why functools.wraps is not politeness, and why @classmethod under someone else's decorator breaks without a sound.
Full technical treatment
TL;DR
@d above def f is neither a declaration nor magic. It is f = d(f):
the function is handed to the decorator and the name is rebound to the result.
That assignment happens once, at the moment the interpreter reaches def. On a
call it is the returned wrapper that runs; the decorator itself costs nothing
by then — it is done.
Hence the main consequence: with several decorators the expressions are
evaluated top-down and applied bottom-up. Almost every surprise grows out of
that pair: the nesting order of wrappers, why @classmethod under a decorator
of your own breaks on the first call rather than while the class is built, and
why functools.wraps is not politeness but a precondition for your debugger,
your documentation and pickle to work at all.
Beyond that come numbers, versions and limits. wraps makes the wrapper
look like the target, not behave like it: inspect.signature reports
the target's signature only because it walks __wrapped__, while the wrapper
itself still accepts *args, **kwargs. A layer has a measurable price: 17.3 ns
undecorated against 53.5 ns with one layer on 3.13.7, about 36 ns per layer.
And the built-in decorators depend on the version: a staticmethod object is
callable from 3.10, a classmethod object is not, and from 3.13 @classmethod
over @property yields a bound method rather than the value.
- a function is an object like any other: it can be passed to another function and returned from one;
- a function defined inside another function remembers the variables around it;
- a call can be accepted as it comes, without naming the parameters — through
*argsand**kwargs.
functools.wraps,__wrapped__,__qualname__,inspect.signature;- bytecode, descriptors, how
staticmethodandclassmethodare built, coroutine flags.
Base: what the @ sign does
A decorator is an ordinary function that takes a function and returns whatever will stand under that function's name from then on. That is the whole definition: there is no special execution mode behind it and no separate machinery inside the interpreter.
The @ sign is shorthand. These lines
@d
def f(): ...mean exactly the same as these:
def f(): ...
f = d(f)The function was handed to the decorator, and the name was rebound to the
result. From then on the name f holds not what the source says but what d
returned. Usually that is a wrapper — a new function that calls the original
inside and does something of its own around the call: times it, checks
permissions, writes a log line.
Three things follow from that one equivalence, and together they are the minimum an interview asks for.
First: the decorator runs once, together with the def. The assignment
f = d(f) sits next to the definition, not inside the function's body — so it
happens when the interpreter reaches the definition, not when the function is
called.
Second: what runs on a call is the wrapper. Calling f(...) calls whatever
the decorator returned. The original function runs only if the wrapper calls
it. This is also the commonest beginner's breakage — "my decorator does
nothing": the wrapper was never returned, and the name now holds None.
Third: with several decorators, they are applied bottom-up. The bottom one
receives the original function, the next one receives the bottom one's result,
and so on up to the top. That is not a formatting convention but a direct
consequence of each @ line being one more assignment wrapped around the
previous result.
That is already enough to answer the basic interview question. Everything below
is about the order in which the expressions after @ are evaluated being the
opposite of the order in which they are applied; about why a wrapper built
without functools.wraps breaks debuggers and serialisation; and about why the
familiar @staticmethod and @classmethod do not behave like functions in
this scheme.
Mechanism 1: one call at definition time
@d above a def is f = d(f) at definition time. Neither version nor implementation changes that.The language reference describes a decorator in four sentences, and everything is already there:
Decorator expressions are evaluated when the function is defined, in the scope that contains the function definition. The result must be a callable, which is invoked with the function object as the only argument. The returned value is bound to the function name instead of the function object.
Is "when the function is defined" literal? Run it:
log = []
def d(f):
log.append("decorator ran")
return f
@d
def g(): pass
print(log) # ['decorator ran'] — before g has ever been called
g(); g()
print(log) # ['decorator ran'] — still exactly onceThe practical consequence matters more than the wording: a decorator is code that runs at import time. Registering a handler, reading an environment variable or opening a connection inside a decorator all happen when the module is imported, not on first call. A module imported for one function pays for all of it.
Mechanism 2: top-down, then bottom-up
The same reference gives the equivalence:
@f1(arg)
@f2
def func(): pass
# roughly the same as:
def func(): pass
func = f1(arg)(f2(func))Below, the same steps are played out one at a time: source on the left, stack on the right. What to watch for is the moment the function itself appears — both decorators are already on the stack by then.
f1(arg)(f2(func)) makes the bottom-up application visible: the inner f2 runs
first. What it does not show is that f1 and arg are evaluated before f2
is. These are different things, and an experiment separates them:
events = []
class Named:
def __init__(self, tag): self.tag = tag
def __call__(self, f):
events.append(f"applied {self.tag}")
return f
def build(tag):
events.append(f"evaluated expression {tag}")
return Named(tag)
@build("upper")
@build("lower")
def f(): pass
for e in events: print(e)evaluated expression upper
evaluated expression lower
applied lower
applied upper
The bytecode says the same thing more bluntly — here and below, python3.13:
import dis
dis.dis(compile("@d1\n@d2(arg)\ndef f():\n pass\n", "<lesson>", "exec")) 1 LOAD_NAME 0 (d1)
2 LOAD_NAME 1 (d2)
PUSH_NULL
LOAD_NAME 2 (arg)
CALL 1
3 LOAD_CONST 0 (<code object f>)
MAKE_FUNCTION
2 CALL 0
1 CALL 0
3 STORE_NAME 3 (f)
The housekeeping RESUME and the None return are omitted — they are in the
output but have nothing to do with decorator order.
d1 is loaded FIRST — before d2 has even been evaluated. Then the function
object is built, and only then do the two CALLs run in reverse order. The
compiler quite literally stacks the decorators top-down and unwinds them
bottom-up.
PEP 318 explains why: the application order "matches the usual order for function-application. In mathematics, composition of functions (g o f)(x) translates to g(f(x))".
Mechanism 3: the name is never bound along the way
The reference's equivalence ends with a caveat people usually skim past:
…except that the original function is not temporarily bound to the name
func.
So func = f1(arg)(f2(func)) is inexact: in that spelling func briefly
exists undecorated. In real decoration it never does. The bytecode above shows
it directly — there is exactly one STORE_NAME f, at the very end.
You can check it without reading bytecode:
seen = []
def probe(f):
seen.append("h" in globals())
return f
@probe
def h(): pass
print(seen) # [False] — the name h does not exist yetWhat this buys you: a recursive call by name inside the decorated function reaches the decorated version, not the original, because by the time any call happens the name is already bound to the decorator's result. That is exactly what you want from a caching decorator — and exactly what defeats naive attempts to "bypass the wrapper" by calling your own name.
Mechanism 4: a decorator with arguments is two calls
@d and @d(...) are not two kinds of decorator but one mechanism. What
follows @ is an expression; its result is called with the function. If the
expression is itself a call, there are two calls:
calls = []
def factory(n):
calls.append(("factory", n))
def deco(f):
calls.append(("decorator", f.__name__))
return f
return deco
@factory(3)
def m(): pass
print(calls) # [('factory', 3), ('decorator', 'm')]Since Python 3.9 any expression is allowed after @ (PEP 614). Before that the
grammar demanded a dotted name with an optional call, so @buttons[0].clicked.connect
— the PEP's own example — had to be hoisted into a temporary variable first.
Mechanism 5: what functools.wraps does, and what it does not
Without wraps the wrapper honestly reports itself — itself, not the target:
import functools, inspect, pickle
def naive(f):
def wrapper(*a, **kw): return f(*a, **kw)
return wrapper
def target(x: int, y: str = "s") -> bool:
"The target's docstring."
return True
n = naive(target)
print(n.__name__) # 'wrapper'
print(n.__doc__) # None
print(inspect.signature(n)) # (*a, **kw)With wraps it reports the target:
def careful(f):
@functools.wraps(f)
def wrapper(*a, **kw): return f(*a, **kw)
return wrapper
c = careful(target)
print(c.__name__) # 'target'
print(inspect.signature(c)) # (x: int, y: str = 's') -> boolAnd here is the part that rarely gets said out loud:
print(inspect.signature(c, follow_wrapped=False)) # (*a, **kw) -> boolThe target's signature is visible not because the wrapper acquired it, but
because inspect follows the __wrapped__ chain that wraps sets for you.
Ask it not to follow, and the wrapper is (*a, **kw) again. The return
annotation survives because it was copied as part of __annotations__.
This distinction is not pedantry. wraps does not validate arguments on your
behalf: the wrapper still accepts anything, and a misspelled keyword argument
travels all the way to the target instead of being rejected at the boundary.
Tools that read __wrapped__ — inspect, help, debuggers, doc generators —
see the truth; the call itself does not.
It helps to split wraps into three separate jobs — they get confused because
one line does all three:
| job | what wraps does | what stays as it was |
|---|---|---|
| metadata | copies __name__, __doc__, __module__, __qualname__, __dict__, __annotations__ | nothing: this is plain copying |
the __wrapped__ chain | sets the link to the target that inspect follows | the chain has to be removable: inspect.unwrap |
| calling convention | nothing | the wrapper takes (*args, **kwargs), and name checking stays with the target |
The first two rows are about what people and tools will see. The third is about what happens on a call — and its absence from the list is the answer to "why did the typo get through".
What wraps genuinely repairs:
@naive
def pnaive(): return 1
@careful
def pcareful(): return 1
pickle.dumps(pnaive) # fails: Can't pickle local object ...wrapper
pickle.dumps(pcareful) # fineThe exception type depends on the version, which matters if you catch it by
type: AttributeError on 3.11, 3.12 and 3.13, PicklingError on 3.14. The
wording changed in 3.13 too — Can't get local object instead of
Can't pickle local object.
pickle locates a function by __module__ plus __qualname__. For an
un-wrapped wrapper that is naive.<locals>.wrapper — a path leading nowhere.
wraps copies both attributes, and the function becomes addressable again. The
same machinery is what lets multiprocessing and task queues ship a function
by name.
Deeper: peeling the layers — inspect.unwrap and a forged signature
The __wrapped__ attribute that wraps sets automatically is a chain, and two
different walks travel along it. You need to know the difference on the day the
documentation lies about the call.
The first walk is inspect.unwrap. It takes off every layer:
Get the object wrapped by func. It follows the chain of __wrapped__
attributes returning the last object in the chain. […] For example, signature
uses this to stop unwrapping if any object in the chain has a __signature__
attribute defined. ValueError is raised if a cycle is encountered.
Checked on three wrappers over one function
(bench/introspection/unwrap_and_signature.py, identical output on 3.11–3.14;
here and below the output is abridged to the relevant lines, and its labels are
translated):
1) the __wrapped__ chain top down: ['c', 'b', 'a', 'ORIGINAL']
and all three are called 'target': ['target', 'target', 'target']
2) inspect.unwrap(three) is target: True
one __wrapped__ takes off only a layer: its layer: b
3) unwrap(..., stop=layer=='a') stopped at layer: a
4) a cycle in __wrapped__ -> ValueError: wrapper loop when unwrapping <function loop_a at 0x...>
(Labels translated from the script's output.) Three wrappers, three identical
__name__s — so the layer cannot be told by name, but it can by __wrapped__,
and unwrap reaches the bottom in one call. stop= halts it at the layer you
want.
The second walk lives inside inspect.signature, and it stops earlier if a layer
has a __signature__. The documentation calls that attribute an implementation
detail:
If the passed object has a __signature__ attribute, we may use it to create the
signature. The exact semantics are an implementation detail and are subject to
unannounced changes. Consult the source code for current semantics.
"Implementation detail" does not mean "has changed" here: the wording and the behaviour match line for line on 3.11, 3.12, 3.13 and 3.14.
What it does mean is this. A wrapper that gives itself a __signature__ shows
tools whatever signature it likes, and follow_wrapped=False does not save
you — the attribute sits on the wrapper itself and wins in both cases:
6) a wrapper with __signature__:
signature(lying) (count: int, *, label: str = '?') -> None
signature(lying, follow_wrapped=False) (count: int, *, label: str = '?') -> None
__wrapped__ still points at target: True
but unwrap still reaches target: True
That last line is exactly the difference between the two walks: __signature__
stops signature and does not stop unwrap.
What this ends in, in practice:
8) str(inspect.signature(lying)) -> "(count: int, *, label: str = '?') -> None"
bind(count=1, label='x') passed against the FORGED signature
and the real call lying(count=1, label='x') -> TypeError: target() got an unexpected keyword argument 'count'
Signature.bind — the very "will these arguments fit" check that validators and
routers lean on — consults the forgery and lets through a call that will fail.
What did not lie here is the code object: for the wrapper above, the real
parameters remained ('a', 'kw').
The rule that follows is worth writing with its limit attached. For a
function written in Python it runs like this: __wrapped__ and
__signature__ are for people and documentation, while __code__ shows the
parameters of that function's own body. For a wrapper declared as
(*args, **kwargs) that is exactly what you want: it makes plain that the
wrapper accepts anything, while signature is busy describing the target.
Outside that case the rule does not apply — not because it gives a wrong answer
but because there is nothing to apply it to. A run of
bench/introspection/code_object_limits.py shows where the edge is:
len (a builtin) AttributeError
an object with __call__ AttributeError
partial(target, 1) AttributeError
dict.get (a method of a C type) AttributeError
All four are callable and none of them has a __code__ at all: only a function
written in Python has one. And even where it exists it describes the body, not
the call: a bound method still lists self, which the caller never passes, and
for the wrapper above there is no sign that it adds flag=True to the
target's call. Neither __code__ nor signature knows about that.
So "go to the code object for the truth" carries two conditions, and both are
required: it is about a Python function and about its own body. For an
arbitrary callable — a builtin, an instance with __call__, a partial, a
method of a C type — there is no such model at all, and the question of what it
really accepts is settled by the documentation and by calling it, not by
introspection.
Deeper: an async decorator and coroutine-ness
The commonest breakage when moving a decorator to async def looks like this:
the wrapper is synchronous, the target is a coroutine function, wraps is in
place, and everything seems to work. What does not work is this
(bench/introspection/async_decorator.py):
1) iscoroutinefunction(job) : True
iscoroutinefunction(broken): False
2) job.__code__.co_flags & CO_COROUTINE : True
broken.__code__.co_flags & CO_COROUTINE: False
The reason is where the marker lives. Being a coroutine function is a flag on
the code object (CO_COROUTINE, 0x0080), and functools.wraps copies
attributes of the function. It cannot reach the flag by definition, and
__wrapped__ does not help here: iscoroutinefunction does not follow the
chain.
7) iscoroutinefunction(broken) : False
iscoroutinefunction(inspect.unwrap(broken)): True
What breaks is not the decorator but whoever picks a path by that marker:
3) dispatch(job, 21) -> 42
dispatch(broken, 21)-> coroutine <coroutine object job at 0x...>
a coroutine object left the function instead of a number: it was never run,
and on collection it will give a RuntimeWarning 'was never awaited'
(Labels translated from the script's output.) So the mistake does not fail at the
point of decoration and does not fail at the point of call. It surfaces as a
garbage-collector warning somewhere else entirely — precisely the class of bug
taken apart in the async lesson.
There are two fixes. The portable one is to make the wrapper async def, and
then the compiler sets the flag. From 3.12 there is a second, for when the
wrapper has to stay synchronous:
Decorator to mark a callable as a coroutine function if it would not otherwise
be detected by iscoroutinefunction. […] When possible, using an async def
function is preferred.
It fixes the answer without touching the flag, and that shows:
3.12.3 and newer
5) inspect.markcoroutinefunction available: True
iscoroutinefunction(marked): True
and the code flag is still NOT a coroutine: False
the marker is an ordinary attribute: True
On 3.11 it does not exist at all, and the only portable route there is
async def.
If a decorator has to work over both plain and coroutine functions, the shape of the wrapper must be chosen from the target rather than from faith:
def deco(f):
if inspect.iscoroutinefunction(f):
@functools.wraps(f)
async def wrapper(*a, **kw): return await f(*a, **kw)
else:
@functools.wraps(f)
def wrapper(*a, **kw): return f(*a, **kw)
return wrapperChecked: the marker survives in both directions, and both calls work.
Separately, if you meet this in someone else's code: from 3.14
asyncio.iscoroutinefunction raises a DeprecationWarning and is slated for
removal in 3.16 — the one to call is inspect.iscoroutinefunction.
Deeper: your decorator on top of staticmethod and classmethod
Both built-ins return descriptors rather than functions — objects that sit
on the class and intervene in attribute access; what they are, and why it is
@classmethod specifically that breaks, is covered in the
Descriptors lesson. The behaviour is
asymmetric, and the asymmetry is measured rather than deduced:
def trace(f):
@functools.wraps(f)
def w(*a, **kw): return f(*a, **kw)
return w
class A:
@trace
@staticmethod
def f(x): return x * 2
A.f(3) # 6 — works
class C:
@trace
@classmethod
def f(cls, x): return x * 3
# the class is built in silence
C.f(3) # TypeError: 'classmethod' object is not callableOne line of documentation accounts for it: "Changed in version 3.10: Static
methods are now callable". classmethod has no such line — a classmethod
object cannot be called, so trace's attempt at f(*a, **kw) fails.
The moment it fails is worth remembering separately: not while the class body
runs, but on the first call. The class is built without a complaint —
functools.wraps copies attributes through try/except and does not trip
over a descriptor. So the import succeeds, tests that only import succeed too,
and what breaks is the first real call.
The rule that follows needs no exceptions: put @staticmethod and
@classmethod outermost. Your decorator then receives a plain function, and
the descriptor is built on top of the result:
class B:
@staticmethod
@trace
def f(x): return x * 2
B.f(3) # 6Deeper: @classmethod over @property
3.9 taught classmethod to wrap other descriptors, 3.11 deprecated it, 3.13
removed it. It was removed in a way that does not raise:
class A:
@classmethod
@property
def v(cls): return "the value"
print(A.v)| version | what it prints |
|---|---|
| 3.11 | 'the value' |
| 3.12 | 'the value' |
| 3.13 | <bound method v of <class '__main__.A'>> |
| 3.14 | <bound method v of <class '__main__.A'>> |
No exception is raised. What comes back is a value of a different type, and it travels on — into an f-string, into JSON, into a comparison — surfacing there rather than here. This is the worst kind of upgrade breakage, and the only defence is not to build chains out of built-in descriptors.
Deeper: what a layer costs
A *args, **kwargs wrapper is one extra Python call plus packing the arguments
into a tuple and a dict. Measured on 3.13.7, best of
a hundred interleaved rounds of 20,000 calls:
The accent segment marks the function's own work — watch it shrink to a sliver on the left.
The same figures as numbers:
| what we call | nanoseconds per call |
|---|---|
| undecorated function | 17.3 |
| one layer | 53.5 |
| two layers | 89.7 |
| three layers | 125.6 |
Each layer adds roughly 36 nanoseconds. For an endpoint that talks to a database this is noise; for a function in a hot loop, three decorators mean seven eighths of the time is spent approaching the work rather than doing it. On 3.14.7 the linearity is the same: 16.5 — 59.6 — 101.7 — 145.2. That quartet must not be compared with the previous one: the builds differ by more than the compiler. What can be compared is neighbours within one quartet — and the linearity there is identical.
The same arithmetic shows what the C wrapper around functools.lru_cache buys:
a cache that costs more than the computation is worthless. One caveat about
that “why”: a Python implementation lives in Lib/functools.py too, and the C
version from _functools replaces it at the end of the module. The measurement
explains why the replacement pays off — it does not prove what the CPython
authors intended.
Deeper: version history
| Version | Change | What it means for your code |
|---|---|---|
| 2.4 | PEP 318: the @ syntax. Before it the transformation was written after the function body — «places the actual transformation after the function body» — which, for a long function, put it far from that function's interface. | The mechanism appears |
| 3.9 | PEP 614: any expression is allowed after @. The old grammar demanded a dotted name with an optional call. | @obj[0].method becomes legal |
| 3.9 | classmethod learns to wrap other descriptors, property among them. | Arrives, to be removed later |
| 3.10 | staticmethod becomes directly callable and gains __wrapped__. From here on, someone else's decorator on top of @staticmethod stops failing — but only that one. | Asymmetry with classmethod |
| 3.11 | Descriptor-wrapping by classmethod is deprecated. | A warning |
| 3.12 | __type_params__ joins WRAPPER_ASSIGNMENTS, so the type parameters of the new generics syntax carry over to the wrapper too. | wraps copies more |
| 3.13 | Descriptor-wrapping by classmethod is removed. Nothing crashes: A.v starts returning a bound method instead of the value. | A silent type change |
| 3.14 | WRAPPER_ASSIGNMENTS carries __annotate__ where __annotations__ used to be (PEP 649): what is copied is the function that computes annotations, not a finished dict. | Deferred annotations |
How to answer in an interview
The short answer: @d above def f is f = d(f), run once, at the moment
the interpreter reaches def. With several decorators the expressions after
@ are evaluated top-down and applied bottom-up. functools.wraps makes the
wrapper look like the target rather than behave like it: inspect reports the
target's signature only because it walks __wrapped__, while the wrapper
itself still accepts *args, **kwargs.
That is enough to answer correctly. Beyond it is what you add if the interviewer digs.
If the interviewer digs deeper
What separates a good answer: name the two phases apart — evaluation and
application run in opposite directions, and confusing the two makes the whole
answer fall apart.
And say what wraps is actually for: not help(), but pickle and
multiprocessing, which look a function up by __module__ plus __qualname__
— and for a wrapper built without wraps that path leads inside a closure,
where nothing can be found.
Next they ask
And a decorator with arguments — is that a different mechanism?
The same one. After @ there is an expression, and its result is called with the
function; if the expression is itself a call, there are simply two calls — the
factory first, then the decorator it produced.
Your own decorator on top of @classmethod — what breaks?
classmethod and staticmethod return descriptors, not functions: the
wrapper receives an object that intervenes in attribute access, not a callable.
The behaviour is asymmetric between those two, and the asymmetry does not follow
from general reasoning — it is checked by running it.
Common misconceptions
“A decorator runs on every call to the function.”
What runs on every call is the WRAPPER the decorator returned. The decorator itself ran once, at def. A counter shows it: the decorator's body appends one line to the log no matter how many times the function is later called. The consequence is not about speed but about side effects — reading an environment variable inside a decorator happens when the module is imported.
“Decorators are applied top-down.”
They are applied bottom-up; what happens top-down is the EVALUATION of the expressions — two distinct phases, both observable. The two-factory experiment prints: “evaluated expression upper”, “evaluated expression lower”, “applied lower”, “applied upper”. The 3.13 bytecode agrees: LOAD_NAME d1 precedes the evaluation of d2(arg), and the two CALLs run in reverse.
“functools.wraps makes the wrapper indistinguishable from the original.”
It copies six attributes — five on 3.11, where __type_params__ is not on the list yet — and sets __wrapped__. It does not change the signature: inspect.signature(c) reports the target only because inspect itself follows __wrapped__. Ask it not to — inspect.signature(c, follow_wrapped=False) — and (*a, **kw) is back. The wrapper still accepts anything: a stray keyword argument is rejected not at the wrapper's boundary but by the target itself, once it has already arrived there.
“wraps is cosmetics for help().”
It also repairs addressability. pickle looks a function up by __module__ and __qualname__; for a wrapper without wraps that is naive.<locals>.wrapper, and serialisation fails — AttributeError on 3.11–3.13, PicklingError on 3.14. With wraps it succeeds. multiprocessing and every task queue that ships a function by name depend on this.
“The order of @staticmethod and my own decorator does not matter as long as both are there.”
It matters, and differently for two similar built-ins. @trace over @staticmethod has worked since 3.10 (“Static methods are now callable”), while @trace over @classmethod raises TypeError: 'classmethod' object is not callable on the first call, not while the class is built — verified on 3.11, 3.12, 3.13 and 3.14. The rule without exceptions: put @staticmethod and @classmethod outermost.
“Decorators are free — a wrapper is just a call.”
Just a call costs what a call costs. Measured on 3.13.7: 17.3 ns undecorated, 53.5 ns with one layer, 125.6 ns with three. Each layer is about 36 ns of *args/**kwargs packing and an extra frame. Invisible in an endpoint that hits a database; in a hot loop, three layers mean most of the time goes into approaching the work rather than doing it.
“Python removes deprecated things loudly, so upgrading is safe.”
Not always. @classmethod over @property worked in 3.9–3.12, was deprecated in 3.11 and removed in 3.13 — yet in 3.13 accessing A.v raises nothing and returns <bound method> instead of the value. The type changed silently, and it will surface further down: in an f-string, in JSON, in a comparison.
Practice
Two exercises. Answer first, then check against the real output: in both, the right answer comes from a recorded run rather than from an editor.
Practice · predict the output
import functools
import inspect
def deco(fn):
@functools.wraps(fn)
def wrapper(*a, **kw):
return fn(*a, **kw)
return wrapper
def target(x: int, y: str = "s") -> bool:
return True
three = deco(deco(deco(target)))
print(inspect.signature(three))
print(inspect.signature(three, follow_wrapped=False))Practice · estimate
Knowledge check
A module is imported for one function. It contains a decorator that, when applied, reads an environment variable and opens a connection. When does that happen?
What measured this
The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
@dabovedef fis neither a declaration nor magic. It isf = d(f): the function is handed to the decorator and the name is rebound to the result. That assignment happens once, at the moment the interpreter reachesdef. On a call it is the returned wrapper that runs; the decorator itself costs nothing by then — it is done.- Hence the main consequence: with several decorators the expressions are evaluated top-down and applied bottom-up. Almost every surprise grows out of that pair: the nesting order of wrappers, why
@classmethodunder a decorator of your own breaks on the first call rather than while the class is built, and whyfunctools.wrapsis not politeness but a precondition for your debugger, your documentation andpickleto work at all. - Beyond that come numbers, versions and limits.
wrapsmakes the wrapper look like the target, not behave like it:inspect.signaturereports the target's signature only because it walks__wrapped__, while the wrapper itself still accepts*args, **kwargs. A layer has a measurable price: 17.3 ns undecorated against 53.5 ns with one layer on 3.13.7, about 36 ns per layer. And the built-in decorators depend on the version: astaticmethodobject is callable from 3.10, aclassmethodobject is not, and from 3.13@classmethodover@propertyyields a bound method rather than the value.
In fact
- What runs on every call is the WRAPPER the decorator returned. The decorator itself ran once, at
def. A counter shows it: the decorator's body appends one line to the log no matter how many times the function is later called. The consequence is not about speed but about side effects — reading an environment variable inside a decorator happens when the module is imported. - They are applied bottom-up; what happens top-down is the EVALUATION of the expressions — two distinct phases, both observable. The two-factory experiment prints: “evaluated expression upper”, “evaluated expression lower”, “applied lower”, “applied upper”. The 3.13 bytecode agrees:
LOAD_NAME d1precedes the evaluation ofd2(arg), and the twoCALLs run in reverse. - It copies six attributes — five on 3.11, where
__type_params__is not on the list yet — and sets__wrapped__. It does not change the signature:inspect.signature(c)reports the target only becauseinspectitself follows__wrapped__. Ask it not to —inspect.signature(c, follow_wrapped=False)— and(*a, **kw)is back. The wrapper still accepts anything: a stray keyword argument is rejected not at the wrapper's boundary but by the target itself, once it has already arrived there. - It also repairs addressability.
picklelooks a function up by__module__and__qualname__; for a wrapper withoutwrapsthat isnaive.<locals>.wrapper, and serialisation fails —AttributeErroron 3.11–3.13,PicklingErroron 3.14. Withwrapsit succeeds.multiprocessingand every task queue that ships a function by name depend on this. - It matters, and differently for two similar built-ins.
@traceover@staticmethodhas worked since 3.10 (“Static methods are now callable”), while@traceover@classmethodraisesTypeError: 'classmethod' object is not callableon the first call, not while the class is built — verified on 3.11, 3.12, 3.13 and 3.14. The rule without exceptions: put@staticmethodand@classmethodoutermost. - Just a call costs what a call costs. Measured on 3.13.7: 17.3 ns undecorated, 53.5 ns with one layer, 125.6 ns with three. Each layer is about 36 ns of
*args/**kwargspacking and an extra frame. Invisible in an endpoint that hits a database; in a hot loop, three layers mean most of the time goes into approaching the work rather than doing it. - Not always.
@classmethodover@propertyworked in 3.9–3.12, was deprecated in 3.11 and removed in 3.13 — yet in 3.13 accessingA.vraises nothing and returns<bound method>instead of the value. The type changed silently, and it will surface further down: in an f-string, in JSON, in a comparison.
By version
- 2.4
- PEP 318: the
@syntax. Before it the transformation was written after the function body — «places the actual transformation after the function body» — which, for a long function, put it far from that function's interface.< - 3.9
- PEP 614: any expression is allowed after
@. The old grammar demanded a dotted name with an optional call.< - 3.9
classmethodlearns to wrap other descriptors,propertyamong them.<- 3.10
staticmethodbecomes directly callable and gains__wrapped__. From here on, someone else's decorator on top of@staticmethodstops failing — but only that one.<- 3.11
- Descriptor-wrapping by classmethod is deprecated.<
- 3.12
__type_params__joinsWRAPPER_ASSIGNMENTS, so the type parameters of the new generics syntax carry over to the wrapper too.<- 3.13
- Descriptor-wrapping by classmethod is removed. Nothing crashes:
A.vstarts returning a bound method instead of the value.< - 3.14
WRAPPER_ASSIGNMENTScarries__annotate__where__annotations__used to be (PEP 649): what is copied is the function that computes annotations, not a finished dict.<
What is covered
- Base: what the `@` sign does
- Mechanism 1: one call at definition time
- Mechanism 2: top-down, then bottom-up
- Mechanism 3: the name is never bound along the way
- Mechanism 4: a decorator with arguments is two calls
- Mechanism 5: what `functools.wraps` does, and what it does not
- Deeper: peeling the layers — `inspect.unwrap` and a forged signature
- Deeper: an async decorator and coroutine-ness
- Deeper: your decorator on top of `staticmethod` and `classmethod`
- Deeper: `@classmethod` over `@property`
- Deeper: what a layer costs
- Deeper: version history
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
- What measured this
Sources & further reading
9 SOURCES
- PEP 318 — Decorators for Functions and MethodsPEP. Status Final, Python 2.4, created 2003-06-05. The source of both the syntax and the reason for the application order: «The rationale for the order of application (bottom to top) is that it matches the usual order for function-application».https://peps.python.org/pep-0318/
- PEP 614 — Relaxing Grammar Restrictions On DecoratorsPEP. Status Final, Python 3.9. Old grammar: `decorator: '@' dotted_name [ '(' [arglist] ')' ] NEWLINE`; new one: `'@' namedexpr_test NEWLINE`. This is where the version comes from: any expression is allowed after @ starting with 3.9.https://peps.python.org/pep-0614/
- The language reference — function definitionsOfficial documentation. The whole semantics in four sentences: «Decorator expressions are evaluated when the function is defined, in the scope that contains the function definition», plus the caveat that makes the familiar equivalence inexact: «except that the original function is not temporarily bound to the name func».https://docs.python.org/3.13/reference/compound_stmts.html#function-definitions
- functools — update_wrapper and wrapsOfficial documentation. The list of copied attributes, the automatic `__wrapped__` added «to allow access to the original function for introspection», and the warning that without it «the metadata of the returned function will reflect the wrapper definition rather than the original».https://docs.python.org/3.13/library/functools.html
- Built-in functions — staticmethod and classmethodOfficial documentation. «Changed in version 3.10: Static methods are now callable» — and the absence of any such line under classmethod. Also: «Deprecated since version 3.11, removed in version 3.13: Class methods can no longer wrap other descriptors such as property()».https://docs.python.org/3.13/library/functions.html
- Lib/functools.py — WRAPPER_ASSIGNMENTS and update_wrapperCPython source code. Lines 34-35: the list of copied attributes. Lines 45-50: the copy loop wrapped in try/except AttributeError, so an attribute the target lacks is skipped silently. The line setting `__wrapped__` closes the same function. CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Lib/functools.py
- Lib/functools.py in 3.14 — the same list after PEP 649CPython source code. Lines 36-37: `__annotate__` now stands where `__annotations__` used to. Not cosmetic: what gets copied is the function that computes annotations, not a finished dict, because 3.14 evaluates annotations lazily. CPython tag 3.14.0.https://github.com/python/cpython/blob/v3.14.0/Lib/functools.py
- PEP 649 — Deferred Evaluation Of Annotations Using DescriptorsPEP. The document behind `__annotate__` appearing in WRAPPER_ASSIGNMENTS in 3.14.https://peps.python.org/pep-0649/
- inspect — unwrap, signature, markcoroutinefunctionOfficial documentation. The three places the unwrapping and async-decorator sections rest on. On unwrapping: "It follows the chain of `__wrapped__` attributes returning the last object in the chain." On the forged signature: "If the passed object has a `__signature__` attribute, we may use it to create the signature. The exact semantics are an implementation detail" — the wording was compared verbatim on 3.11, 3.12, 3.13 and 3.14 and is the same in all four. On the marker: "Decorator to mark a callable as a coroutine function if it would not otherwise be detected by `iscoroutinefunction`… When possible, using an `async def` function is preferred", added in 3.12.https://docs.python.org/3.14/library/inspect.html