What is it? #
A decorator is a function that takes another function and returns a replacement for it. The @ syntax is shorthand for reassigning the original name to the wrapped version.
They exist because some concerns — timing, logging, caching, retries, permission checks — apply to many functions but are not what those functions are about. Writing them inside every function buries the real logic.
Under the hood the trick is simple: functions are objects. You can pass them around, store them and return new ones that call the original.
The one thing to always remember is functools.wraps. Without it, the decorated function loses its name and docstring, which breaks debugging, documentation tools and some frameworks.
Think of it like this #
A decorator is like gift wrapping. The present inside is unchanged; the wrapping adds something around it. You can even wrap a wrapped present, which is exactly what stacking decorators does.
Simple example #
You want to know how long a slow database call takes, and you want a second function to cache its result. Neither concern belongs inside the functions themselves.
Code #
import time
import functools
def timed(func):
@functools.wraps(func) # keeps name and docstring
def wrapper(*args, **kwargs):
started = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = (time.perf_counter() - started) * 1000
print(f"{func.__name__} took {elapsed:.1f} ms")
return wrapper
@timed
def fetch_orders(customer_id):
"""Pretend this hits a database."""
time.sleep(0.2)
return [{"id": 1}, {"id": 2}]
orders = fetch_orders(42)
print(fetch_orders.__name__) # fetch_orders — thanks to wraps
# A decorator that takes arguments needs one more layer
def retry(times=3, delay=0.1):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except ConnectionError as exc:
last = exc
print(f"attempt {attempt} failed")
time.sleep(delay)
raise last
return wrapper
return decorator
@retry(times=2)
def unreliable():
raise ConnectionError("network down")
# Built-in caching decorator
@functools.lru_cache(maxsize=128)
def slow_square(n):
time.sleep(0.1)
return n * n
print(slow_square(12), slow_square(12)) # second call is instant
How it works #
timed receives func and returns wrapper. The @timed line above fetch_orders is exactly the same as writing fetch_orders = timed(fetch_orders) after the definition.
*args, **kwargs in the wrapper means "accept whatever the original accepts and pass it straight through". That is what makes the decorator reusable for functions with any signature.
The try/finally guarantees the timing gets printed even when the wrapped function raises, and the exception still propagates normally.
@functools.wraps(func) copies the original name, docstring and metadata onto the wrapper. Without it, fetch_orders.__name__ would print wrapper, and every function in your logs would look identical.
retry has three levels because it takes arguments. retry(times=2) is called first and returns decorator, which then receives the function. That extra layer is the standard shape for any configurable decorator.
functools.lru_cache is a ready-made decorator that remembers results by arguments. Calling slow_square(12) twice runs the body once. It only works when the arguments are hashable and the function is pure — same inputs, same output, no side effects.
Real-world use #
Web frameworks use decorators for routing (@app.get("/orders")), authentication (@login_required), rate limiting, and transactions. Test frameworks use them for fixtures and parameterised cases.
In application code the common ones are caching expensive lookups, timing slow paths, retrying flaky network calls and logging entry and exit during debugging.
The discipline is to keep decorators thin. A decorator that quietly changes return values or swallows exceptions makes debugging painful, because the behaviour is invisible at the call site. Cross-cutting and predictable is the sweet spot.
Common mistakes #
- Forgetting
functools.wraps, which destroys the function name and docstring. - Not passing
*args, **kwargsthrough, so the decorator only works for one signature. - Swallowing exceptions inside the wrapper, hiding real failures.
- Using
lru_cacheon a function with side effects or unhashable arguments. - Stacking five decorators on one function until nobody can tell what actually runs.
Practice #
Write a log_calls decorator that prints the function name and its arguments before calling it, and prints the return value afterwards. Apply it to a small function. Then write a require_positive decorator that raises a ValueError if any numeric argument is negative.