Python, from the ground up Lesson 5 / 60

Decorators, demystified

Functions that wrap functions. The patterns you'll encounter daily, the pitfalls to know, and when to write your own.

You’ve seen @something above a function definition in Flask, FastAPI, pytest, Django, SQLAlchemy, and basically every Python library written after 2010. The @ symbol looks like it’s doing magic. It isn’t. It’s two lines of code in disguise. By the end of this lesson you’ll be able to read any decorator in any codebase and know exactly what it’s doing.

The two-line definition

A decorator is a function that takes a function and returns a function. That’s it.

from typing import Callable


def shout(fn: Callable[[str], str]) -> Callable[[str], str]:
    def wrapped(name: str) -> str:
        return fn(name).upper() + "!"
    return wrapped


def greet(name: str) -> str:
    return f"hello, {name}"


greet = shout(greet)        # wrap it
print(greet("narcis"))      # HELLO, NARCIS!

The @ syntax is just shorthand for that last assignment. These two snippets are exactly equivalent:

@shout
def greet(name: str) -> str:
    return f"hello, {name}"

# vs.

def greet(name: str) -> str:
    return f"hello, {name}"

greet = shout(greet)

Read it like that — greet = shout(greet) — and decorators stop being mysterious. Everything else is variations on this theme.

A real decorator: timing

Here is the example you’ll write in your career somewhere between five and fifty times. A decorator that measures how long a function takes.

from __future__ import annotations
import time
import functools
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


