Decorators
A decorator is a function that takes a function (or class) and returns a function (or class). The @decorator syntax admits compact application; the mechanism is one of Python’s most distinctive contributions and is pervasive in modern frameworks (Flask, FastAPI, Django, pytest, dataclasses, the standard library). Decorators admit cross-cutting concerns — logging, caching, validation, authentication, registration — to be applied without modifying the decorated function’s body. The combination of first-class functions, closures, and the @ syntax produces a substantial AOP-style mechanism without ceremony.
This page covers function decorators, decorator factories, class decorators, the standard-library decorators, and the conventional patterns. The deeper functional surface (functools, operator) is in Functional.
Function decorators
The simplest decorator wraps a function:
def with_logging(f):
def wrapped(*args, **kwargs):
print(f"calling {f.__name__}")
result = f(*args, **kwargs)
print(f" → {result}")
return result
return wrapped
@with_logging
def square(n):
return n * n
square(5)
# calling square
# → 25
# 25
The @with_logging is equivalent to square = with_logging(square) after the function’s definition. The decorator:
- Takes the function
squareas its argumentf. - Defines an inner function
wrappedthat takes any arguments. - The inner function calls
f(*args, **kwargs)and adds behaviour. - Returns
wrapped;squareis now bound towrapped.
The wrapped function preserves the original signature through *args, **kwargs forwarding.
functools.wraps
The naive decorator above loses the original function’s name, docstring, and other metadata. The functools.wraps admits preserving them:
from functools import wraps
def with_logging(f):
@wraps(f)
def wrapped(*args, **kwargs):
print(f"calling {f.__name__}")
return f(*args, **kwargs)
return wrapped
@with_logging
def square(n):
"""Compute the square of n."""
return n * n
square.__name__ # "square" (not "wrapped")
square.__doc__ # "Compute the square of n."
The @wraps(f) copies f’s metadata onto the wrapped function. The conventional discipline is to use @wraps in every decorator that wraps a function.
Decorator factories
A decorator factory is a function that returns a decorator. The pattern admits configurable decorators:
def repeat(n):
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
for _ in range(n):
result = f(*args, **kwargs)
return result
return wrapped
return decorator
@repeat(3)
def greet(name):
print(f"Hello, {name}")
greet("alice")
# Hello, alice
# Hello, alice
# Hello, alice
The @repeat(3) is equivalent to greet = repeat(3)(greet). The repeat(3) returns a decorator (the inner decorator function); the decorator is then applied to greet.
The pattern is the conventional form for parameterised decorators — caching with TTL, retry with attempts, logging with level, etc.
Stacking decorators
Multiple decorators apply bottom-up:
@decorator_a
@decorator_b
@decorator_c
def f():
pass
# Equivalent to:
f = decorator_a(decorator_b(decorator_c(f)))
The innermost (decorator_c) wraps first; the outermost (decorator_a) wraps last and is the final outer wrapper.
@with_logging
@with_timing
def compute():
do_work()
# When called:
# 1. with_logging's wrapper runs (prints "calling")
# 2. with_timing's wrapper runs (records start)
# 3. compute's body runs
# 4. with_timing's wrapper records end
# 5. with_logging's wrapper prints the result
The conventional advice: apply decorators in an order that makes the layering explicit; logging tends to be outermost (so it logs the wrapped behaviour), caching tends to be innermost (so it caches the actual computation, not the cached result).
Built-in decorators
Several decorators ship with Python:
@property, @<name>.setter, @<name>.deleter
For computed attributes:
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("negative")
self._radius = value
@property
def area(self):
return 3.14159 * self._radius ** 2
Treated in Classes and inheritance.
@classmethod and @staticmethod
For methods that don’t take an instance:
class Date:
@classmethod
def today(cls):
# cls is the class
...
@staticmethod
def is_valid(year, month, day):
# no implicit first argument
...
Treated in Classes and inheritance.
@functools.lru_cache
A memoising cache:
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
fibonacci(30) # computed once; subsequent calls hit the cache
The maxsize=None admits unbounded caching:
@lru_cache(maxsize=None) # unbounded
def expensive(...):
...
@lru_cache # since 3.8: shorthand for maxsize=128
def cached(...):
...
Python 3.9 added @functools.cache (unbounded shorthand for lru_cache(maxsize=None)).
@functools.cached_property
A property that is computed once per instance and cached:
from functools import cached_property
class Document:
def __init__(self, content):
self.content = content
@cached_property
def word_count(self):
# computed once per instance, on first access
return len(self.content.split())
The cache lives in the instance’s __dict__; reads after the first are direct attribute access.
@functools.singledispatch
Type-based dispatch:
from functools import singledispatch
@singledispatch
def serialise(value):
raise NotImplementedError
@serialise.register
def _(value: int):
return str(value)
@serialise.register
def _(value: list):
return "[" + ",".join(serialise(x) for x in value) + "]"
Treated in Functions.
@dataclass
Auto-generated __init__, __repr__, __eq__:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
Treated in Classes and inheritance.
@contextmanager
Convert a generator function to a context manager:
from contextlib import contextmanager
@contextmanager
def transaction(db):
try:
db.begin()
yield db
db.commit()
except:
db.rollback()
raise
with transaction(database) as db:
db.execute("INSERT ...")
Treated in Duck typing and protocols.
Class decorators
A decorator may take a class and return a class:
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 Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(3, 4)
print(p) # Point(x=3, y=4)
The @dataclass decorator is itself a class decorator; it adds __init__, __repr__, __eq__, etc.
The pattern admits adding cross-cutting behaviour to classes — registration, instrumentation, validation. Many frameworks use class decorators heavily.
Decorators with state
A decorator may maintain state across calls:
def call_counter(f):
@wraps(f)
def wrapped(*args, **kwargs):
wrapped.calls += 1
return f(*args, **kwargs)
wrapped.calls = 0
return wrapped
@call_counter
def expensive():
pass
expensive()
expensive()
expensive()
print(expensive.calls) # 3
The wrapped.calls is an attribute on the wrapped function; the decorator initialises it.
For more elaborate state, a decorator class is the conventional alternative:
class CallCounter:
def __init__(self, f):
self.f = f
self.calls = 0
wraps(f)(self)
def __call__(self, *args, **kwargs):
self.calls += 1
return self.f(*args, **kwargs)
@CallCounter
def expensive():
pass
expensive()
print(expensive.calls) # 1
The __call__ admits the class instance to be called; the wraps(f)(self) copies f’s metadata.
Common patterns
Logging
def with_logging(f):
@wraps(f)
def wrapped(*args, **kwargs):
logger.info(f"calling {f.__name__}({args}, {kwargs})")
try:
result = f(*args, **kwargs)
logger.info(f" → {result}")
return result
except Exception as e:
logger.error(f" → exception: {e}")
raise
return wrapped
The pattern admits adding logging without modifying the function.
Timing
import time
def timed(f):
@wraps(f)
def wrapped(*args, **kwargs):
start = time.perf_counter()
try:
return f(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
print(f"{f.__name__} took {elapsed:.3f}s")
return wrapped
Validation
def validate_args(*types):
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
for arg, expected in zip(args, types):
if not isinstance(arg, expected):
raise TypeError(f"expected {expected}, got {type(arg)}")
return f(*args, **kwargs)
return wrapped
return decorator
@validate_args(int, str)
def process(n, name):
pass
process(42, "alice") # OK
process("42", "alice") # TypeError
The pattern admits runtime validation.
Retry
import time
def retry(attempts, delay=1):
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
for attempt in range(attempts):
try:
return f(*args, **kwargs)
except Exception as e:
if attempt == attempts - 1:
raise
time.sleep(delay)
return wrapped
return decorator
@retry(attempts=3, delay=2)
def fetch_data():
response = requests.get(url)
response.raise_for_status()
return response.json()
Memoisation
def memoize(f):
cache = {}
@wraps(f)
def wrapped(*args):
if args not in cache:
cache[args] = f(*args)
return cache[args]
return wrapped
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
For most cases, @functools.lru_cache is preferable (it has bounded size, thread-safety, and statistics).
Registration
HANDLERS = {}
def handler(event_type):
def decorator(f):
HANDLERS[event_type] = f
return f
return decorator
@handler("click")
def handle_click(event):
pass
@handler("key")
def handle_key(event):
pass
The decorator registers the function in a global dispatch table. The pattern is the conventional Python form for plugin systems.
Authentication
def require_auth(f):
@wraps(f)
def wrapped(request, *args, **kwargs):
if not request.user.is_authenticated:
raise PermissionError
return f(request, *args, **kwargs)
return wrapped
@require_auth
def view_dashboard(request):
return render(...)
Web frameworks use the pattern extensively for access control.
Result transformation
def stringify(f):
@wraps(f)
def wrapped(*args, **kwargs):
return str(f(*args, **kwargs))
return wrapped
@stringify
def double(n):
return n * 2
double(5) # "10"
The pattern admits modifying the function’s output.
Typing decorators
For decorators that preserve the wrapped function’s signature, type hints use ParamSpec:
from typing import Callable, ParamSpec, TypeVar
from functools import wraps
P = ParamSpec("P")
R = TypeVar("R")
def with_logging(f: Callable[P, R]) -> Callable[P, R]:
@wraps(f)
def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"calling {f.__name__}")
return f(*args, **kwargs)
return wrapped
The ParamSpec admits the type checker to preserve the signature; without it, the decorator’s return type would lose information about the wrapped function’s parameters.
For decorator factories:
def repeat(n: int) -> Callable[[Callable[P, R]], Callable[P, R]]:
def decorator(f: Callable[P, R]) -> Callable[P, R]:
@wraps(f)
def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
for _ in range(n):
result = f(*args, **kwargs)
return result
return wrapped
return decorator
The treatment of ParamSpec and decorator typing is in Type hints.
A note on the conventional discipline
The contemporary Python decorator advice:
- Use
@wrapsin every decorator that wraps a function. - Use
functools.lru_cacheandcachefor memoisation. - Use class decorators for cross-cutting concerns on classes.
- Use decorator factories for parameterised decorators.
- Use
ParamSpecfor type-correct decorators.
Decorators are a powerful mechanism but easy to overuse. The conventional discipline is to use them for cross-cutting concerns (logging, timing, caching, registration) — not for the function’s principal logic. A function whose meaning is buried in three layers of decorator wrapping is harder to read than one with the same logic written explicitly.