Decorators

Functions that wrap functions — the pattern behind routes, caching, and retries

Why

Decorators appear everywhere in real Python: Flask/FastAPI routes (@app.get), pytest fixtures (@pytest.fixture), caching (@lru_cache), retry logic, access control, and timing. You need to know how to write one, not just recognise the @ symbol.

Understanding decorators also unlocks a mental model: functions are objects, and returning a function from a function is completely normal. This is the same thinking needed for closures and higher-order design patterns.

Intuition

A decorator is a function that takes a function and returns a (usually enhanced) function. The @decorator syntax is pure sugar — @log_calls above def process(...) is identical to writing process = log_calls(process) immediately after the function definition.

The inner wrapper function is what gets called instead of the original. It can run code before and after the original, modify arguments or return values, or skip the original entirely.

Explanation
1. Writing a decorator

A decorator is a function that takes a function and returns a replacement — adding behaviour before or after the original runs. The @name syntax is shorthand for func = decorator(func).

import functools def log_calls(func): @functools.wraps(func) # preserves __name__, __doc__, signature def wrapper(*args, **kwargs): print(f"→ calling {func.__name__}") result = func(*args, **kwargs) print(f"← done {func.__name__}") return result return wrapper @log_calls def process(data): return data.upper() # Equivalent to: process = log_calls(process) # process.__name__ → "process" (not "wrapper" — thanks to @wraps)

Always use @functools.wraps(func) on the wrapper. Without it, the decorated function loses its __name__, __doc__, and signature — breaking introspection and debugging tools.

2. Decorators with arguments

When the decorator itself needs configuration (e.g. retry count, exception types), add one extra nesting level — the outermost function takes the config and returns the actual decorator.

def retry(times=3, exceptions=(Exception,)): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(times): try: return func(*args, **kwargs) except exceptions: if attempt == times - 1: raise return wrapper return decorator @retry(times=5, exceptions=(TimeoutError, ConnectionError)) def call_api(url): ...

A decorator with arguments needs one extra nesting level. Three layers: outer function accepts config → returns the decorator → decorator wraps the function.

3. @lru_cache and stacking

@cache memoizes a pure function — repeated calls with the same arguments return the cached result instantly instead of recomputing. Stacking multiple decorators applies them from innermost to outermost.

from functools import cache @cache # Python 3.9+ — unbounded memoisation def fib(n): if n < 2: return n return fib(n-1) + fib(n-2) # fib(40) computed once, then O(1) from cache # Stacking — applied bottom-up @A @B @C def f(): ... # Equivalent to: f = A(B(C(f))) # C wraps first, B wraps that, A wraps that

@cache memoizes pure functions — identical arguments return the cached result. Requires hashable arguments. When stacking, the decorator closest to the function applies first.