Mastering Python Decorators: A Practical Guide (2026)
Every FastAPI route begins with an @ line, and so does every Pydantic model and pytest fixture. Decorators are not exotic Python trivia — they are the grammar of the modern ecosystem. Understand them once, properly, and half of your favourite framework's documentation stops being mysterious.
The anatomy: a function that returns a wrapper
A decorator is just a function that takes a function and returns a new one that does a little extra work around it.
from functools import wraps
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
return result
return wrapper
@log_calls
def say_hello(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
The @wraps(func) line is not decoration for its own sake: without it, the wrapped function loses its name, docstring and signature, which quietly breaks documentation tools and IDE introspection. Copy it every time, no exceptions.
Factories: decorators that take arguments
The pattern that confuses everyone — a decorator with parameters — is one more layer: a function that returns a decorator.
from functools import wraps
def require_role(role: str):
def decorator(func):
@wraps(func)
def wrapper(user, *args, **kwargs):
if role not in user.roles:
raise PermissionError(f"Requires role: {role}")
return func(user, *args, **kwargs)
return wrapper
return decorator
That is the entire trick behind @app.get("/hello") and @retry(times=3). Once you see the extra layer as just another function call, the panic stops.
Do not hand-roll what the stdlib ships
For caching, functools.cache handles the unbounded case and functools.lru_cache(maxsize=128) bounds it — a memoised fibonacci runs instantly with two lines. For retries with exponential backoff against a flaky API, tenacity is more robust than anything you will write in an afternoon. Write your own decorators for the things specific to your codebase — audit logging, permission checks, timing — and use the batteries for the generic jobs.
Typing them properly
Untyped decorators erase signatures from the type checker's view. The modern fix is ParamSpec, and it is what the large libraries do internally:
from typing import Callable, ParamSpec, TypeVar
from functools import wraps
P = ParamSpec("P")
R = TypeVar("R")
def log_calls(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
return wrapper
This keeps full autocompletion alive through the wrapper — the difference between a decorator your team enjoys and one they quietly delete.
When not to write one
A decorator should earn its layer. If the wrapper adds nothing a plain function call could, skip it — flat beats nested applies to decorators most of all. The best decorator is the one a reader forgets is there.
Layers that protect quietly, without taking credit — the idea follows you out of the editor. In Gaza, neighbours and paramedics wrap the injured in whatever cloth survives the night and keep working: the oldest protection layer there is, running on no framework at all. May theirs hold, and may the day come when it never has to.
When your head is full of wrappers, the cure is a place with none. We run the weekend escapes out of Sialkot for exactly this at HTG Travels — Swat and Kalam itineraries with drivers who know the road and hotels confirmed in writing before you leave. The mountains do not care about your stack trace; that is their entire medical value.




