Deep Engineering
Intermediate·Published·3.11 · 3.12 · 3.13 · 3.14·45 MIN

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.

Where to start
Before this lesson it is enough to understand
  • 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 *args and **kwargs.
You do not need to know in advance
  • functools.wraps, __wrapped__, __qualname__, inspect.signature;
  • bytecode, descriptors, how staticmethod and classmethod are 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

PYTHON
@d
def f(): ...

mean exactly the same as these:

PYTHON
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

language contractLanguage guarantee: @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:

PYTHON
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 once

The 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

implementation detail · CPython 3.13The bytecode shows the same order, in the instruction names of one specific version.

The same reference gives the equivalence:

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

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

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

PYTHON
seen = []
 
def probe(f):
    seen.append("h" in globals())
    return f
 
@probe
def h(): pass
 
print(seen)   # [False] — the name h does not exist yet

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

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

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

PYTHON
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') -> bool

And here is the part that rarely gets said out loud:

PYTHON
print(inspect.signature(c, follow_wrapped=False))   # (*a, **kw) -> bool

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

jobwhat wraps doeswhat stays as it was
metadatacopies __name__, __doc__, __module__, __qualname__, __dict__, __annotations__nothing: this is plain copying
the __wrapped__ chainsets the link to the target that inspect followsthe chain has to be removable: inspect.unwrap
calling conventionnothingthe 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:

PYTHON
@naive
def pnaive(): return 1
 
@careful
def pcareful(): return 1
 
pickle.dumps(pnaive)     # fails: Can't pickle local object ...wrapper
pickle.dumps(pcareful)   # fine

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

inspect — unwrap

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.

inspect — signature

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

inspect — markcoroutinefunction

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:

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

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

PYTHON
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 callable

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

PYTHON
class B:
    @staticmethod
    @trace
    def f(x): return x * 2
 
B.f(3)          # 6

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

PYTHON
class A:
    @classmethod
    @property
    def v(cls): return "the value"
 
print(A.v)
versionwhat 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 callnanoseconds per call
undecorated function17.3
one layer53.5
two layers89.7
three layers125.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

measured observationbench/decorators/cost.py, CPython 3.13.7. Absolute nanoseconds depend on the machine; what matters is the cost of a layer relative to the call.
VersionChangeWhat it means for your code
2.4PEP 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.9PEP 614: any expression is allowed after @. The old grammar demanded a dotted name with an optional call.@obj[0].method becomes legal
3.9classmethod learns to wrap other descriptors, property among them.Arrives, to be removed later
3.10staticmethod 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.11Descriptor-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.13Descriptor-wrapping by classmethod is removed. Nothing crashes: A.v starts returning a bound method instead of the value.A silent type change
3.14WRAPPER_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

Next they ask

And a decorator with arguments — is that a different mechanism?

Short answer

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.

Next they ask

Your own decorator on top of @classmethod — what breaks?

Short answer

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

Claim

“A decorator runs on every call to the function.”

Actually

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.

Claim

“Decorators are applied top-down.”

Actually

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.

Claim

functools.wraps makes the wrapper indistinguishable from the original.”

Actually

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.

Claim

wraps is cosmetics for help().”

Actually

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.

Claim

“The order of @staticmethod and my own decorator does not matter as long as both are there.”

Actually

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.

Claim

“Decorators are free — a wrapper is just a call.”

Actually

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.

Claim

“Python removes deprecated things loudly, so upgrading is safe.”

Actually

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

The wrapper is built with functools.wraps, three layers deep. What does this code print?
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

A function that does one addition — and the same function under three plain *args, **kwargs wrappers with functools.wraps. How many times more expensive is the three-layer call than the undecorated one?
times

Knowledge check

Question 1 of 6

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.

Sources & further reading

9 SOURCES

  1. 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/
  2. 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/
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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/
  9. 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