Skip to content
Path to Engineer
All lessons
PythonPython internals13 min read

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.

Day 44 of the 488-day pathway. Published in full — nothing held back.

Decorators — the pattern, functools.wraps, decorators with arguments

The sentence to own: @deco above a def is exactly f = 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 — @deco above a def is exactly f = 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, and inspect.signature reports (*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". wraps also sets __wrapped__, which is what lets inspect.signature follow 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 — @a over @b gives a(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 def is 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

  1. Write log_calls without @wraps. Print __name__, __doc__ and inspect.signature. Then add @wraps and print them again.
  2. Decorate a FastAPI-style function without @wraps and inspect the signature the framework would see. That is the bug.
  3. Write a wrapper that forgets to return the result. Watch every call give None.
  4. Write @retry(times=3, delay=0.1) with all three levels, and test it against a function that fails twice then succeeds.
  5. Write a @timing decorator and apply it to something slow.
  6. Stack two decorators that print on entry and exit. Predict the output order before running.
  7. Write a decorator supporting both @deco and @deco(option=True).
  8. Decorate a method and confirm self arrives in args[0].
  9. Put @classmethod in the wrong position and read the error.
  10. Write a class-based decorator that counts calls, then try it on a method. Reproduce the unbound-self failure.

Exit questions

Answer aloud, no notes.

  1. Rewrite @deco as plain assignment.
  2. Name the three things every wrapper must do.
  3. When does the decorator run? Give one consequence.
  4. Name five things that break without @wraps.
  5. What is __wrapped__ for?
  6. Why do argument-taking decorators need three levels?
  7. How do you tell how many levels you need?
  8. Stacking: application order and execution order.
  9. Give a case where the order is a security bug.
  10. Where must @classmethod sit in a stack?
  11. Why does a class-based decorator fail on a method?
  12. 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