Decorators — functions, classes, and parameterised
Write decorators that preserve signatures, stack cleanly, and respect the wrapped function's docstring.
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
- Write a decorator from scratch.
- Preserve metadata with
functools.wraps. - Write a parameterised decorator.
- Write a class-based decorator.
- Stack decorators correctly.
1. The mechanics
A decorator is a callable that takes a function (or class) and returns a callable.
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:
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__:
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 ← badAdd @functools.wraps(fn):
from functools import wraps
def my_decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapperNow greet.__name__ == "greet" and the docstring is preserved. Always do this.
3. A useful real-world decorator: @timed
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:
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:
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.
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 # 3Trade-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:
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:
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
@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), thenretry(times=3)(...). - Runtime call order:
retry's wrapper runs first, thentimed's wrapper, thenf.
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
@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 dispatchYou'll see all of these constantly.
9. Async-aware decorators
If the wrapped function may be async, your wrapper must await:
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 wrapperThe pattern: check iscoroutinefunction; return an async wrapper for async, sync wrapper for sync.
10. Type hints for decorators
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 wrapperParamSpec (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
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 decoSave these in utils/decorators.py of any new project.
Hands-on lab (2 hours)
- Write
@timedfrom scratch. Add it to 3 functions; verify logs. - Write
@retry(times, on, backoff). Test it triggering a fakehttpx.HTTPError. - Write
@memoize(skip args=hashable). Compare runtime offib(35)before/after. - Write
@validate_types(allow_subclass=True)that usesinspect.signatureto verify args against annotations at runtime. - Stack
@retry(3) @timedand@timed @retry(3); observe difference in logs. - Write a
@tracedecorator that handles both sync and async (useinspect.iscoroutinefunction). - Add
ParamSpectyping so your decorators preserve signatures. Runmypy.
Common pitfalls
- Forgetting
@wraps(fn). @decoratorvs@decorator()confusion.- Wrapping a method but losing
self(place decorator outside, not inside, the class definition incorrectly). - Stateful decorators (closures) shared across all calls when you wanted per-instance state.
- Async wrappers that don't
awaitthe inner function. - Catching
Exceptionin@retryand masking bugs.
Self-check
- What does
@decoratordesugar to? - Why is
functools.wrapsnecessary? - Difference between a decorator and a parameterised decorator.
- How would you preserve type signatures with
ParamSpec? - 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.
functoolsdocs.- Graham Dumpleton, "How you implemented your Python decorator is wrong" (blog).
Sign in to save your progress and earn badges.