Python decorators, explained
A decorator is one of Python's most distinctive features — and one interviewers reach for
constantly, because understanding it requires that functions are first-class objects,
that you grasp closures, and that you know what the @ syntax actually does. This
guide builds the idea up from scratch and works through the patterns that trip people up:
preserving metadata, parameterised decorators, class-based decorators, and stacking order.
What a decorator actually is
A decorator is just a callable that takes a function and returns a function (usually a new one that wraps the original). Because functions are first-class objects in Python, you can pass them around, define them inside other functions, and return them.
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
result = func(*args, **kwargs) # call the original
print(f"{func.__name__} returned {result!r}")
return result
return wrapper # return the wrapped version
wrapper is a closure: it remembers func from the enclosing scope even after
log_calls has returned. The *args, **kwargs forwarding is what lets one wrapper handle
a function with any signature.
The @ syntax is just sugar
The @decorator line above a function is shorthand for reassigning the name to the
decorated version. These two snippets are identical:
@log_calls
def add(a, b):
return a + b
# is exactly the same as:
def add(a, b):
return a + b
add = log_calls(add) # the @ line does this
So add no longer points at the original function — it points at wrapper. That's the
whole trick: decoration happens once, at definition time, and every later call to
add actually runs wrapper.
Preserving metadata with functools.wraps
There's a hidden cost to the version above: add.__name__ is now "wrapper" and its
docstring is gone, because the name is bound to the wrapper, not the original. This breaks
introspection, debuggers, and documentation tools.
import functools
def log_calls(func):
@functools.wraps(func) # copy __name__, __doc__, __wrapped__, etc.
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@log_calls
def add(a, b):
"""Add two numbers."""
return a + b
add.__name__ # 'add' (without @wraps it would be 'wrapper')
add.__doc__ # 'Add two numbers.'
Always use functools.wraps on the wrapper. It's the single most common thing missing
from a hand-written decorator.
Decorators that take arguments
To configure a decorator (e.g. @retry(times=3)), you need one more layer: a function
that takes the arguments and returns a decorator. So a parameterised decorator is a
function returning a function returning a function.
import functools
def retry(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception:
if attempt == times - 1:
raise # last attempt — give up
return wrapper
return decorator
@retry(times=3) # retry(3) runs first, returns the real decorator
def fetch():
...
Read it outside-in: retry(times=3) is called immediately and returns decorator, which
is then applied to fetch. That extra layer is the only structural difference from a
plain decorator.
Class-based decorators
A class works as a decorator if its instances are callable (it defines __call__).
This is handy when the decorator needs to hold state across calls.
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func) # the @wraps equivalent for classes
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"call #{self.count}")
return self.func(*args, **kwargs)
@CountCalls
def greet():
print("hi")
Here greet becomes a CountCalls instance; calling greet() runs __call__. Use a
class when state (like count) is cleaner as an attribute than as a closure variable.
Stacking decorators and order
Multiple decorators apply bottom-up — the one closest to the function wraps first, and the topmost runs outermost at call time.
@bold # applied last, runs first at call time (outermost)
@italic # applied first, wraps the original
def text():
return "hi"
# equivalent to:
text = bold(italic(text))
So the decoration order is bottom-to-top, but the execution order at call time is top-to-bottom (outermost first). Getting this backwards is a classic interview slip.
Where decorators earn their keep
Real-world decorators centralise cross-cutting concerns so the wrapped function stays focused:
import functools, time
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
return wrapper
Common uses: functools.lru_cache for memoisation, @property for computed
attributes, framework routing (@app.route(...)), authentication checks, logging, timing,
and retries — anywhere you'd otherwise copy-paste boilerplate around many functions.
Recap
A decorator is a callable that takes a function and returns a (usually wrapping)
function, and @deco is just sugar for func = deco(func) at definition time. Forward
*args, **kwargs so the wrapper handles any signature, and always apply
functools.wraps to preserve the original's name and docstring. A decorator that takes
arguments needs an extra layer (a function returning a decorator); a class with
__call__ works when you want stateful decoration. Stacked decorators apply bottom-up and
execute outermost-first. Master the closure underneath and decorators stop feeling like
magic.