Contents
12 Chapter

Decorator patterns

Every shape of decorator that wraps a function — the basic form, decorators with arguments, functools.wraps, class decorators, and ParamSpec.

The @contextmanager from Chapter 11 iterables, generators, yield from, the @dataclass from Chapter 8 dataclass, the @functools.cache in the standard library, and FastAPI’s @app.get(...) are all decorators. This chapter covers every way to build that pattern yourself.

The ParamSpec-based signature preservation we cover at the end of the chapter is revisited in Chapter 20 typing advanced — Variance, ParamSpec, Self, overload. Extra tools appear there from the library author’s perspective, not the decorator user’s.

Decorator = a function that wraps a function #

Simplest decorator
def log_calls(fn):
    def wrapper(*args, **kwargs):
        print(f"call: {fn.__name__}({args}, {kwargs})")
        result = fn(*args, **kwargs)
        print(f"return: {result}")
        return result
    return wrapper

@log_calls
def add(a: int, b: int) -> int:
    return a + b

add(2, 3)
# call: add((2, 3), {})
# return: 5

Defining the function with @log_calls on top is just sugar for this:

What actually happens
def add(a: int, b: int) -> int:
    return a + b

add = log_calls(add)

A decorator takes a function and returns a new function. The result is that the original name now refers to the new function.

First pitfall — metadata loss #

The add above is no longer add.

Name and docstring are gone
@log_calls
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b

print(add.__name__)   # 'wrapper'  ← !
print(add.__doc__)    # None

The tool that fixes this is functools.wraps.

✅ Preserve metadata with functools.wraps
from functools import wraps