def timed(fn: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            elapsed_ms = (time.perf_counter() - start) * 1000
            print(f"{fn.__name__} took {elapsed_ms:.2f} ms")
    return wrapper


@timed
def slow_add(a: int, b: int) -> int:
    time.sleep(0.1)
    return a + b


print(slow_add(2, 3))
# slow_add took 100.43 ms
# 5

A few things worth pointing out:

  • *args, **kwargs lets the wrapper accept any signature. That’s the standard pattern for a decorator that doesn’t care what the underlying function looks like.
  • ParamSpec and TypeVar (Python 3.10+) preserve the original function’s type signature. Before 3.10 you had to choose between accurate types and a generic decorator. Now you can have both.
  • try/finally ensures the timing is printed even if the wrapped function raises.
  • @functools.wraps(fn) is doing the most important work in this snippet, and we need to talk about it.

functools.wraps: the line you must not skip

When you wrap a function, the wrapper takes over the original’s identity. __name__, __doc__, __module__, __annotations__ — all of them now belong to the wrapper, not the wrapped function. This breaks documentation tools, pytest’s test discovery, Sphinx, traceback messages, anything that introspects.

functools.wraps is a small decorator that copies those attributes from the wrapped function to the wrapper.

import functools
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


def bare(fn: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return fn(*args, **kwargs)
    return wrapper


def proper(fn: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return fn(*args, **kwargs)
    return wrapper


@bare
def f1() -> None:
    """Original docstring."""


@proper
def f2() -> None:
    """Original docstring."""


print(f1.__name__, f1.__doc__)   # wrapper None
print(f2.__name__, f2.__doc__)   # f2 Original docstring.

Use functools.wraps. Always. Every decorator you write in production code should have it. The cost is one line. The cost of forgetting it is a future colleague spending two hours wondering why their stack traces all say wrapper.

(Tangent: AI assistants are extremely good at writing decorators, because the pattern is so structured — @functools.wraps, *args, **kwargs, the inner def wrapper. The boilerplate is identical across thousands of training examples. What they consistently miss is thread-safety. If your decorator caches results in a dict, or mutates a counter, or appends to a shared list, the model will happily generate code that races. Whenever you accept AI-written decorator code that touches shared state, ask yourself: what happens if two threads call this at the same time? Usually the answer is “a RuntimeError six months from now in production.”)

Stacking decorators

Multiple decorators on one function compose bottom-up. The decorator nearest the function runs first.

@logged
@timed
def work(x: int) -> int:
    return x * 2

That’s equivalent to:

work = logged(timed(work))

timed wraps work. Then logged wraps the timed version. When you call work(3), the outermost wrapper (logged) runs first, calls inward, and unwinds outward. The order matters: if logged decided to short-circuit on bad input and not call inward, timed would never run. Stacking order changes behaviour.

Decorators with arguments — the third-order dance

This is where decorators trip people up. Sometimes you want to parameterise the decorator itself:

@retry(times=3)
def fetch(url: str) -> bytes:
    ...

retry(times=3) has to evaluate to a decorator — meaning a function that takes a function and returns a function. So retry is a function that returns a decorator. Three layers of def:

import functools
import time
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


def retry(times: int = 3, delay: float = 0.5) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def decorator(fn: Callable[P, R]) -> Callable[P, R]:
        @functools.wraps(fn)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            last_exc: BaseException | None = None
            for attempt in range(1, times + 1):
                try:
                    return fn(*args, **kwargs)
                except Exception as exc:
                    last_exc = exc
                    if attempt < times:
                        time.sleep(delay)
            assert last_exc is not None
            raise last_exc
        return wrapper
    return decorator


@retry(times=3, delay=0.1)
def flaky() -> str:
    ...

Three defs, one job each:

  1. retry(times, delay) — the outermost — captures the configuration and returns a decorator.
  2. decorator(fn) — the middle — captures the function being decorated and returns a wrapper.
  3. wrapper(*args, **kwargs) — the inner — captures each call and does the actual work.

If you stare at it long enough it stops looking strange. If you don’t stare at it long enough, you’ll keep writing @retry (no parens) and getting a confusing error. The parens are mandatory when the decorator takes arguments — even an empty @retry() — because the @ syntax requires a decorator, not a decorator factory.

Decorators in the standard library

Half a dozen built-ins you’ll use without thinking:

import functools


class Order:
    def __init__(self, total: float, vat_rate: float) -> None:
        self._total = total
        self._vat_rate = vat_rate

    @property
    def vat_amount(self) -> float:
        return self._total * self._vat_rate

    @staticmethod
    def supported_currencies() -> list[str]:
        return ["EUR", "USD", "GBP"]

    @classmethod
    def empty(cls) -> "Order":
        return cls(total=0.0, vat_rate=0.0)


@functools.cache
def fib(n: int) -> int:
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)


@functools.lru_cache(maxsize=128)
def expensive_lookup(key: str) -> dict[str, int]:
    ...
  • @property makes a method look like an attribute. order.vat_amount reads better than order.vat_amount().
  • @staticmethod and @classmethod change how self/cls is bound. Useful, occasionally.
  • @functools.cache (3.9+) memoises a function with an unbounded cache. Same as lru_cache(maxsize=None) but clearer.
  • @functools.lru_cache(maxsize=N) memoises with a bounded LRU policy. The cache key is the argument tuple; arguments must be hashable.

Caches have the thread-safety footnote I mentioned earlier. functools.lru_cache is thread-safe in CPython — the underlying dict operations are protected by the GIL — but a cache you write yourself is not, unless you put a Lock around the read-modify-write.

Patterns you’ll see daily:

# Flask
@app.route("/orders/<int:order_id>", methods=["GET"])
def get_order(order_id: int) -> dict[str, object]:
    ...

# pytest
@pytest.fixture
def db_connection() -> Iterator[Connection]:
    conn = connect()
    yield conn
    conn.close()

# tenacity
@retry(stop=stop_after_attempt(3), wait=wait_exponential())
def call_external_api() -> bytes:
    ...

# Click
@click.command()
@click.option("--country", default="IT")
def export(country: str) -> None:
    ...

You don’t need to understand the library internals to use these. You do need to know that they’re following the same decorator(fn) -> fn pattern you saw above. When something goes wrong (@app.route throws, the test fixture isn’t injected), it helps to remember that there’s no magic — just function-returning-function machinery you could write yourself in a pinch.

Class-based decorators

You can also write a decorator as a class with __call__:

import functools
from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


class CallCounter:
    def __init__(self, fn: Callable[P, R]) -> None:
        functools.update_wrapper(self, fn)
        self.fn = fn
        self.calls = 0

    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
        self.calls += 1
        return self.fn(*args, **kwargs)


@CallCounter
def ping() -> str:
    return "pong"


ping(); ping(); ping()
print(ping.calls)   # 3

Useful when the decorator needs persistent state and you want it accessible from the outside. functools.update_wrapper is the function form of @functools.wraps — same job.

When NOT to write a decorator

A decorator is a great tool when:

  • The behaviour wraps a function (before/after/around).
  • You want the same wrapping in many places.
  • The wrapping is orthogonal to what the function does (timing, caching, retries, auth checks).

It is the wrong tool when:

  • The behaviour belongs inside the function body — just write a helper.
  • You need acquire/release semantics around a block of code, not a function — use a context manager (next lesson).
  • You only ever wrap one function — just inline the logic.

The smell to watch for: a decorator with a giant configuration object and a conditional if context.something: do_a() else: do_b() inside the wrapper. That’s a function masquerading as a decorator. Refactor it.

That’s decorators. Next lesson: context managers — the with block, three ways to write your own, and the patterns that make try/finally redundant.


Citations (retrieved 2026-05-01):

Search