A decorator is nothing more than a function that takes a function and returns a function. That single idea, once it clicks, explains @property, @staticmethod, @app.route, @pytest.fixture, and every other @-prefixed line you've ever copied without fully understanding.
1A Decorator Is Just a Function That Returns a Function
The entirety of what a decorator is can be demonstrated without the @ syntax at all: timer is an ordinary function that accepts another function, func, and returns a brand new function, wrapper, which calls func and adds behavior around that call. say_hello = timer(say_hello) reassigns the name say_hello to point at wrapper instead of the original function — the original function object still exists, just captured inside wrapper's closure.
@timer written directly above def say_hello(): is defined by the language to mean exactly that reassignment, evaluated immediately after the function body is compiled. There is no additional mechanism, no special runtime hook — it's syntactic sugar for func = decorator(func), which is precisely why any callable that accepts one function argument and returns one callable can be used as a decorator, including classes with a __call__ method.
This is also why decorators compose in a predictable, textually-readable order: @a @b def f(): is equivalent to f = a(b(f)) — b wraps f first, then a wraps the result, and calling the final f() runs a's wrapper code, which calls b's wrapper code, which calls the original f.
def timer(func):
def wrapper(*args, **kwargs):
import time
start = time.time()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.time() - start:.4f}s")
return result
return wrapper
def say_hello():
print("Hello!")
say_hello = timer(say_hello) # manual decoration
say_hello()Hello!
say_hello took 0.0001s
2functools.wraps: Preserving Identity Through the Wrapper
The moment wrapper replaces the original function, every piece of metadata that made the original identifiable — __name__, __doc__, __module__, and the function's signature as introspected by tools — now belongs to wrapper, not to the function you actually decorated. Calling say_hello.__name__ after decoration returns 'wrapper', which is actively misleading in tracebacks, in help(say_hello), and in any tool (like a web framework's route table, or Sphinx documentation generation) that relies on introspecting the function's real name or docstring.
functools.wraps(func), applied as a decorator to wrapper itself, copies func's __name__, __doc__, __module__, __dict__, and a few other attributes onto wrapper, and also sets wrapper.__wrapped__ = func, which lets introspection tools (and inspect.signature) find the original function's real signature even through the wrapping layer.
Omitting @wraps is a distinctive beginner tell in decorator code, and it's not merely cosmetic: debugging a stack trace that shows wrapper instead of the actual function name that failed, or discovering that a framework's automatic API documentation shows every decorated endpoint as wrapper(*args, **kwargs), are both real, time-costing consequences of skipping it.
@timer
def say_hello():
print("Hello!")
# identical to: say_hello = timer(say_hello)
say_hello()'say_hello' — identity preserved
3Parameterized Decorators: A Function Returning a Decorator
@timer and @retry(times=3) look similar but differ in one crucial way: @timer applies timer directly to the function, while @retry(times=3) first calls retry(3) — an ordinary function call, evaluated immediately — and *whatever that call returns* becomes the decorator that's actually applied. This means retry itself is not a decorator; it's a *decorator factory*, a function whose job is to build and return the real decorator, now closed over the times argument.
That's why the parameterized version needs three nested levels instead of two: retry(times) returns decorator, decorator(func) returns wrapper, and wrapper(*args, **kwargs) is what actually executes when the decorated function is called. Each layer's closure carries forward exactly the state it needs — decorator remembers times from its enclosing scope, and wrapper remembers both times and func.
A subtlety worth internalizing: because retry(times=3) must always be called with parentheses — even with no arguments, as @retry() — a common ergonomic pattern is making the decorator work both bare (@retry) and called (@retry(times=3)) by checking, inside the outer function, whether it was called with a single callable argument. Most production decorators skip that complexity and simply require the parentheses consistently, favoring predictability over cleverness.
print(say_hello.__name__) # 'wrapper', not 'say_hello' — misleading!
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapperretry(3) → decorator → decorator(flaky_call) → wrapper
4Step-by-Step Breakdown
Every '@' symbol above a function definition is calling a function on a function. Let's build that mental model from scratch.
Strip away the @ syntax and a decorator is just this: timer(say_hello) returns a new function that wraps the original.
@timer above a function definition is pure syntactic sugar for exactly that reassignment — nothing more.
Checkpoint: What does @timer above def say_hello(): actually do?
- →Reassigns say_hello to the result of timer(say_hello)
- →Runs say_hello in a background thread automatically
Without functools.wraps, the decorated function loses its real name and docstring — a common source of confusing debugging sessions.
A decorator that takes its own arguments needs an extra layer: a function that returns a decorator.
Checkpoint: Why does a parameterized decorator like @retry(times=3) need an extra nested function level compared to @timer?
- →@retry(times=3) first calls retry(3), which must return the actual decorator function
- →It does not — parameterized and non-parameterized decorators are identical
Decorators wrap callables — generators, our next stop, let you build lazy, memory-efficient iterables using the same 'function that isn't quite a function' intuition.
Build a Real Logging Decorator. Finish log_call(): a decorator wraps a function to add behavior before/after the original call.
Level Up 🚀
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Always apply @functools.wraps(func) to your wrapper function
It costs one line and preserves __name__, __doc__, and introspectability — skipping it silently corrupts debugging, documentation generation, and any framework that inspects the decorated function.
Keep decorators single-purpose and composable
A @timer that only times and a @retry that only retries can be stacked (@timer @retry(times=3) def f(): ...) far more flexibly than one decorator that tries to time, retry, and log all at once.
Frequent Bugs
Forgetting *args, **kwargs on wrapper, breaking any decorated function that takes arguments other than the exact ones the decorator's author anticipated.
Write wrapper as def wrapper(*args, **kwargs): and forward them with func(*args, **kwargs) unless you have a specific, documented reason to restrict the decorated function's signature.
Real-World Examples
A Simple Permission-Check Decorator for a Web Handler
A Flask/FastAPI-style handler function should only execute if the current user has the "admin" role, otherwise it should raise an error before the handler body runs.
from functools import wraps
def require_admin(func):
@wraps(func)
def wrapper(user, *args, **kwargs):
if not user.get("is_admin"):
raise PermissionError("Admin access required")
return func(user, *args, **kwargs)
return wrapper
@require_admin
def delete_account(user, account_id: int) -> None:
print(f"Deleting account {account_id}")