def log_calls(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        print(f"call: {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

@log_calls
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b

print(add.__name__)   # 'add'
print(add.__doc__)    # 'Add two integers.'

A single line of @wraps(fn) copies the name, docstring, annotations, and signature from the original. If you’re writing a user-facing decorator, you should almost always attach it.

Decorators that take arguments #

To pass arguments to the decorator itself like @retry(times=3), you add one more layer.

🚫 No-arg decorator
@retry
def request(): ...

# Behavior: request = retry(request)
✅ Decorator with arguments
@retry(times=3)
def request(): ...

# Behavior: request = retry(times=3)(request)
#                     ^^^^^^^^^^^^^^^ this has to return a decorator

That means retry(...) has to be a function that returns a decorator. You end up with three nested levels.

3-level nesting
from functools import wraps

def retry(times: int):                  # 1) receives arguments
    def decorator(fn):                   # 2) receives the function
        @wraps(fn)
        def wrapper(*args, **kwargs):    # 3) receives the call
            last_error = None
            for _ in range(times):
                try:
                    return fn(*args, **kwargs)
                except Exception as e:
                    last_error = e
            raise last_error
        return wrapper
    return decorator

@retry(times=3)
def fetch():
    ...

How to read it:

  • Outermost retry(times) — decorator arguments
  • Middle decorator(fn) — the function being decorated
  • Innermost wrapper(*args, **kwargs) — the actual call

The three levels look awkward, but they’re the standard form for every decorator with arguments.

The functools.partial trick #

When you want a decorator that works both as @retry() (no args) and as @retry(3) (with args).

Support both
from functools import wraps, partial

def retry(fn=None, *, times: int = 3):
    if fn is None:
        return partial(retry, times=times)
    @wraps(fn)
    def wrapper(*args, **kwargs):
        for _ in range(times):
            try:
                return fn(*args, **kwargs)
            except Exception:
                continue
        raise
    return wrapper

@retry
def a(): ...

@retry(times=5)
def b(): ...

Complex, so not commonly used. Usually picking one of the two and standardizing on it is cleaner.

Commonly used standard decorators #

@functools.cache / @lru_cache — memoization #

cache
from functools import cache

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

print(fib(100))   # 354224848179261915075 — instant

Called again with the same arguments, it returns the cached result without recomputing. Only use on pure functions (not on functions with side effects).

@lru_cache(maxsize=128) is the variant with a cache-size limit. Use it when you need to prevent unbounded memory growth.

@property — call like an attribute #

property
from dataclasses import dataclass

@dataclass
class Rectangle:
    width: float
    height: float

    @property
    def area(self) -> float:
        return self.width * self.height

r = Rectangle(3, 4)
print(r.area)    # 12.0  ← attribute access, not a method call

The generalization of @property is the descriptor protocol in Chapter 16 descriptors and __set_name__.

@staticmethod / @classmethod #

static / class method
class Math:
    @staticmethod
    def add(a: int, b: int) -> int:
        return a + b

    @classmethod
    def from_pair(cls, pair: tuple[int, int]):
        return cls(*pair)

staticmethod — takes neither self nor cls. classmethod — takes the class itself as the first arg (often used for alternative constructors).

Class decorators #

Decorators can also be applied to classes. Just like for functions, it’s a function that takes a class and returns a class.

Class decorator example
def add_repr(cls):
    def __repr__(self):
        attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
        return f"{cls.__name__}({attrs})"
    cls.__repr__ = __repr__
    return cls

@add_repr
class User:
    def __init__(self, id: int, name: str):
        self.id = id
        self.name = name

print(User(1, "Curtis"))
# User(id=1, name='Curtis')

@dataclass is exactly this pattern — it takes a class and returns a class with __init__ / __repr__ / __eq__ added.

Decorator classes — making the object on call #

Instead of a function, you can also use a class with __call__ as a decorator. Fits when you need state.

Class-form decorator
class CallCounter:
    def __init__(self, fn):
        self.fn = fn
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        return self.fn(*args, **kwargs)

@CallCounter
def greet():
    print("hi")

greet()
greet()
greet()
print(greet.count)   # 3

greet is now a CallCounter instance. Attribute access like .count works naturally. Downside: function signature / metadata preservation is tricky, and static analyzers eye it warily. If you don’t need state, a function decorator is better.

Decorators on methods — self flows through #

Because the wrapper inside the decorator takes *args, **kwargs, the method’s self simply comes in as args[0].

Works on methods too
def log_calls(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        print(f"call: {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

class Service:
    @log_calls
    def fetch(self, url: str):
        return f"data from {url}"

s = Service()
s.fetch("https://api.example.com")
# call: fetch

No special handling needed. It just works.

Type hints for decorators — ParamSpec #

Typing the decorator makes static analysis exact. But decorators wrap arbitrary functions, so preserving the type as-is is tricky. The tool that solves this is ParamSpec.

ParamSpec (3.10+)
from typing import Callable
from collections.abc import Awaitable

def log_calls[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    @wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"call: {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

What [**P, R] (3.12+) means:

  • P — the function’s parameter signature as-is
  • R — the return type

Written this way, the decorated function’s signature is preserved. Caller-side autocomplete and verification stay accurate. The old way looked like:

Old style — don't use in new code
from typing import TypeVar, ParamSpec, Callable
from functools import wraps

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

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

New code writes [**P, R] next to the method. Chapter 20 typing advanced revisits other uses of ParamSpec (decorators that accept only some of the arguments, etc.).

Composition — stacking multiple decorators #

Multiple decorators
@log_calls
@retry(times=3)
@cache
def fetch(url): ...

The code above is applied bottom-up.

Equivalent transformation
fetch = log_calls(retry(times=3)(cache(fetch)))

Order matters, so watch it. In the example above:

  1. cache first — on a cache hit, retry doesn’t even run
  2. retry next — retries when the cache misses and the real call happens
  3. log_calls outermost — logs every call, retries included

Common patterns — in one place #

Timing #

@timing
import time
from functools import wraps

def timing[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    @wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{fn.__name__}: {elapsed:.3f}s")
        return result
    return wrapper

Permission check #

@require_admin
def require_admin(fn):
    @wraps(fn)
    def wrapper(user, *args, **kwargs):
        if not user.is_admin:
            raise PermissionError("admin only")
        return fn(user, *args, **kwargs)
    return wrapper

@require_admin
def delete_user(actor, target_id): ...

Web framework permission handling usually has this shape. In Part 4 FastAPI, the Depends system from Chapter 23 routing, Pydantic models, dependency injection sits in the same spot.

Result caching (with TTL) #

TTL cache
from functools import wraps
import time

def ttl_cache(seconds: float):
    def decorator(fn):
        cache_data: dict = {}
        @wraps(fn)
        def wrapper(*args, **kwargs):
            key = (args, tuple(sorted(kwargs.items())))
            now = time.monotonic()
            if key in cache_data:
                value, ts = cache_data[key]
                if now - ts < seconds:
                    return value
            value = fn(*args, **kwargs)
            cache_data[key] = (value, now)
            return value
        return wrapper
    return decorator

@ttl_cache(seconds=60)
def get_config(): ...

Exercises #

  1. Write a @timing decorator with the [**P, R] signature. Print the wrapped function’s execution time in the format print(f"{fn.__name__}: {elapsed:.3f}s"). After applying @wraps(fn), verify that add.__name__ is still "add".
  2. Write a decorator with arguments called as @retry(times=3, delay=0.1). On failure, wait delay seconds and retry; if all attempts fail, re-raise the last exception. Build a function that intentionally fails with 1/3 probability and confirm the statistics line up.
  3. Write a class decorator @add_repr that automatically adds a __repr__ printing every field of __dict__. Compare how @dataclass does the same job in old Chapter 8 dataclass.

In one line: A decorator is a function that takes a function and returns a function. @d is sugar for f = d(f). Almost always attach @functools.wraps(fn) to user decorators. Decorators with arguments need three nested levels. You can also apply them to classes (@dataclass) and write class-form decorators (__call__). Preserve signatures with [**P, R] (3.12+). Multiple decorators apply bottom-up.

Next chapter #

In Chapter 13 pattern matching in depth we cover every pattern of the match-case we glimpsed in Chapter 3 control flow — class patterns, __match_args__, capture variables, and the depth of guards.

X