Create decorators¶
The question. You want to wrap an existing function with extra behaviour — logging, timing, retries, caching — without editing the function itself.
The shape of every decorator you'll write is the same: an outer function that takes the wrapped function, a nested wrapper that does the extra work and forwards its arguments, and @functools.wraps so the wrapped function keeps its name and docstring. Once you have that shape in your head, the variants (decorators with arguments, stacked decorators) are small tweaks on top.
import functools
import time
def timer(func):
"""Print how long the wrapped function takes."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f'{func.__name__} took {elapsed:.4f}s')
return result
return wrapper
@timer
def slow_sum(n: int) -> int:
"""Sum 1..n with a deliberate pause."""
time.sleep(0.1)
return sum(range(1, n + 1))
print(slow_sum(1000))
print('name ->', slow_sum.__name__) # preserved by wraps
print('doc ->', slow_sum.__doc__) # preserved by wraps
Why it works¶
@timer is syntactic sugar for slow_sum = timer(slow_sum). The name slow_sum is now bound to the wrapper closure, not to the original function. Each call goes through wrapper, which starts a clock, calls the original via the captured func reference, records the elapsed time, and returns whatever the original returned.
*args, **kwargs in the wrapper matters more than it looks. Without it your decorator only works on functions with the exact signature you happened to test against. With it, the wrapper accepts any combination of positional and keyword arguments and forwards them unchanged — the wrapped function gets called with exactly what the caller passed.
@functools.wraps(func) copies __name__, __doc__, __module__, __qualname__, and the type annotations from func onto wrapper. Skip it and help(slow_sum) shows the wrapper's docstring (or none) instead of the real one, which breaks documentation tools and IDEs. Make it a reflex: every decorator you write starts with @functools.wraps(func) on the wrapper.
Trade-offs and variations¶
- Decorators with arguments need three layers.
@retry(max_attempts=3)is a call, soretry(max_attempts=3)has to return a decorator. That's an outer factory, a middle decorator, and an inner wrapper. It's fiddly; when in doubt, start without arguments and add them only when you need to. - For caching, reach for
functools.lru_cachefirst. A hand-rolled memoisation decorator is a fine exercise butfunctools.lru_cache(maxsize=128)is already the best version of it — it handles keyword arguments, size limits, and cache statistics for you. - Stacked decorators apply bottom-up.
@validate_types(...)over@retry(...)means retries happen inside the type check. Rearrange deliberately; the order matters. - Decorators are just functions, not magic. If the wrapping syntax is getting in the way, call the decorator explicitly (
slow_sum = timer(slow_sum)) — it's exactly the same thing, and sometimes clearer when you're debugging who wraps whom.
Related¶
- Learn — Decorators for a longer walkthrough of the three-layer decorator factory pattern.
- Learn — Scope and closures for why
funcstays visible insidewrapperafter the outer call returns. - Reference — Function syntax for the
*args/**kwargsrules the wrapper relies on. - Concepts — First-class functions in Python for why "a function that returns a function" is just ordinary Python.