Decorators

Python · Advanced · 14 min

Decorators power caching, logging, retries, auth checks and most framework APIs (Flask, pytest, FastAPI).

A decorator is a callable that takes a function and returns a replacement. The @ syntax is pure sugar:

@timed
def load(): ...
# is the same as
load = timed(load)

The usual implementation is a closure: the decorator defines an inner wrapper(*args, **kwargs) that does something before and/or after calling the original, then returns wrapper. Accepting *args, **kwargs lets it wrap any signature.

Always decorate the wrapper with @functools.wraps(func). Without it, the decorated function's __name__, __doc__ and __module__ become the wrapper's, which breaks debugging, logging, help() and tools that introspect functions. wraps also sets __wrapped__, giving access to the original.

A decorator that takes arguments, like @retry(times=3), needs one more level: retry(times=3) is called first and must return the actual decorator. Three nested functions — factory, decorator, wrapper — look intimidating but follow mechanically.

Decorators stack: the one nearest the def is applied first. They also work on classes, receiving the class object (that's how @dataclass works), and on methods.

The standard library's most useful decorators:

  • functools.cache / lru_cache(maxsize=...) memoise pure functions
  • property, classmethod, staticmethod, functools.cached_property
  • contextlib.contextmanager, functools.singledispatch

Decorators run at definition time (import time), not call time — keep that work cheap and free of side effects.

Example

import functools
def retry(times=3, exceptions=(Exception,)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    print(f"attempt {attempt} failed: {e}")
            raise RuntimeError(f"{func.__name__} failed {times} times")
        return wrapper
    return decorator

outcomes = iter([ValueError("flaky"), ValueError("flaky"), "ok"])
@retry(times=3, exceptions=(ValueError,))
def fetch():
    """Fetch something unreliable."""
    result = next(outcomes)
    if isinstance(result, Exception):
        raise result
    return result

print(fetch())                       # two failures, then: ok
print(fetch.__name__, fetch.__doc__) # fetch Fetch something unreliable.

Remember

  • @d above def f means f = d(f)
  • Always use functools.wraps in wrappers
  • Decorators with arguments are factories that return a decorator

Watch out for

  • Forgetting return in the wrapper, so the decorated function always returns None.

Check yourself

  1. For `@a` stacked above `@b` above `def f`, what is `f` bound to?
  2. What goes wrong if a wrapper omits `functools.wraps(func)`?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/functools.html#functools.wraps