Python decorators, from the pattern up
Decorators built from first principles: the closure underneath, why functools.wraps matters, and how decorators that take arguments work.
Decorators — the pattern, functools.wraps, decorators with arguments
The sentence to own:
@decoabove adefis exactlyf = deco(f)after it. That is the whole feature. Everything else is closures (Day 043) and function objects (Day 042).Decorators are the most-used advanced feature in Python backends —
@app.get,@property,@lru_cache,@pytest.fixture,@task— and the most commonly written badly, because the naive version silently destroys the function's identity and signature.
Part 1 — The pattern
def log_calls(fn): # takes a function
def wrapper(*args, **kwargs): # accepts ANY signature (Day 040)
print(f"calling {fn.__name__}")
result = fn(*args, **kwargs) # `fn` comes from the closure (Day 043)
print(f"{fn.__name__} returned")
return result # RETURN IT. Forgetting this is the classic bug.
return wrapper # returns a replacement function
@log_calls
def add(a, b): return a + b
# IS EXACTLY:
def add(a, b): return a + b
add = log_calls(add)
THE THREE THINGS A WRAPPER MUST DO, AND EACH IS A REAL BUG WHEN MISSED:
1. accept *args, **kwargs ⇒ or it only works for one signature
2. RETURN fn's RESULT ⇒ or every decorated function returns None
3. be wrapped in @wraps ⇒ or the function loses its identity (Part 2)
⚠ The decorator runs at import time. @app.get("/users") executes when the module is
imported, not when a request arrives — which is exactly how the route gets registered, and also
why a decorator with an expensive or failing side effect breaks your application at startup rather
than in a request.
Part 2 — functools.wraps — and what breaks without it
@log_calls
def add(a, b):
"""Add two numbers."""
add.__name__ # ⚠️ "wrapper"
add.__doc__ # ⚠️ None
inspect.signature(add) # ⚠️ (*args, **kwargs) — YOUR SIGNATURE IS GONE
from functools import wraps
def log_calls(fn):
@wraps(fn) # copies __name__, __doc__, __module__, __qualname__,
def wrapper(*args, **kwargs): # __dict__, __annotations__ — and sets __wrapped__
return fn(*args, **kwargs)
return wrapper
WHAT ACTUALLY BREAKS WITHOUT @wraps — these are not cosmetic:
· FastAPI/Flask see (*args, **kwargs) ⇒ your route loses every parameter and its schema
· Sphinx and help() document "wrapper(*args, **kwargs)" for every decorated function
· pytest cannot collect or introspect fixtures properly
· tracebacks, logs and profiler output all say "wrapper"
· @singledispatch and anything reading __annotations__ stops working
· __wrapped__ is what lets inspect.signature FOLLOW the chain back to the real function
@wraps is one line and it is never optional. If a decorated FastAPI endpoint mysteriously
takes no parameters, this is the first thing to check.
⚠ @wraps copies metadata but does not truly change the wrapper's parameters — it sets
__wrapped__ so inspect.signature follows it, which covers almost everything. For the rare case
where a library reads __code__ directly, you need inspect.signature-based construction or a
library like decorator/wrapt.
Part 3 — Decorators with arguments — three levels
def retry(times=3, delay=1.0): # LEVEL 1: takes the ARGUMENTS, returns the decorator
def decorator(fn): # LEVEL 2: takes the FUNCTION, returns the wrapper
@wraps(fn)
def wrapper(*args, **kwargs): # LEVEL 3: takes the CALL
for attempt in range(times):
try:
return fn(*args, **kwargs)
except Exception:
if attempt == times - 1: raise
time.sleep(delay * 2 ** attempt)
return wrapper
return decorator
@retry(times=5, delay=0.5)
def fetch(url): ...
# IS: fetch = retry(times=5, delay=0.5)(fetch) ← TWO calls. That is why three levels.
THE RULE THAT REMOVES THE CONFUSION:
@deco ⇒ deco is called with THE FUNCTION ⇒ 2 levels
@deco(...) ⇒ deco(...) is called FIRST, and its RESULT is called with the function
⇒ 3 levels
⇒ count the parentheses at the call site; that tells you how many levels you need.
Supporting both @deco and @deco() is a common library requirement:
def deco(fn=None, *, option=False):
if fn is None: # called as @deco(option=True)
return lambda f: deco(f, option=option)
@wraps(fn)
def wrapper(*a, **kw): ...
return wrapper
# CLASS-BASED — when the decorator needs real state or several methods (Day 031's __call__)
class CountCalls:
def __init__(self, fn):
self.fn = fn; self.count = 0
wraps(fn)(self) # works on instances too
def __call__(self, *a, **kw):
self.count += 1
return self.fn(*a, **kw)
⚠ A class-based decorator on a method breaks, because the instance is not a descriptor and
self is never bound (Day 032). You need a __get__ returning partial(self.__call__, obj) — or,
far more simply, use a function-based decorator for methods.
Part 4 — Stacking, methods, and real decorators
@a
@b
def f(): ...
# APPLIED BOTTOM-UP: f = a(b(f))
# so at CALL time, a's wrapper runs FIRST (outermost), then b's, then f.
The ordering matters in practice: authentication must be outside caching (or you serve a
cached response to an unauthorised user), and @app.get must be outermost of all (it registers
whatever it is given, so anything below it is what gets registered).
# Decorating methods — `self` just arrives as args[0]:
class Service:
@log_calls
def run(self, x): ... # wrapper(*args) receives (self, x). Nothing special needed.
# ⚠️ ORDER WITH classmethod/staticmethod: they must be OUTERMOST (Day 045)
class C:
@classmethod
@log_calls
def make(cls): ... # correct
| Decorator you already use | What it does |
|---|---|
@property, @classmethod, @staticmethod |
descriptors (Day 032) |
@functools.lru_cache(maxsize=128) |
memoise — ⚠ bound the size (Days 028, 051) |
@functools.wraps |
this page |
@app.get("/x") / @app.route |
registers, then returns the function unchanged |
@pytest.fixture, @pytest.mark.parametrize |
test machinery |
@dataclass |
a class decorator — rewrites the class |
@contextlib.contextmanager |
Day 048 |
Decorators are not only for functions — @dataclass decorates a class, receiving the class
object and returning it (usually the same object, with methods added). Same pattern, different
input.
⚠ The debugging cost is real: decorators add a stack frame to every traceback, they can hide where an exception came from, and a chain of four is genuinely hard to reason about. They are a tool for cross-cutting concerns — logging, retry, caching, auth, timing — and a bad tool for business logic.
Common mistakes
| Mistake | Correction |
|---|---|
Forgetting @wraps |
Breaks FastAPI, docs, pytest, tracebacks. Never optional. |
| Wrapper not returning the result | Every decorated function returns None. |
| Wrapper with a fixed signature | *args, **kwargs. |
Confusing @deco and @deco() |
Two levels vs three. Count the parentheses. |
| Getting stacking order backwards | Bottom-up application, top-down execution. |
| Caching outside authentication | Serves an authorised response to the wrong user. |
classmethod inside another decorator |
It must be outermost. |
| Class-based decorator on a method | No __get__ ⇒ self is never bound. |
| Expensive work at import time | Decorators run on import. |
lru_cache with no maxsize on user input |
Unbounded, attacker-controlled. |
| Decorating business logic | Cross-cutting concerns only. |
Interview questions
Q: What is a decorator?
A callable that takes a function and returns a replacement —
@decoabove adefis exactlyf = deco(f)after it. It's built from two things I already have: functions are objects, so they can be passed and returned, and closures let the replacement remember the original. Everything else is syntax.
Q: Why does functools.wraps matter?
Because without it the wrapper's identity replaces the function's.
__name__becomes "wrapper", the docstring is gone, andinspect.signaturereports(*args, **kwargs). That last one is the expensive part: FastAPI builds query parameters, body models and OpenAPI docs from the signature, so a decorated endpoint silently loses all its parameters. Sphinx documents every decorated function identically, pytest can't introspect fixtures, and tracebacks all say "wrapper".wrapsalso sets__wrapped__, which is what letsinspect.signaturefollow the chain back to the real function.
Q: Why do decorators with arguments need three levels?
Because
@deco(...)is two calls, not one. The decorator expression is evaluated first — that's the outer function, which receives the arguments and returns a decorator — and then that result is called with the function. So it's arguments, then function, then call. The way I keep it straight is to count the parentheses at the use site: no parentheses means two levels, parentheses mean three.
Q: In what order do stacked decorators run?
They're applied bottom-up —
@aover@bgivesa(b(f))— so at call time the outermost,a, runs first. That matters more than it sounds: an auth decorator has to be outside a caching decorator, or a cache hit returns someone else's data without ever checking authorisation. And a route decorator has to be outermost, because it registers whatever function it's handed.
Q: When would you not use a decorator?
For business logic. Decorators are excellent for cross-cutting concerns — logging, timing, retry, caching, authorisation — where the behaviour is genuinely orthogonal to what the function does. But they add a frame to every traceback, they obscure where an exception originated, and a stack of four is hard to reason about. If the wrapper needs to know what the function means, that's a sign it should be an explicit call rather than a decorator.
Q: Do decorators run at import time or call time?
The decorator itself runs at import, when the
defis executed; the wrapper body runs per call. That's what makes route registration work —@app.get("/users")registers the handler as the module loads. It also means an expensive or failing decorator breaks the application at startup, so anything slow belongs inside the wrapper, not around it.
Mini task
- Write
log_callswithout@wraps. Print__name__,__doc__andinspect.signature. Then add@wrapsand print them again. - Decorate a FastAPI-style function without
@wrapsand inspect the signature the framework would see. That is the bug. - Write a wrapper that forgets to return the result. Watch every call give
None. - Write
@retry(times=3, delay=0.1)with all three levels, and test it against a function that fails twice then succeeds. - Write a
@timingdecorator and apply it to something slow. - Stack two decorators that print on entry and exit. Predict the output order before running.
- Write a decorator supporting both
@decoand@deco(option=True). - Decorate a method and confirm
selfarrives inargs[0]. - Put
@classmethodin the wrong position and read the error. - Write a class-based decorator that counts calls, then try it on a method. Reproduce the
unbound-
selffailure.
Exit questions
Answer aloud, no notes.
- Rewrite
@decoas plain assignment. - Name the three things every wrapper must do.
- When does the decorator run? Give one consequence.
- Name five things that break without
@wraps. - What is
__wrapped__for? - Why do argument-taking decorators need three levels?
- How do you tell how many levels you need?
- Stacking: application order and execution order.
- Give a case where the order is a security bug.
- Where must
@classmethodsit in a stack? - Why does a class-based decorator fail on a method?
- When is a decorator the wrong tool?
Articulation drill
Record two minutes: "Write a retry decorator, out loud."
Start with the shape and say why: three levels, because @retry(times=3) is two calls — the
arguments first, then the function. Then the wrapper: *args, **kwargs so it works on any
signature, a loop with the attempt count, re-raise on the last attempt, and exponential backoff
with the delay doubling — and mention jitter, because in a distributed system synchronised retries
are a thundering herd (Stage 12).
Then the two details that separate someone who has shipped one from someone reciting the
pattern: "@wraps(fn), which is not cosmetic — without it inspect.signature reports
(*args, **kwargs) and a decorated FastAPI endpoint loses every parameter. And I'd only retry on
specific exceptions, never bare Exception, because retrying a 400 or a validation error is pure
waste and retrying a non-idempotent POST double-charges the customer — which is Day 010's
idempotency rule showing up in a language feature."
Previous: Day 043 · Tomorrow: Day 045 — classmethod,
staticmethod, functools.partial, and the operator module