Decorators Explained: Wrap Functions Without Touching Them
- Functions are first-class objects in Python; they can be stored in variables and passed as arguments.
- A decorator is a callable that takes a function, wraps it, and returns the wrapper.
- The
@decoratorline is syntactic sugar forfunc = decorator(func). - Use
functools.wrapsto preserve the original function's name and docstring.
Functions are first-class objects
Before decorators make sense, you need to be comfortable with one Python fact: functions are objects just like integers or strings. You can assign them to variables, store them in lists, and pass them to other functions as arguments.
def greet(name):
return f"Hello, {name}"
say_hello = greet # no call, just a reference
print(say_hello("world")) # "Hello, world"
def apply(func, value):
return func(value)
print(apply(greet, "Alice")) # "Hello, Alice"
This is the foundation. A decorator is just a function that accepts another function and returns something (usually a modified function).
Writing a wrapper from scratch
Say you want to log every call to a function without editing it. You write a wrapper that runs before and after the original call:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} ...")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
def add(a, b):
return a + b
add = log_calls(add) # replace add with the wrapped version
add(2, 3)
# Calling add ...
# add returned 5
The key move is that wrapper closes over the func argument from the outer scope. Python closures let inner functions remember variables from their enclosing function, so func stays alive as long as wrapper does.
The @ shorthand
The pattern add = log_calls(add) is so common that Python has dedicated syntax for it. Placing @log_calls immediately before a function definition does the same thing:
@log_calls
def multiply(a, b):
return a * b
multiply(4, 5)
# Calling multiply ...
# multiply returned 20
The @ line is evaluated at import time, not at call time. By the time you call multiply, it is already the wrapped version.
Preserving identity with functools.wraps
There is a subtle problem with the naive wrapper: the wrapped function loses its original __name__ and __doc__, because Python sees wrapper instead of the original. This breaks introspection and documentation tools.
from functools import wraps
def log_calls(func):
@wraps(func) # copies name, docstring, etc. onto wrapper
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} ...")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
After adding @wraps(func), calling help(multiply) or checking multiply.__name__ gives the original name and docstring. Always include @wraps when writing decorators intended for production use.
Decorators that accept arguments
Sometimes you want to configure the decorator itself, for example to set how many times a function should retry on failure. You need one extra layer of nesting:
from functools import wraps
import time
def retry(times=3, delay=0.5):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
if attempt == times:
raise
print(f"Attempt {attempt} failed: {exc}. Retrying...")
time.sleep(delay)
return wrapper
return decorator
@retry(times=4, delay=1.0)
def fetch_data(url):
... # network call that might fail
The call @retry(times=4, delay=1.0) first invokes retry with those arguments, which returns decorator. Then decorator is applied to fetch_data. The three-layer structure—factory, decorator, wrapper—is the standard form for parametrised decorators.
Real-world patterns
Python's standard library and ecosystem use decorators everywhere. A few you will encounter repeatedly:
@property— turns a method into an attribute accessor without needing explicit getter/setter plumbing.@staticmethodand@classmethod— alter how a method receives its first argument inside a class.@functools.cache— memoises a function's return values by its arguments, eliminating redundant computation in recursive or repeated calls.@dataclasses.dataclass— generates__init__,__repr__, and__eq__from annotated class fields automatically.
@timer on top of @log_calls means the timer wraps the already-logged version, not the raw function.