Python Decorators Explained With Real Code
- The actual definition
- A real working decorator: timing
- Why functools.wraps matters
- Decorators with arguments
- Class-based decorators
- When NOT to use a decorator
- FAQ
TL;DR: Decorators are functions that wrap other functions to add behavior — logging, caching, timing — without touching the original code. They're not magic, they're function composition with @-syntax sugar.
The actual definition
A decorator is a callable that takes a function and returns a (usually) different function. Python's @decorator syntax is just sugar for func = decorator(func). Once you internalize that, every confusing decorator pattern becomes obvious.
A real working decorator: timing
Here's a decorator that measures how long any function takes. Drop it in a file, import it, slap @timed on something slow.
import functools
import time
def timed(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return fn(*args, **kwargs)
finally:
elapsed_ms = (time.perf_counter() - start) * 1000
print(f"{fn.__name__} took {elapsed_ms:.2f}ms")
return wrapper
@timed
def slow_add(a, b):
time.sleep(0.1)
return a + b
slow_add(1, 2) # slow_add took 100.42ms
Why functools.wraps matters
Without @functools.wraps(fn) the wrapper steals the original function's name, docstring, and signature. Tools like pytest, Sphinx, and IDEs rely on those. Always include it.
Decorators with arguments
When you want @retry(tries=3), you need three layers: a function that takes config, returns a decorator, that returns a wrapper.
def retry(tries=3, delay=0.1):
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(tries):
try:
return fn(*args, **kwargs)
except Exception as e:
last_exc = e
time.sleep(delay * (2 ** attempt))
raise last_exc
return wrapper
return decorator
@retry(tries=3, delay=0.5)
def fetch_user(uid: int):
# ... call API that occasionally times out
return {"id": uid}
Class-based decorators
Sometimes you need state across calls. A class with __call__ works as a decorator and can hold instance state.
class CountCalls:
def __init__(self, fn):
functools.update_wrapper(self, fn)
self.fn = fn
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.fn(*args, **kwargs)
@CountCalls
def greet(name):
return f"hi {name}"
greet("a"); greet("b")
print(greet.count) # 2
When NOT to use a decorator
If the wrapping behavior changes per-call, use a context manager instead. If it changes the function's signature, that's an antipattern — every call site has to read your decorator's source to understand it. Keep decorators boring and orthogonal.
// recommended — frontend-masters Frontend Masters — Python and FastAPI courses by working engineersFAQ
Can I stack multiple decorators?
Yes. They apply bottom-up. @a\n@b\ndef f(): ... is equivalent to f = a(b(f)).
Are decorators slower than calling the function directly?
There's one extra function call per invocation. For hot paths in tight loops, that adds up. For everything else, it's free.
Should I use decorators for caching?
Use functools.cache or functools.lru_cache from the stdlib. They're battle-tested. Don't roll your own unless you have specific needs.