Why this matters
You often want to add the same behavior around many functions: log each call, check permissions, time it, retry on failure, cache results. Copying that code into every function is repetitive and mixes concerns. A decorator wraps a function in another function that adds the behavior, and the @decorator line above a def applies it in one readable step. You have probably seen @property, @dataclass and @classmethod already. Web frameworks use decorators for routing (@app.route("/")), test frameworks for marking tests, and the standard library for caching (@functools.cache). Decorators are nothing more than the closures of the last lesson, used with a bit of syntax.
A decorator is a function that takes a function
def shout(func):
def wrapper(text):
return func(text).upper() + "!"
return wrapper
def greet(name):
return f"hello, {name}"
greet = shout(greet)
print(greet("ada"))
HELLO, ADA!
shout receives the original function, defines a wrapper closure that calls it and changes the result, and returns the wrapper. Rebinding greet to the wrapper means every caller now gets the new behavior.
The @ syntax
Writing greet = shout(greet) after the function is easy to miss. The @ syntax does exactly the same thing, right where you define the function:
@shout
def greet(name):
return f"hello, {name}"
is equivalent to:
def greet(name):
return f"hello, {name}"
greet = shout(greet)
The decorator runs once, at definition time (when the def statement executes, typically on import). The wrapper runs on every call. With several decorators, the one closest to def is applied first:
@a
@b
def f(): ...
# f = a(b(f))
Writing general decorators
A useful decorator should work with any function, whatever its parameters. Use *args and **kwargs to accept and forward everything:
def trace(func):
def wrapper(*args, **kwargs):
print(f"-> {func.__name__}{args} {kwargs}")
result = func(*args, **kwargs)
print(f"<- {func.__name__} returned {result!r}")
return result
return wrapper
@trace
def add(a, b=0):
return a + b
add(2, b=3)
-> add(2,) {'b': 3}
<- add returned 5
Always return the result of func(...); forgetting it makes every decorated function return None.
functools.wraps: keep the identity
The wrapper replaced the original, so its metadata is now the wrapper's:
print(add.__name__, add.__doc__)
wrapper None
That breaks help(), tracebacks, debugging and tools that look up functions by name. functools.wraps copies __name__, __doc__, __module__, __qualname__ and the annotations from the original to the wrapper, and sets wrapper.__wrapped__ to the original:
import functools
def trace(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Notice that functools.wraps(func) is itself a decorator with an argument, which leads to the next topic. Use @functools.wraps in every decorator you write.
Decorators with arguments
@retry(3) passes an argument to the decorator. Read it literally: Python first evaluates retry(3), and whatever that returns is used as the decorator. So retry is a decorator factory: a function returning a decorator, which returns a wrapper. Three nested levels:
import functools
def repeat(times): # factory: receives the arguments
def decorator(func): # decorator: receives the function
@functools.wraps(func)
def wrapper(*args, **kwargs): # wrapper: receives the call arguments
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def ping():
print("ping")
ping()
ping
ping
ping
@repeat(3) desugars to ping = repeat(3)(ping). Each level is a closure over the one outside it: wrapper sees func and times.
Practical decorators
Timing. A decorator can measure how long calls take, using time.perf_counter() (a high-resolution clock for measuring intervals):
import functools, time
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.3f}s")
return wrapper
The try/finally ensures the time is reported even if the function raises.
Caching. Results of a pure function can be stored and reused. The standard library does it for you:
import functools
@functools.cache
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(100))
print(fib.cache_info())
354224848179261915075
CacheInfo(hits=98, misses=101, maxsize=None, currsize=101)
Without the cache, this recursion makes an exponential number of calls; with it, each fib(k) is computed once. It works because the recursive calls look up the global name fib, which now refers to the caching wrapper. functools.lru_cache(maxsize=128) keeps only the most recent entries to bound memory. Arguments must be hashable, since they become dict keys.
Validation and registration. Decorators can check arguments before calling, or simply record the function somewhere and return it unchanged:
handlers = {}
def on(event):
def decorator(func):
handlers[event] = func
return func # not wrapped at all
return decorator
@on("start")
def starting():
return "starting up"
print(handlers["start"]())
starting up
This registration pattern is how web frameworks map URLs to functions.
Classes can be decorated too: @dataclass takes a class and returns it with generated methods added. And any callable can be a decorator, including a class with __call__.
Common mistakes
- Calling the function in the decorator instead of wrapping it:
return func(*args)at the decorator level (outside a wrapper) runs it once at definition time and replaces it with its result: later calls fail withTypeError: 'NoneType' object is not callable. - Forgetting to return the wrapper from the decorator: same
'NoneType' object is not callableerror. - Forgetting
returninside the wrapper: the decorated function silently returnsNone. - Using a factory without parentheses:
@retryinstead of@retry(3)passes the function astimes; the error appears later and is confusing. - Wrapper with a fixed signature like
def wrapper(x): breaks for functions with other parameters. Use*args, **kwargs. - Skipping
functools.wraps: every decorated function is namedwrapperin tracebacks.
Recap
- A decorator is a callable that takes a function and returns a replacement, usually a closure wrapping it.
@dabovedef fmeansf = d(f), applied once at definition time; stacked decorators apply bottom-up.- Forward arguments with
*args, **kwargs, return the result, and use@functools.wraps(func). - A decorator with arguments is a factory:
@d(x)meansf = d(x)(f). functools.cacheandlru_cachememoize pure functions.
Going further: read PEP 318 (the decorator syntax rationale) and the functools docs (wraps, cache, lru_cache, singledispatch).