Decorators That Don't Break Things: A Practical Deep Dive into Python Decorators
Learn how Python decorators really work — from the closure underneath to functools.wraps, decorators that take arguments, class-based decorators, stacking, and the pitfalls that bite people in production.
Decorators are one of the first pieces of Python that feel like magic. You put an @something on a line above a function, and suddenly that function is timed, cached, retried, or logged — without a single change to its body. That magic is also where the confusion starts. Why does @wraps matter? How do you write a decorator that takes arguments? Why do stacked decorators run in a surprising order?
This guide takes the mystery out of all of it. By the end you'll be able to read any decorator in a codebase and write your own with confidence — including the small details that separate a toy decorator from one you'd ship.
The one idea underneath everything
A decorator is just a function that takes a function and returns a function. The @ syntax is nothing more than syntactic sugar. These two snippets are identical:
@announce
def add(a, b):
return a + b
# is exactly the same as:
def add(a, b):
return a + b
add = announce(add)
Once that clicks, decorators stop being special syntax and become ordinary function calls. Here is the classic template, built on a closure — an inner wrapper function that "remembers" the original func:
import functools
def announce(func):
@functools.wraps(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
@announce
def add(a, b):
return a + b
print(add(2, 3))
# Calling add
# add returned 5
# 5
The *args, **kwargs in the wrapper is what makes the decorator reusable: it accepts any call signature and forwards it untouched to the wrapped function. Notice too that the wrapper returns result. Forgetting that return is the single most common decorator bug — the decorated function silently starts returning None.
Why functools.wraps is not optional
Without @functools.wraps, the wrapper replaces your function's identity. The name, docstring, and signature all become the wrapper's. That breaks documentation tools, debuggers, and anything that introspects the function.
def announce(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper # no @wraps
@announce
def greet(name):
"Return a greeting."
return f"Hi {name}"
print(greet.__name__) # 'wrapper' ← wrong
print(greet.__doc__) # None ← lost
Add @functools.wraps(func) to the wrapper and greet.__name__ is 'greet' again, the docstring is preserved, and inspect.signature reports the original parameters. Treat it as mandatory boilerplate on every decorator you write.
Decorators that take arguments
This is the part that trips people up, but it follows directly from the one idea above. A decorator receives a function. So a decorator that takes arguments must be a function that returns a decorator. That means three nested layers.
import functools
def retry(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exc = exc
print(f"attempt {attempt} failed: {exc}")
raise last_exc
return wrapper
return decorator
@retry(times=3)
def flaky():
...
Read it from the outside in: retry(times=3) runs first and returns decorator; then decorator is applied to flaky, exactly like a normal decorator. The times argument stays alive in the closure. If you can write the three-layer version from memory, you understand decorators.
A genuinely useful example: timing
Here's a decorator you'll actually reach for. Note the try/finally — it ensures the timing is reported even if the wrapped function raises.
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:.6f}s")
return wrapper
@timed
def work():
return sum(range(100_000))
work() # work took 0.000697s
Use time.perf_counter() rather than time.time() for measuring durations — it's a monotonic, high-resolution clock that won't jump if the system clock is adjusted.
Stacking decorators — mind the order
You can apply more than one decorator. They compose bottom-up: the one closest to the function wraps it first, and the one on top wraps the result.
@announce
@timed
def compute(x):
return x * x
compute(9)
This is equivalent to compute = announce(timed(compute)). So timed is the inner layer and announce is the outer one. When you call compute(9), announce's wrapper runs first, then hands off to timed's wrapper, then to the real function. Reading a decorator stack top-to-bottom tells you the order the wrappers begin executing; the function itself runs last.
Class-based decorators
A decorator only has to be callable and return something callable — it doesn't have to be a function. A class whose instances are callable (via __call__) works nicely when you want the decorator to hold state, like a call counter.
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CountCalls
def ping():
return "pong"
ping()
ping()
print(ping.count) # 2
Here @CountCalls replaces ping with an instance of the class. Each call bumps self.count. Note functools.update_wrapper(self, func) — it's the class-decorator equivalent of @functools.wraps, copying the wrapped function's metadata onto the instance.
You don't always have to write your own
The standard library ships decorators worth knowing before you build a homemade one. functools.lru_cache memoizes a function's results — turning an exponential recursive Fibonacci into a linear one:
import functools
@functools.lru_cache(maxsize=None)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(30)) # 832040
print(fib.cache_info()) # CacheInfo(hits=28, misses=31, ...)
Others you'll meet often: functools.cache (a simpler unbounded lru_cache in 3.9+), functools.cached_property for lazy, cached instance attributes, @staticmethod and @classmethod, @property, and functools.singledispatch for function overloading by argument type.
Common pitfalls
Forgetting to return the result. If your wrapper doesn't return func(...), every decorated call returns None. Easy to miss, painful to debug.
Skipping @wraps. Without it, introspection, logging, and framework routing (Flask, FastAPI, pytest) can break because they rely on __name__ and signatures.
Swallowing exceptions. A bare try/except in a wrapper that doesn't re-raise will hide real errors. Be deliberate about what you catch, and prefer try/finally when you only need cleanup.
Decorating methods and losing self. Because *args captures everything, method decorators usually just work — but remember that args[0] is self for instance methods if you need to inspect it.
Shared mutable state. A single decorator instance (as with class-based decorators or caches) is shared across all calls. That's often the point, but be aware of it in concurrent code.
Wrap-up and next steps
Decorators reduce to a single sentence: a callable that takes a function and returns a replacement. Everything else — arguments, stacking, class-based versions — is that idea applied recursively. Keep functools.wraps on every wrapper, always return the result, and reach for the standard library's built-in decorators before rolling your own.
From here, try writing a decorator that caches results to disk, one that enforces argument types using inspect.signature, or an async-aware decorator that wraps coroutine functions. Each pushes on the same core mechanic you now understand — and once you've written a few, the @ stops looking like magic and starts looking like a tool.