Python Decorators Explained: How They Work With Real Examples
Python decorators add behaviour to a function without touching its code. Learn what the @ symbol really does, write your own step by step and use them for retries, caching and timing.
- A decorator is a function that takes a function and returns a new one. The
@nameline is only a shortcut for that. - Always use
functools.wraps, otherwise your function loses its name and docstring. - A decorator with arguments needs three levels of functions.
- In our test,
lru_cachecut a recursivefib(35)from about 1,360 ms to under 1 ms.
You have seen them in almost every Python framework: @app.route, @pytest.fixture, @property. Python decorators look like magic at first, but they rest on one small idea that you can learn in a few minutes.
This guide builds that idea from the ground up. Every example was run on Python 3.13, and the outputs shown are real. If you are still learning functions and loops, read our Python tutorial for beginners first and come back.
The idea behind Python decorators
Python treats functions like any other value. You can store a function in a variable, pass it to another function and return it from a function. That property is usually described as functions being first-class objects, and decorators depend on it completely.
Suppose you have ten functions and want each one to log when it runs. You could paste a logging line into all ten, but that repeats code and mixes logging with business logic. A decorator lets you write the logging once, in one place, and apply it wherever you need it. The original functions stay clean and focused on their own job.
So the definition is short: a decorator is a function that receives a function, wraps it with extra behaviour and returns the wrapped version.
Your first decorator
Here is a decorator that turns the return value of a function into upper case.
def shout(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
@shout
def greet(name):
"""Return a greeting."""
return f"hello {name}"
print(greet("asha")) # HELLO ASHA
Read it from the inside out. wrapper is the new function. It calls the original with whatever arguments it received, then changes the result. shout returns wrapper without calling it.
The @shout line is pure syntax. It means exactly the same as writing this after the function definition:
greet = shout(greet)
Once you see that line, decorators stop being mysterious. The name greet now points to wrapper, and wrapper remembers the original function through a closure. The *args, **kwargs pair is what makes the decorator work with any function signature.
Why functools.wraps matters
The simple version has a hidden flaw. Because greet now points to wrapper, it has lost its identity. Here is what we saw when we printed its details.
print(greet.__name__) # wrapper
print(greet.__doc__) # None
That breaks debuggers, documentation tools and anything else that inspects functions. The fix is one line from the standard library.
import functools
def shout(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
@shout
def greet(name):
"""Return a greeting."""
return f"hello {name}"
print(greet.__name__) # greet
print(greet.__doc__) # Return a greeting.
Tip: treat @functools.wraps(func) as a required part of every decorator you write. It costs one line and prevents confusing stack traces later.
Decorators with arguments
Sometimes the decorator itself needs settings, such as how many times to retry. That requires one more layer: an outer function that takes the settings and returns the real decorator.
def retry(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except ValueError as e:
print(f"attempt {attempt} failed: {e}")
raise RuntimeError(f"gave up after {times} attempts")
return wrapper
return decorator
calls = {"n": 0}
@retry(3)
def flaky():
calls["n"] += 1
if calls["n"] < 3:
raise ValueError("server busy")
return "ok"
print(flaky())
This is the output from our run:
attempt 1 failed: server busy
attempt 2 failed: server busy
ok
Three levels look heavy, but each has one job. retry(3) stores the setting and returns decorator. decorator receives the function. wrapper runs on every call. This pattern is very useful in test automation, where network calls can fail briefly. Just be careful not to hide real failures, a problem we describe in test automation mistakes beginners make.
Stacking decorators
You can apply several decorators to one function. They are applied from the bottom up, and the outermost one runs first.
def bold(f):
return lambda: "<b>" + f() + "</b>"
def italic(f):
return lambda: "<i>" + f() + "</i>"
@bold
@italic
def word():
return "hi"
print(word()) # <b><i>hi</i></b>
The code means word = bold(italic(word)). Order matters in real projects too. If you stack an authentication check and a logging decorator, the one on top decides whether rejected calls still get logged.
Caching with lru_cache
The standard library ships some excellent decorators, and functools.lru_cache is one of the most useful. It stores results so that repeated calls with the same arguments return instantly.
@functools.lru_cache(maxsize=None)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(35)) # 9227465
print(fib.cache_info())
# CacheInfo(hits=33, misses=36, maxsize=None, currsize=36)
We timed the same recursive function with and without the cache on one machine. Your numbers will differ, but the gap will not close.
| Version of fib(35) | Time in our run |
|---|---|
| Plain recursion | About 1,360 ms |
With lru_cache |
About 0.39 ms |
Arguments must be hashable for this to work, so lists and dictionaries cannot be passed directly. For choosing the right data structure, see lists, tuples, sets and dictionaries. Use caching only for pure functions, because a function that depends on the clock or a database would return stale answers.
Class-based decorators
A decorator does not have to be a function. Any object that can be called will do, and a class is handy when you need to keep state between calls.
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CountCalls
def ping():
return "pong"
ping()
ping()
print(ping.count) # 2
__init__ receives the function once, and __call__ runs on every call. functools.update_wrapper does the same job here that wraps does for function decorators.
Built-in decorators you will meet
| Decorator | What it does |
|---|---|
@property |
Lets a method be read like an attribute |
@staticmethod |
Defines a method that needs neither the instance nor the class |
@classmethod |
Passes the class as the first argument, which suits alternative constructors |
@functools.lru_cache |
Stores results of earlier calls |
@dataclasses.dataclass |
Generates methods such as __init__ for a class |
The last row shows that a decorator can also wrap a class, not only a function. Third-party tools rely on the same mechanism. In Selenium and pytest projects, you will often use @pytest.fixture to create a browser for each test, as shown in our Selenium Python tutorial.
Common mistakes with Python decorators
- Calling the function too early. Writing
return wrapper()runs the function during decoration. Returnwrapperwithout brackets. - Forgetting the return value. If
wrappercallsfuncwithoutreturn, every decorated function returnsNone. - Fixed arguments on the wrapper. A wrapper with no
*args, **kwargsbreaks as soon as the function takes a parameter. - Skipping functools.wraps. Tools then see
wrappereverywhere. - Doing too much at import time. The decorator body runs when the module loads. Keep slow work inside
wrapper. - Over-using them. Three or four stacked decorators hide the flow. A plain function call is sometimes clearer.
A real-world example: a retry decorator
Network calls fail for temporary reasons all the time. Rather than writing a loop around every call, you can put the retry logic in one decorator and apply it wherever you need it. This example combines several ideas from this guide: arguments, functools.wraps and wrapping any function signature.
import functools, time
def retry(times=3, delay=0.1, 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 as error:
print(f"Attempt {attempt} failed: {error}")
if attempt == times:
raise
time.sleep(delay)
return wrapper
return decorator
calls = {"count": 0}
@retry(times=3, exceptions=(ConnectionError,))
def fetch_price():
calls["count"] += 1
if calls["count"] < 3:
raise ConnectionError("server busy")
return 499
print(fetch_price())
# Attempt 1 failed: server busy
# Attempt 2 failed: server busy
# 499
That output is from our own run. Two details matter here. The decorator catches only the exceptions you list, so a real bug such as a TypeError fails immediately rather than being retried. And on the final attempt it re-raises the error, so the caller still finds out. Only retry operations that are safe to repeat, because retrying a payment that actually went through could charge a customer twice.
Final thoughts
Python decorators are simply functions that wrap functions. Remember greet = shout(greet), add functools.wraps every time and you can read almost any decorator you meet. Start by writing a small timing or logging decorator for your own project. To keep that project’s packages tidy, use a Python virtual environment.
Frequently asked questions
What is a decorator in Python?
It is a function that takes another function, adds behaviour around it and returns the new version. The @name syntax applies it above a function definition.
What does functools.wraps do?
It copies the name, docstring and other details of the original function onto the wrapper, so debuggers and documentation tools still see the original identity.
How do I pass arguments to a decorator?
Add an outer function that takes the arguments and returns the decorator. That gives three levels: settings, decorator and wrapper.
In what order do stacked decorators run?
They are applied from the bottom up, so the decorator closest to the function wraps first. When called, the top decorator runs first.
When should I use a class-based decorator?
Use one when the decorator needs to keep state between calls, such as a counter. For simple behaviour, a function-based decorator is shorter.
Keep learning
Need help with testing, WordPress or SEO?
We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.