Decorators
Decorators are the cheapest way to attach new behavior in Python. They are also one of the easiest ways to destroy signatures, metadata, and debuggability if you treat wrappers casually.
Quick takeaway: a good decorator changes behavior while preserving the function's signature and metadata. `functools.wraps` and `ParamSpec` are close to mandatory defaults for serious decorator work.
Where a Decorator Acts
Baseline Pattern With wraps and ParamSpec
from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def traced(name: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"[{name}] before", func.__name__)
result = func(*args, **kwargs)
print(f"[{name}] after", func.__name__)
return result
return wrapper
return decorator
@traced("calc")
def add(x: int, y: int) -> int:
return x + y
print(add(1, 2))
print(add.__name__)`wraps()` preserves metadata such as `__name__`, `__doc__`, and `__wrapped__`. `ParamSpec` keeps the wrapper aligned with the original call signature at type-check time.
Function vs Class Decorators
- function decorators wrap or augment call behavior
- class decorators post-process class objects
- class decorators are often a lighter alternative to metaclasses
Overuse Patterns to Watch
Too much implicit behavior
Stacking retry, logging, tracing, auth, and transaction behavior across many decorators quickly hurts debuggability.
Metadata loss
Without `wraps()`, introspection, documentation, and framework integration can break.
Type degradation
Using `Callable[..., Any]` everywhere turns decorated code into a type-checking blind spot.
Stateful wrappers
Long-lived mutable state inside decorators can create concurrency and test-isolation problems.
Practical Connections
- FastAPI route decorators
- dependency helpers
- logging, tracing, and caching wrappers