Decorators — functions, classes, and parameterised

Write decorators that preserve signatures, stack cleanly, and respect the wrapped function's docstring.

🧠 Module 3 7 min read Not started

Why this matters

Decorators are the canonical Python way to add behaviour around functions: timing, caching, retries, auth, logging, metrics, validation, FastAPI routes, pytest fixtures. Read 5 lines of any production Python and you'll see @something. Mastering decorators — including parameterised and class decorators — is what makes you fluent.

Learning objectives

  1. Write a decorator from scratch.
  2. Preserve metadata with functools.wraps.
  3. Write a parameterised decorator.
  4. Write a class-based decorator.
  5. Stack decorators correctly.

1. The mechanics

A decorator is a callable that takes a function (or class) and returns a callable.

python
def my_decorator(fn):
    def wrapper(*args, **kwargs):
        print(f"calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

@my_decorator
def greet(name):
    print(f"hi {name}")

greet("Ada")
# calling greet
# hi Ada

@my_decorator is sugar for:

python
def greet(name): ...
greet = my_decorator(greet)

The original greet is replaced by the wrapper. If you call greet("Ada") you're actually calling wrapper("Ada").


2. Preserve metadata with functools.wraps

Without wraps, the wrapper steals the original's __name__, __doc__, __module__:

python
def my_decorator(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

@my_decorator
def greet(name):
    """say hi"""

greet.__name__         # 'wrapper'   ← bad
greet.__doc__          # None        ← bad

Add @functools.wraps(fn):

python
from functools import wraps

def my_decorator(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

Now greet.__name__ == "greet" and the docstring is preserved. Always do this.


3. A useful real-world decorator: @timed

python
from functools import wraps
import time
import logging

log = logging.getLogger(__name__)

def timed(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            elapsed_ms = (time.perf_counter() - start) * 1000
            log.info("%s took %.2f ms", fn.__name__, elapsed_ms)
    return wrapper

@timed
def slow():
    time.sleep(0.1)

finally ensures we log even if the function raised.


4. Parameterised decorators (decorator factories)

If the decorator needs arguments, you add another level of wrapping:

python
def retry(times: int = 3, exceptions: tuple = (Exception,)):
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            for attempt in range(times):
                try:
                    return fn(*args, **kwargs)
                except exceptions as e:
                    if attempt + 1 == times:
                        raise
                    log.warning("attempt %d failed: %s", attempt + 1, e)
        return wrapper
    return decorator

@retry(times=5, exceptions=(httpx.HTTPError,))
def fetch(url): ...

The structure is:

def factory(*args):       # the @decorator(args)
    def decorator(fn):    # the @decorator
        def wrapper(*a, **kw):  # the call site
            ...
        return wrapper
    return decorator

@decorator() vs @decorator

@retry (no parens) is NOT the same as @retry(). The former passes fn directly to retry, expecting it to BE the decorator. The latter calls retry() which returns the decorator.

To support both forms gracefully, use a "smart" decorator:

python
def retry(fn=None, *, times=3):
    if fn is None:
        # Called as @retry(times=5)
        return lambda f: retry(f, times=times)
    # Called as @retry
    @wraps(fn)
    def wrapper(*a, **kw):
        ...
    return wrapper

@retry              # both work
def a(): ...

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

5. Class-based decorators

A class with __call__ can act as a decorator. Useful when the decorator carries state.

python
class CallCounter:
    def __init__(self, fn):
        self.fn = fn
        self.calls = 0
        wraps(fn)(self)            # copy __name__/__doc__ to instance

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

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

hi(); hi(); hi()
hi.calls         # 3

Trade-off: class decorators don't behave like the function (they're instances), and method-on-class scenarios get fiddly (Phase 3.4 descriptors). Prefer function decorators unless state really helps.

For parameterised class decorators:

python
class Throttle:
    def __init__(self, per_second: float):
        self.interval = 1.0 / per_second
        self.last = 0.0
    def __call__(self, fn):
        @wraps(fn)
        def wrapper(*a, **kw):
            wait = self.last + self.interval - time.monotonic()
            if wait > 0: time.sleep(wait)
            self.last = time.monotonic()
            return fn(*a, **kw)
        return wrapper

@Throttle(per_second=5)
def call_api(): ...

6. Decorating classes (not just functions)

A "class decorator" rewrites or augments a class:

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

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

User("Ada")          # User(name='Ada')

@dataclass is just a fancy class decorator. So is @attrs.define.


7. Stacking decorators

python
@retry(times=3)
@timed
def f(): ...

Equivalent to: f = retry(times=3)(timed(f)).

Decorators apply bottom-up at definition, but execute top-down at call:

  • Definition order (innermost first): timed(f), then retry(times=3)(...).
  • Runtime call order: retry's wrapper runs first, then timed's wrapper, then f.

So retry sees the whole timed(f) as one unit. Each retry triggers a fresh timing.

Order matters. Try swapping; observe behaviour.


8. Decorators in the wild

python
@app.get("/users/{uid}")           # FastAPI route
@cache                              # functools cache
@dataclass(frozen=True, slots=True) # dataclass
@pytest.fixture                     # pytest fixture
@pytest.mark.parametrize(...)       # pytest parametrization
@property                           # turn method into attribute
@classmethod                        # class method
@staticmethod                       # static method
@contextlib.contextmanager          # generator-based context manager
@functools.singledispatch           # type-based dispatch

You'll see all of these constantly.


9. Async-aware decorators

If the wrapped function may be async, your wrapper must await:

python
import inspect, functools, time, logging

def timed(fn):
    if inspect.iscoroutinefunction(fn):
        @functools.wraps(fn)
        async def awrapper(*a, **kw):
            start = time.perf_counter()
            try: return await fn(*a, **kw)
            finally:
                logging.info("%s %.2fms", fn.__name__, (time.perf_counter()-start)*1000)
        return awrapper
    @functools.wraps(fn)
    def wrapper(*a, **kw):
        start = time.perf_counter()
        try: return fn(*a, **kw)
        finally:
            logging.info("%s %.2fms", fn.__name__, (time.perf_counter()-start)*1000)
    return wrapper

The pattern: check iscoroutinefunction; return an async wrapper for async, sync wrapper for sync.


10. Type hints for decorators

python
from typing import Callable, ParamSpec, TypeVar
from functools import wraps

P = ParamSpec("P")           # captures positional + keyword args
R = TypeVar("R")             # return type

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

ParamSpec (PEP 612, 3.10+) preserves the wrapped function's signature for type checkers. Without it, callers lose autocomplete. Phase 3.5 explains the typing in depth.


11. A useful library of decorators

python
from functools import wraps
import time
import logging

log = logging.getLogger(__name__)

def timed(fn):
    @wraps(fn)
    def w(*a, **kw):
        s = time.perf_counter()
        try: return fn(*a, **kw)
        finally: log.info("%s %.2fms", fn.__name__, (time.perf_counter()-s)*1000)
    return w

def retry(times=3, on=(Exception,), backoff=0.1):
    def deco(fn):
        @wraps(fn)
        def w(*a, **kw):
            for i in range(times):
                try: return fn(*a, **kw)
                except on as e:
                    if i + 1 == times: raise
                    time.sleep(backoff * 2 ** i)
        return w
    return deco

def deprecated(reason: str):
    import warnings
    def deco(fn):
        @wraps(fn)
        def w(*a, **kw):
            warnings.warn(f"{fn.__name__} deprecated: {reason}", DeprecationWarning, stacklevel=2)
            return fn(*a, **kw)
        return w
    return deco

Save these in utils/decorators.py of any new project.


Hands-on lab (2 hours)

  1. Write @timed from scratch. Add it to 3 functions; verify logs.
  2. Write @retry(times, on, backoff). Test it triggering a fake httpx.HTTPError.
  3. Write @memoize (skip args=hashable). Compare runtime of fib(35) before/after.
  4. Write @validate_types(allow_subclass=True) that uses inspect.signature to verify args against annotations at runtime.
  5. Stack @retry(3) @timed and @timed @retry(3); observe difference in logs.
  6. Write a @trace decorator that handles both sync and async (use inspect.iscoroutinefunction).
  7. Add ParamSpec typing so your decorators preserve signatures. Run mypy.

Common pitfalls

  1. Forgetting @wraps(fn).
  2. @decorator vs @decorator() confusion.
  3. Wrapping a method but losing self (place decorator outside, not inside, the class definition incorrectly).
  4. Stateful decorators (closures) shared across all calls when you wanted per-instance state.
  5. Async wrappers that don't await the inner function.
  6. Catching Exception in @retry and masking bugs.

Self-check

  1. What does @decorator desugar to?
  2. Why is functools.wraps necessary?
  3. Difference between a decorator and a parameterised decorator.
  4. How would you preserve type signatures with ParamSpec?
  5. When to use a class-based decorator?

References

  • Fluent Python, Ramalho — Chapter 9.
  • PEP 318 — Decorators for Functions and Methods.
  • PEP 612 — Parameter Specification Variables.
  • functools docs.
  • Graham Dumpleton, "How you implemented your Python decorator is wrong" (blog).

Sign in to save your progress and earn badges.