Python Decorators Explained with Practical Examples
A decorator wraps a function to add behavior such as timing, retries, or caching. Learn how they work, how to write them correctly, and when to avoid them.
A team adds the same six lines to forty functions: record the start time, run the work, record the end time, and log the difference. Then the log format changes, and someone edits forty functions. One of them gets missed. A decorator would have put those six lines in one place, applied with a single line above each function.
Python decorators are callables that accept a function and return a function, usually a wrapper that adds behavior before or after the original runs. The @name line above a definition is shorthand for passing the function through that callable.
This guide uses Python 3.10 and only the standard library. Check your interpreter with python --version. You need two ideas first. A closure is an inner function that remembers variables from the function that created it. The *args, **kwargs pair collects and forwards any arguments. You can read more about closures and the *args and **kwargs syntax if either is unfamiliar.
My position: a good decorator is one you could delete without changing what the function returns. Logging, timing, caching, and retries pass that test. Anything that alters results or hides control flow belongs in the function body, where readers can see it.
How Python decorators work
Functions in Python are objects. You can assign one to a variable, pass it as an argument, and return it from another function. A decorator uses all three abilities.
def shout(func):
def wrapper():
result = func()
return result.upper()
return wrapper
def greet():
return 'hello'
greet = shout(greet) # replace greet with the wrapper
print(greet())
HELLO
The shout function receives greet, defines a wrapper that calls it, and returns the wrapper. The wrapper is a closure: it remembers func after shout has finished. The @ syntax performs the same reassignment for you:
@shout
def greet():
return 'hello'
What @shout does
@shout greet = shout(greet)
def greet(): ... ==
What a call does afterwards
greet() ---> wrapper() ---> original greet()
| |
+---- result <------+
|
returns result.upper()
Nothing else is involved. A decorator is ordinary function application with nicer syntax. The trade-off appears immediately, though: the name greet now points at wrapper, not at the code you wrote.
Forward any arguments and return the result
The first wrapper accepted no arguments, so it works only for functions without parameters. A general decorator forwards whatever it receives and returns whatever the function returns.
def log_calls(func):
def wrapper(*args, **kwargs):
print(f'calling {func.__name__} with {args} {kwargs}')
result = func(*args, **kwargs)
print(f'{func.__name__} returned {result!r}')
return result
return wrapper
@log_calls
def add(a, b=0):
return a + b
add(2, b=3)
calling add with (2,) {'b': 3}
add returned 5
Two bugs are common here. Forgetting return result makes every decorated function return None. Forgetting *args, **kwargs raises TypeError: wrapper() takes 0 positional arguments but 1 was given.
Preserve identity with functools.wraps
Because the wrapper replaces the original, the function’s name and docstring disappear unless you copy them.
# WRONG: the decorated function loses its name and docstring
def log_calls(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@log_calls
def add(a, b):
"""Return the sum of a and b."""
return a + b
print(add.__name__, add.__doc__)
wrapper None
# RIGHT: functools.wraps copies the metadata onto the wrapper
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@log_calls
def add(a, b):
"""Return the sum of a and b."""
return a + b
print(add.__name__, add.__doc__)
add Return the sum of a and b.
Without wraps, every traceback, log line, and profiler row shows wrapper. With forty decorated functions, you cannot tell which one failed. functools.wraps also sets a __wrapped__ attribute that points at the original, which helps debugging and testing. It costs one line, so apply it to every wrapper you write.
Decorators with arguments
Sometimes the decorator itself needs configuration, as in @retry(times=3). That requires one more layer. The outer function takes the settings and returns a decorator, which takes the function and returns the wrapper.
retry(times=3) returns decorator
decorator(func) returns wrapper
wrapper(*args, **kw) calls func, up to 3 times
The complete example below shows the full pattern. One pitfall follows from the extra layer. If you write @retry without parentheses, Python passes your function as the times argument. No error appears at definition time. Instead, calling the function returns another function, not your result. Always include the parentheses for a decorator that takes arguments.
A complete example: timing and retries
This script defines a timing decorator and a configurable retry decorator, then stacks them. It also uses ParamSpec, added to typing in Python 3.10, so type checkers keep the decorated function’s real signature. Save it as decorators_demo.py.
import functools
import time
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec('P')
R = TypeVar('R')
def timed(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
print(f'{func.__name__} took {elapsed:.4f}s')
return wrapper
def retry(
times: int = 3,
exceptions: tuple[type[Exception], ...] = (Exception,),
delay: float = 0.0,
):
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as error:
if attempt == times:
raise
print(f'attempt {attempt} failed: {error}')
time.sleep(delay)
raise AssertionError('times must be at least 1')
return wrapper
return decorator
calls = {'count': 0}
@timed
@retry(times=3, exceptions=(ConnectionError,))
def fetch_report(name: str) -> str:
"""Pretend to call a server that fails twice, then succeeds."""
calls['count'] += 1
if calls['count'] < 3:
raise ConnectionError('server busy')
return f'report {name} ready'
def main() -> None:
print(fetch_report('sales'))
print(fetch_report.__name__)
if __name__ == '__main__':
main()
python decorators_demo.py
attempt 1 failed: server busy
attempt 2 failed: server busy
fetch_report took 0.0000s
report sales ready
fetch_report
Three design choices matter. First, retry catches only the exception types you list, so a bug such as TypeError fails at once instead of being retried. Second, the final attempt uses a bare raise, so the caller sees the real error with its traceback. Third, timed prints in a finally block, so it reports the duration even when the function raises.
Stacking order
When you stack decorators, Python applies them from the bottom up. The line closest to the function wraps first.
@timed fetch_report = timed(retry(...)(fetch_report))
@retry(times=3)
def fetch_report(): ...
call ---> timed wrapper ---> retry wrapper ---> original function
Here, timed is outermost, so it measures all three attempts together. Swap the two lines and it would time each attempt separately, printing three durations. Consequently, the order of decorators is part of the behavior, and a reordering during cleanup can change results.
The myth: the decorator runs each time you call the function
Many developers picture a decorator as code that runs on every call. Only the wrapper does. The decorator function itself runs exactly once, when Python executes the def statement, which usually means at import time.
def announce(func):
print(f'decorating {func.__name__}')
return func
@announce
def hello():
print('hello')
print('module loaded')
hello()
hello()
decorating hello
module loaded
hello
hello
The message decorating hello prints once, before the module finishes loading, and never again. Notice also that announce returns the function unchanged. A decorator does not have to wrap anything.
This timing leads to an insight that the wrapping examples hide. A decorator is a hook that runs at definition time, which makes it a registration tool. Web frameworks use this for routes, and test tools use it for marks:
COMMANDS = {}
def command(func):
COMMANDS[func.__name__] = func
return func
@command
def backup():
print('backing up')
@command
def restore():
print('restoring')
COMMANDS['backup']()
print(sorted(COMMANDS))
backing up
['backup', 'restore']
The same timing carries a cost. Any slow work in the decorator body, such as opening a connection or reading a file, runs on import. It slows startup and runs even in code paths that never call the function.
Decorators in the standard library
You already use decorators that ship with Python. Knowing them prevents you from rewriting them.
| Decorator | What it does | Watch out for |
|---|---|---|
@functools.lru_cache and @functools.cache |
Remember results for repeated arguments | Arguments must be hashable, and the cache keeps them alive |
@property |
Exposes a method as a read-only attribute | Callers expect it to be cheap |
@staticmethod and @classmethod |
Change what a method receives as its first argument | A plain function is often simpler than a static method |
@contextlib.contextmanager |
Turns a generator function into a context manager | Wrap the yield in try and finally |
@dataclasses.dataclass |
Generates __init__, __repr__, and __eq__ for a class |
It decorates a class, not a function |
The last row shows that decorators apply to classes too. A class decorator receives the class and returns a class, which is exactly how @dataclass works. You can read more about dataclasses and regular classes separately.
Caching deserves one specific warning. Never put lru_cache on a generator function. The cache stores the generator object, and the second caller receives the same, already exhausted generator and gets no items. If that sentence is unclear, read about how generators work.
import functools
@functools.lru_cache(maxsize=None)
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(80))
print(fibonacci.cache_info())
23416728348467685
CacheInfo(hits=78, misses=81, maxsize=None, currsize=81)
Without the cache, this recursive function would make an astronomical number of calls for n=80. With it, each value is computed once.
What a wrapper costs
Every decorated call adds one extra function call, plus the packing and unpacking of arguments. You can measure it with my illustrative script:
import functools
import timeit
def passthrough(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def plain(x):
return x
@passthrough
def wrapped(x):
return x
if __name__ == '__main__':
number = 1_000_000
print(f'plain: {timeit.timeit(lambda: plain(1), number=number):.3f}s')
print(f'wrapped: {timeit.timeit(lambda: wrapped(1), number=number):.3f}s')
On my machine, the wrapped call takes noticeably longer than the plain one for this trivial function, and the absolute difference stays well under a microsecond per call. For a function that queries a database, that overhead is invisible. For a function called ten million times in a tight loop, it can dominate. Measure before you decorate a hot path.
How real systems use decorators
- Route and command registration. Web frameworks such as Flask map a URL to a function with
@app.route('/tasks'). The decorator records the function in a table at import time. - Authentication and permission checks. A decorator such as Django’s
@login_requiredrejects the request before the view runs. The view body then contains only the feature logic. - Retries around network calls. Client code wraps calls to other services with a retry decorator that lists specific exception types and a backoff delay.
- Caching pure functions. Expensive, deterministic lookups carry
@lru_cachewith a boundedmaxsize. - Metrics and tracing. A timing decorator reports durations to a metrics system, so every decorated function gets the same measurement without repeated code.
A mistake I have seen in production is @lru_cache placed on an instance method of a request-scoped object. The cache held a reference to every instance through its self argument, so none of them could be garbage-collected. Memory grew steadily until the process restarted each night. Moving the cached logic into a module-level function that took only hashable values fixed the leak.
Writing a decorator: a decision framework
Ask these questions in order before you write one.
- Does the behavior apply to several unrelated functions? If only one function needs it, put the code in that function.
- Could you remove it without changing return values? If yes, it is a cross-cutting concern and a good fit. If no, make it an explicit function call.
- Does the standard library already provide it? Check
functoolsandcontextlibfirst. - Does it need configuration? Write a decorator factory with keyword arguments, and require the parentheses.
- Does it only need to record the function? Return the original function unchanged and skip the wrapper entirely.
When NOT to use a decorator
- When it changes what the function means. A decorator that converts return values, swallows exceptions, or skips the call under some condition hides control flow. Readers of the function body cannot see it.
- When a context manager fits better. If you need the behavior around a few lines, not a whole function, a
withblock is more precise. - When the stack grows past two or three. Five stacked decorators create an ordering puzzle. At that point, one explicit function that calls the steps in sequence is easier to test.
Common mistakes
- Forgetting to return the result. The wrapper calls the function and drops the value. Every decorated function returns
None. - Skipping functools.wraps. Names and docstrings become
wrapperandNone. Tracebacks and logs stop identifying the function. - Omitting parentheses on a decorator factory. Writing
@retryinstead of@retry()passes the function as a setting. Calls then return a function instead of a result. - Retrying every exception. A decorator that catches
Exceptionretries programming errors too. Bugs take longer to surface and can repeat side effects such as sending emails. - Doing heavy work at decoration time. Code in the decorator body runs on import. Startup slows down, and imports can fail when a service is unreachable.
- Caching functions with mutable or unhashable arguments.
lru_cacheraisesTypeError: unhashable type: 'list', or returns a shared mutable result that one caller then modifies for everyone.
Key takeaways
@decoratorabove a function meansfunc = decorator(func), and nothing more.- A wrapper should accept
*args, **kwargs, call the original, and return its result. - Apply
@functools.wraps(func)to every wrapper. - A decorator with arguments needs three levels: settings, function, call.
- Stacked decorators apply bottom-up, so the top one runs first at call time.
- The decorator body runs once at definition time, and only the wrapper runs per call.
- Keep decorators for logging, timing, caching, retries, and access checks.
FAQ
What is a decorator in Python?
A decorator is a callable that takes a function and returns a function, usually a wrapper that adds behavior. The @name syntax applies it at definition time.
What does functools.wraps do?
It copies the original function’s name, docstring, and other metadata onto the wrapper. Without it, the decorated function reports itself as wrapper in tracebacks and logs.
How do I pass arguments to a Python decorator?
Write a function that accepts the arguments and returns a decorator. You then apply it with parentheses, for example @retry(times=3).
In what order are multiple decorators applied?
Python applies them from the bottom up. The decorator closest to the function wraps it first, and the top decorator becomes the outermost layer that runs first on each call.
Do decorators slow down Python code?
Each wrapper adds one extra function call, which costs well under a microsecond. That matters only for very cheap functions called millions of times.
Wrap the concern, not the logic
Decorators remove repetition by moving a shared concern into one place. They work well when that concern is independent of what the function computes. Once a decorator starts deciding results, you have hidden a function call behind an @ sign.
Rule of thumb: if deleting the decorator would change the answer, it should not be a decorator.
