Python Decorators Explained Step by Step
Decorators look like magic, but they're just functions that wrap functions. Build your own timing and retry decorators from scratch.
On this page
- Idea 1: functions are objects
- Idea 2: functions can be defined inside functions
- Idea 3: inner functions remember their surroundings
- Putting it together: your first decorator
- Don't forget functools.wraps
- A practical decorator: timing
- Decorators that take arguments
- Built-in decorators you should know
- Recap
You've probably seen code like this in Flask, FastAPI or Django:
@app.route("/")
def home():
return "Hello!"That @ line is a decorator. Decorators let you add behaviour to a function (logging, timing, caching, access control) without changing the function's own code. They can look like magic, but they're built from three simple ideas. Let's take them one at a time.
Idea 1: functions are objects#
In Python, functions are values like any other. You can assign them to variables and pass them to other functions:
def greet(name):
return f"Hello, {name}!"
say_hello = greet # no parentheses: we're not calling it
print(say_hello("Ada")) # Hello, Ada!Idea 2: functions can be defined inside functions#
def outer():
def inner():
return "I'm inside!"
return inner # return the function itself
fn = outer()
print(fn()) # I'm inside!Idea 3: inner functions remember their surroundings#
An inner function can use variables from the function that created it, even after that outer function has finished. This is called a closure:
def make_multiplier(factor):
def multiply(x):
return x * factor # "remembers" factor
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5), triple(5)) # 10 15Putting it together: your first decorator#
A decorator is a function that takes a function and returns a new function, usually one that wraps the original with extra behaviour:
def shout(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
def greet(name):
return f"Hello, {name}!"
greet = shout(greet) # wrap it manually
print(greet("Ada")) # HELLO, ADA!The @ syntax is simply a shortcut for that greet = shout(greet) line:
@shout
def greet(name):
return f"Hello, {name}!"
print(greet("Ada")) # HELLO, ADA!*args and **kwargs let the wrapper accept any arguments and pass them straight through, so the decorator works on functions with any signature.
Don't forget functools.wraps#
Our decorator has a subtle problem: the wrapped function has lost its identity.
print(greet.__name__) # wrapperThat breaks debugging, documentation tools and some frameworks. Fix it with functools.wraps, which copies the original function's name, docstring and other metadata onto the wrapper:
from functools import wraps
def shout(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapperMake it a habit
Add @wraps(func) to every decorator you write. It costs one line and prevents confusing bugs later.
A practical decorator: timing#
Let's build something useful: a decorator that reports how long a function takes.
import time
from functools import wraps
def timed(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.3f}s")
return wrapper
@timed
def build_report(rows):
return sum(i * i for i in range(rows))
build_report(5_000_000) # build_report took 0.312sThe try/finally makes sure the time is printed even if the function raises an exception.
Decorators that take arguments#
What if you want to write @retry(times=3)? Then you need one more layer: a function that takes the arguments and returns a decorator.
import time
from functools import wraps
def retry(times=3, delay=1.0, exceptions=(Exception,)):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
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}. Retrying in {delay}s…")
time.sleep(delay)
return wrapper
return decorator
@retry(times=5, delay=0.5, exceptions=(ConnectionError,))
def fetch_prices():
...@retry(times=5) first calls retry(...), which returns decorator, which is then applied to fetch_prices. Three layers sounds complicated, but each one has a single job:
| Layer | Receives | Returns |
|---|---|---|
retry |
The options (times, delay…) |
decorator |
decorator |
The function to wrap | wrapper |
wrapper |
The arguments of each call | The function's result |
Built-in decorators you should know#
Python ships with several excellent decorators. For example, you don't need to write your own caching, because functools.cache (Python 3.9+) does it for you:
from functools import cache
@cache
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(100)) # 354224848179261915075, instantlyWithout @cache, this naive recursion would make more than 10²⁰ function calls. With it, each value is computed exactly once. Use functools.lru_cache(maxsize=…) if you need to cap memory use.
Other built-in decorators worth knowing:
@propertyexposes a method as a read-only attribute.@staticmethodand@classmethoddefine methods that don't need an instance.@dataclassgenerates__init__,__repr__and__eq__for you.@contextlib.contextmanagerturns a generator into awith-statement context manager.
Recap#
- Functions are objects: they can be passed around, returned and defined inside other functions.
- A decorator takes a function and returns a wrapped version;
@decois shorthand forfunc = deco(func). - Use
*args, **kwargsto support any signature, and always use@functools.wraps. - Decorators with arguments need one extra layer: a function that returns the decorator.
- Reach for built-ins like
@cache,@propertyand@dataclassbefore writing your own.
Challenge: write a @log_calls decorator that prints the function's name and arguments every time it's called. Then stack it with @timed and observe the order in which they run.