@decorator syntax looks like special language magic the first time you see it — it's actually just a function that takes a function and returns a (usually wrapped) function, with @ as syntactic sugar for calling it.
What @ actually desugars to
@my_decorator
def greet():
print("Hello")is exactly equivalent to:
def greet():
print("Hello")
greet = my_decorator(greet)@my_decorator above def greet is shorthand for "call my_decorator with greet, and reassign greet to whatever it returns." Nothing more mysterious than that.
Writing a basic decorator
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_calculation(n):
return sum(i * i for i in range(n))
slow_calculation(1_000_000)
# slow_calculation took 0.0821swrapper is what actually runs when you call slow_calculation(...) — it calls the real function, measures the time around that call, and returns the real result unchanged. *args, **kwargs let the wrapper forward any arguments the original function accepts, regardless of its actual signature.
functools.wraps: don't skip it
Without @functools.wraps(func), the decorated function's introspection metadata (__name__, __doc__) gets replaced by the wrapper's own — breaking anything that relies on it, including debuggers, documentation generators, and simple print(func.__name__) calls:
# Without functools.wraps
print(slow_calculation.__name__) # "wrapper" — wrong, confusing
# With functools.wraps
print(slow_calculation.__name__) # "slow_calculation" — correctA decorator that takes its own arguments
Decorators that need configuration (a retry count, a cache size) require an extra layer — a function that returns the actual decorator:
def retry(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception as e:
last_error = e
print(f"Attempt {attempt + 1} failed: {e}")
raise last_error
return wrapper
return decorator
@retry(times=3)
def flaky_api_call():
...@retry(times=3) calls retry(3) first, which returns the real decorator function, which then wraps flaky_api_call — three levels of nesting, but each layer has exactly one job: retry captures the argument, decorator does the actual wrapping, wrapper is what runs at call time.
Built-in decorators you're probably already using
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def area(self):
return 3.14159 * self._radius ** 2
@staticmethod
def from_diameter(diameter):
return Circle(diameter / 2)
@classmethod
def unit_circle(cls):
return cls(radius=1)@property makes circle.area callable like an attribute instead of circle.area(). @staticmethod and @classmethod are the same wrapping mechanism, just built into the language for these two specific, common patterns.
Stacking multiple decorators
@timer
@retry(times=3)
def fetch_data():
...Decorators apply bottom-up — retry wraps fetch_data first, then timer wraps the already-retry-wrapped function. This means timer measures the total time including all retry attempts, not just one call — the order of stacked decorators genuinely changes behavior, not just cosmetics.
Class-based decorators
A decorator doesn't have to be a function — any callable object works, and a class implementing __call__ is a genuinely useful alternative when the decorator needs to maintain its own state across calls:
class CallCounter:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.call_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
print(f"Call #{self.call_count} to {self.func.__name__}")
return self.func(*args, **kwargs)
@CallCounter
def process_order(order_id):
...
process_order(1) # Call #1 to process_order
process_order(2) # Call #2 to process_order
print(process_order.call_count) # 2functools.update_wrapper is the class-based equivalent of functools.wraps — same purpose, applied to a class instance instead of a nested function. This pattern is worth reaching for specifically when the decorator needs its own persistent state (a call count, a cache) that's cleaner to model as object attributes than as variables closed over by a nested function.
Preserving function signatures for type checkers
For a codebase using type hints seriously, a decorator that doesn't preserve the wrapped function's signature can confuse static type checkers into losing track of the real parameter and return types. The ParamSpec typing feature addresses this directly:
from typing import Callable, TypeVar
from typing_extensions import ParamSpec
P = ParamSpec("P")
T = TypeVar("T")
def timer(func: Callable[P, T]) -> Callable[P, T]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
# ... timing logic ...
return func(*args, **kwargs)
return wrapperWithout ParamSpec, a type checker typically can't verify that calls to the decorated function match the original function's real parameter types — worth adopting for any decorator in a codebase that takes type checking seriously, since a decorator is exactly the kind of wrapping code that silently erases type information without this.
functools.lru_cache: memoization as a built-in decorator
Python ships a production-ready memoization decorator, rather than needing to hand-roll the cache pattern shown earlier for most cases:
from functools import lru_cache
@lru_cache(maxsize=128)
def expensive_computation(n):
return sum(i * i for i in range(n))
expensive_computation(1_000_000) # slow, computes and caches
expensive_computation(1_000_000) # instant, returns cached resultmaxsize=128 caps the cache at the 128 most recently used distinct argument combinations, evicting the least-recently-used entry once that limit is reached — worth knowing about before reaching for a hand-written cache decorator, since lru_cache already handles eviction correctly.
Common mistakes
- Forgetting
functools.wraps, silently breaking__name__,__doc__, and anything else that introspects the decorated function — a small omission with real downstream effects on debugging and documentation tools. - Writing a decorator that doesn't accept
*args, **kwargs, breaking the moment it's applied to a function with a different signature than whatever it was originally tested against. - Not considering stacking order when applying multiple decorators — since they apply bottom-up, the visual top-to-bottom order in the source doesn't match the actual wrapping order, which can produce surprising behavior if the decorators aren't independent of each other.
- Doing expensive work directly at decoration time (inside
decorator, not insidewrapper) when it should happen per-call instead — code outside the innermostwrapperfunction runs once, at decoration time, not on every call to the decorated function.
Related reading
- Async Python with asyncio: A Practical Introduction — shares tags: python, programming (same category).
- Python Virtual Environments: venv, pipenv, poetry, or uv? — shares tags: python, programming.
- Clean Code Principles That Actually Hold Up in Practice — shares tags: programming.
- JavaScript Closures Explained: What They Actually Are and Why They Matter — shares tags: programming.
- Big O Notation Without the Math Panic — shares tags: programming.