Python Decorators: How They Work and How to Write Your Own
Practice3 minInnovialabs editorial team
In short
A decorator is a function that takes a function and returns a new one with extra behavior before or after the call; the @decorator syntax is just shorthand for func = decorator(func).
What a decorator is
A decorator is a function that takes another function and returns a new one. Writing @timer above a function is simply shorthand for func = timer(func). Decorators let you move repeated logic — logging, timing, retries, permission checks — out of business code and into one place.
They work because of two Python features: functions are ordinary objects, and a nested function "remembers" the variables of its enclosing function. That second feature is called a closure.
Step 1: a closure
def make_greeter(greeting):
def greet(name):
return f"{greeting}, {name}!"
return greet
hello = make_greeter("Hello")
print(hello("Anna")) # Hello, Anna!
The inner greet still sees greeting after make_greeter has returned. A decorator works the same way, except the outer function receives a function instead of a string.
Step 2: your first decorator and functools.wraps
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__}: {time.perf_counter() - start:.3f} s")
return result
return wrapper
@timer
def build_report(n):
return sum(range(n))
The details that matter:
*args, **kwargs— the wrapper accepts any arguments and passes them through.return result— without it, the decorated function returnsNone.functools.wrapscopies__name__,__doc__and other attributes from the original. Without it every function is calledwrapper, which breaks logs, debugging and docs.
Step 3: a decorator with arguments
When a decorator needs parameters, add one more level: the outer function takes the settings and returns the actual decorator.
def retry(times=3, delay=1.0, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions:
if attempt == times:
raise
time.sleep(delay)
return wrapper
return decorator
@retry(times=5, delay=0.5, exceptions=(ConnectionError,))
def fetch_rates():
...
Note that @retry() with parentheses and @retry without them are different. In the second case the function lands in the times parameter and the code breaks.
Practical examples
Caching. For pure functions, don't write your own cache — the standard library has functools.lru_cache and functools.cache:
@functools.lru_cache(maxsize=256)
def get_tariff(region_id):
...
Arguments must be hashable, and the cache lives in the process memory.
Permission checks. A decorator fits handlers that receive the user as the first argument:
def require_role(role):
def decorator(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if role not in user.roles:
raise PermissionError(f"Role {role} required")
return func(user, *args, **kwargs)
return wrapper
return decorator
@require_role("admin")
def delete_order(user, order_id):
...
Class-based decorators vs class decorators
These are two different things:
| What | Looks like | Useful when |
|---|---|---|
| A class used as a decorator | class with __init__(self, func) and __call__ | you need state, such as a call counter |
| A decorator for classes | function takes a class and returns a class | registration, adding methods; this is how @dataclass works |
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.calls = 0
def __call__(self, *args, **kwargs):
self.calls += 1
return self.func(*args, **kwargs)
Common mistakes
- Forgetting
functools.wraps— the name and docstring are lost. - Forgetting
returnin the wrapper — the function silently returnsNone. - Mixing up
@decoand@deco()for parameterized decorators. - Ignoring order: decorators are applied bottom to top, the one closest to
deffirst. - Wrapping
async defwith a regular wrapper: for coroutines the wrapper must also beasyncandawait func(...). - Hiding too much logic in a decorator — if the behavior isn't obvious from the name, readers will struggle.