Python Decorators Deep Dive
A practical tour of Python decorators: how they work under the hood, when to use them, and how to write decorators that preserve metadata, accept arguments, and stack cleanly.
What you'll learn
- ✓How decorators desugar into plain function calls
- ✓Writing decorators with and without arguments
- ✓Preserving metadata with functools.wraps
- ✓Stacking decorators and order of execution
- ✓Common pitfalls and production-grade patterns
Prerequisites
- •Basic Python
What and why
A decorator is a callable that takes a function and returns a function. That is all. The @ syntax is sugar: @deco above def f(): ... means f = deco(f). Once you internalize that, decorators stop feeling magical and start feeling like ordinary higher-order functions.
Why use them? Decorators give you a clean place to insert cross-cutting behavior: logging, caching, authentication, retries, timing, validation. Instead of repeating the same five lines at the top of every view or job, you wrap the function and move on.
Mental model
Think of a decorator as a pipeline. The original function goes in, a new function comes out, and that new function is what your caller actually invokes. The original is captured in a closure.
@log_calls log_calls(greet)
def greet(name): => greet = log_calls(greet)
...
call greet("ada")
|
v
+----------------+ +-----------------+ +----------------+
| wrapper(*a) | -> | original greet | -> | return value |
| pre-hook | | (closure) | | post-hook |
+----------------+ +-----------------+ +----------------+ Hands-on example
Start with the simplest possible decorator: one that logs every call.
import functools
import time
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"-> {func.__name__}({args}, {kwargs})")
result = func(*args, **kwargs)
print(f"<- {func.__name__} = {result!r}")
return result
return wrapper
@log_calls
def greet(name):
return f"hello, {name}"
greet("ada")
functools.wraps copies the name, docstring, and __wrapped__ attribute from the original to the wrapper. Without it, greet.__name__ becomes "wrapper", which breaks introspection, Sphinx docs, and frameworks like FastAPI that read signatures.
Now a decorator that takes arguments. This requires one more layer: a factory that returns the decorator.
def retry(times=3, delay=0.1):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last = None
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception as exc:
last = exc
time.sleep(delay * (2 ** attempt))
raise last
return wrapper
return decorator
@retry(times=4, delay=0.2)
def fetch(url):
...
Read it bottom-up: retry(times=4, delay=0.2) runs first and returns decorator. Then decorator(fetch) runs and returns wrapper. The @ syntax glues these calls together.
Stacking works the same way. The decorator closest to the function is applied first.
@log_calls
@retry(times=3)
def charge(user, amount):
...
# equivalent: charge = log_calls(retry(times=3)(charge))
So when you call charge(...), log_calls’s wrapper runs first, which calls retry’s wrapper, which calls the real charge. The outermost decorator sees every retry as a single call. Swap the order and log_calls would log each retry attempt instead.
Common pitfalls
Forgetting functools.wraps is the most common mistake. It bites you the moment you look at stack traces or generate OpenAPI schemas.
Mutating shared state in the closure is another trap. If your decorator keeps a list of seen arguments for caching, that list is global to all calls of the wrapped function. Use functools.lru_cache for memoization instead of hand-rolling unless you really need custom eviction.
Decorating methods is fragile. self is just the first positional argument from the wrapper’s perspective, so it works, but if you want a decorator that distinguishes methods from functions, inspect the signature or use functools.wraps plus inspect.ismethod carefully. For classmethods and staticmethods, apply your decorator below @classmethod, never above.
Finally, async functions need async wrappers. A sync wrapper around an async function returns a coroutine without awaiting it, which silently breaks behavior.
def timed(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return await func(*args, **kwargs)
finally:
print(f"{func.__name__} took {time.perf_counter()-start:.3f}s")
return wrapper
If you need one decorator that supports both, branch on asyncio.iscoroutinefunction(func) and return the appropriate wrapper.
Production tips
Make the wrapped function discoverable. Set wrapper.__wrapped__ = func (which functools.wraps does automatically) so that inspect.unwrap can peel layers in production debugging tools.
Keep decorators thin. If logic grows past twenty lines, push it into a helper class and have the decorator delegate. This makes the behavior testable in isolation and avoids deeply nested closures.
Be careful with side effects at import time. Code inside the decorator body, but outside wrapper, runs once when the module is imported. That is often what you want for registering routes or metrics, but it also means a slow import-time call can hurt cold starts.
Type your decorators. Use typing.ParamSpec and TypeVar so that the wrapped function keeps its original signature in your IDE and in mypy.
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def log_calls(func: Callable[P, R]) -> Callable[P, R]:
...
Wrap-up
Decorators are just functions that return functions. The @ syntax is sugar, functools.wraps is mandatory, and decorators with arguments need one extra layer. Once you have the mental model, you can compose retries, logging, auth, caching, and timing without polluting every function body. Keep them small, type them, and respect async boundaries.
Related articles
- Python Python Decorators Explained
Learn how Python decorators work from first principles — functions as objects, wrapping callables, preserving metadata with functools.wraps, and writing decorators that take arguments.
- Python Python Decorators: The Beginner-Friendly Guide
A practical introduction to Python decorators — functions as objects, wrapping, the @ syntax, functools.wraps, a logging and timing decorator, and decorators with arguments.
- Python Python asyncio Event Loop Guide
Understand how Python's asyncio event loop schedules coroutines, what await actually does, and how to avoid the classic mistakes that turn async code into a tangle of bugs.
- Python Python Logging Best Practices
How to set up Python logging properly: loggers vs handlers, structured logs, contextual fields, log levels that scale, and how to avoid the classic print-debug trap.