Python Decorators Explained: A Complete Guide with Real Examples
Introduction
You have probably seen code like this:
@login_required
def dashboard(request):
return render(request, "dashboard.html")
That @login_required line is a decorator. In one line, it adds authentication checking to any function — without touching the function’s own code.
Decorators are one of the most powerful and widely used Python features. They appear everywhere: Flask and Django route handlers, caching, logging, rate limiting, retry logic, test utilities, and more.
But most explanations jump straight to decorator syntax without explaining the underlying mechanics. This guide builds from first principles — closures, higher-order functions, and functools.wraps — so the @ syntax actually makes sense.
By the end, you will understand:
- Why decorators exist and what problem they solve
- How closures make decorators possible
- How to write decorators from scratch
- How to pass arguments to decorators
- How to stack multiple decorators
- Real-world decorator patterns used in production code
All examples are tested on Python 3.12.
The Problem Decorators Solve
Suppose you have several functions and you want to log how long each one takes to run:
import time
def fetch_data():
start = time.time()
# actual work
time.sleep(0.1)
end = time.time()
print(f"fetch_data took {end - start:.3f}s")
def process_data():
start = time.time()
# actual work
time.sleep(0.2)
end = time.time()
print(f"process_data took {end - start:.3f}s")
This approach works, but it has serious problems. The timing logic is duplicated in every function. If you want to change the format, you have to update every function. And the timing code is mixed in with the actual business logic, making both harder to read.
Decorators solve this by letting you wrap a function’s behavior without modifying the function itself.
Prerequisites: Functions Are Objects
Before writing decorators, you need to understand one key fact: in Python, functions are objects.
This means you can assign functions to variables, store them in lists, and pass them as arguments to other functions:
def greet(name):
return f"Hello, {name}"
# Assign to a variable
say_hello = greet
print(say_hello("Alice"))
# Pass as an argument
def call_twice(func, value):
func(value)
func(value)
call_twice(print, "hello")
Expected output:
Hello, Alice
hello
hello
Functions that accept other functions as arguments, or return functions as results, are called higher-order functions. Decorators are built on this concept.
Prerequisites: Closures
A closure is a function that remembers variables from the scope where it was defined, even after that scope has finished executing:
def make_multiplier(factor):
def multiply(x):
return x * factor # factor is captured from the outer scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5))
print(triple(5))
Expected output:
10
15
make_multiplier returns a new function each time it is called. Each returned function remembers its own factor value. This is a closure — multiply closes over the variable factor from the enclosing scope.
Understanding closures is the key to understanding how decorators work internally.
Building a Decorator from Scratch
A decorator is a function that takes a function as input and returns a new function that wraps the original.
Here is the timing example rewritten as a decorator:
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.3f}s")
return result
return wrapper
Now apply it manually:
def fetch_data():
time.sleep(0.1)
return "data"
fetch_data = timer(fetch_data)
result = fetch_data()
Expected output:
fetch_data took 0.100s
This is exactly what the @ syntax does — it is shorthand for fetch_data = timer(fetch_data).
The @ Syntax
The decorator syntax makes the wrapping explicit and readable:
@timer
def fetch_data():
time.sleep(0.1)
return "data"
@timer
def process_data():
time.sleep(0.2)
return "processed"
fetch_data()
process_data()
Expected output:
fetch_data took 0.100s
process_data took 0.201s
One decorator, applied to as many functions as needed, with no code duplication. The @timer line is equivalent to writing fetch_data = timer(fetch_data) after the function definition.
Why *args and **kwargs Matter
The wrapper function inside a decorator uses *args and **kwargs to accept any combination of positional and keyword arguments. This is essential — without it, the decorator would only work with functions that have a specific signature.
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs) # pass everything through
end = time.time()
print(f"{func.__name__} took {end - start:.3f}s")
return result
return wrapper
@timer
def add(x, y):
return x + y
@timer
def greet(name, greeting="Hello"):
return f"{greeting}, {name}"
print(add(3, 4))
print(greet("Alice", greeting="Hi"))
Expected output:
add took 0.000s
7
greet took 0.000s
Hi, Alice
The decorator works correctly regardless of how many arguments the wrapped function takes.
Preserving Function Identity with functools.wraps
Without extra care, a decorator replaces the original function’s metadata with the wrapper’s metadata:
@timer
def fetch_data():
"""Fetches data from the server."""
time.sleep(0.1)
print(fetch_data.__name__)
print(fetch_data.__doc__)
Expected output:
wrapper
None
The function’s name and docstring are gone — replaced by the wrapper’s. This breaks documentation tools, debuggers, and anything that inspects function metadata.
The fix is functools.wraps:
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.3f}s")
return result
return wrapper
@timer
def fetch_data():
"""Fetches data from the server."""
time.sleep(0.1)
print(fetch_data.__name__)
print(fetch_data.__doc__)
Expected output:
fetch_data
Fetches data from the server.
Always use @functools.wraps(func) inside decorators. It is a one-line addition that prevents subtle bugs in production code.
Decorators with Arguments
Sometimes you want to configure a decorator — for example, specifying a retry count or a cache timeout. This requires one more layer of nesting:
import functools
import time
def retry(max_attempts=3, delay=1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Attempt {attempt} failed: {e}")
if attempt < max_attempts:
time.sleep(delay)
raise RuntimeError(f"{func.__name__} failed after {max_attempts} attempts")
return wrapper
return decorator
Usage:
@retry(max_attempts=3, delay=0.5)
def unstable_api_call():
import random
if random.random() < 0.7:
raise ConnectionError("Network error")
return "success"
result = unstable_api_call()
Example output:
Attempt 1 failed: Network error
Attempt 2 failed: Network error
success
The three-layer structure works like this:
retry(max_attempts=3, delay=0.5)is called first and returnsdecoratordecorator(unstable_api_call)is called next and returnswrapperwrapperis what actually runs when you callunstable_api_call()
Stacking Multiple Decorators
You can apply multiple decorators to a single function. They are applied from bottom to top:
import functools
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
@bold
@italic
def greet(name):
return f"Hello, {name}"
print(greet("Alice"))
Expected output:
<b><i>Hello, Alice</i></b>
@bold is on the outside, @italic is on the inside. The function is first wrapped by italic, then that result is wrapped by bold. Reading from bottom to top matches the order of wrapping.
Class-Based Decorators
Decorators do not have to be functions. Any callable object works — including classes that implement __call__:
import functools
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
print(f"{self.func.__name__} has been called {self.count} time(s)")
return self.func(*args, **kwargs)
@CountCalls
def say_hello():
print("Hello!")
say_hello()
say_hello()
say_hello()
Expected output:
say_hello has been called 1 time(s)
Hello!
say_hello has been called 2 time(s)
Hello!
say_hello has been called 3 time(s)
Hello!
Class-based decorators are useful when the decorator needs to maintain state between calls — like a call counter, a cache, or a rate limiter.
Real-World Decorator Patterns
Caching with functools.lru_cache
Python’s standard library includes a caching decorator:
import functools
@functools.lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50))
print(fibonacci.cache_info())
Expected output:
12586269025
CacheInfo(hits=48, misses=51, maxsize=128, currsize=51)
Without lru_cache, computing fibonacci(50) would require billions of recursive calls. With it, each unique input is computed once and cached. The cache_info() method shows how many times the cache was hit versus missed.
Timing and Profiling
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__}: {elapsed:.4f}s")
return result
return wrapper
@timer
def sort_large_list():
data = list(range(1_000_000, 0, -1))
return sorted(data)
sort_large_list()
Example output:
sort_large_list: 0.0842s
time.perf_counter() is more precise than time.time() for measuring short durations.
Logging
import functools
import logging
logging.basicConfig(level=logging.INFO)
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
logging.info(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
logging.info(f"{func.__name__} returned {result}")
return result
return wrapper
@log_calls
def add(x, y):
return x + y
add(3, 4)
Example output:
INFO:root:Calling add with args=(3, 4), kwargs={}
INFO:root:add returned 7
Input Validation
import functools
def validate_positive(*param_names):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
import inspect
sig = inspect.signature(func)
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for name in param_names:
if name in bound.arguments and bound.arguments[name] <= 0:
raise ValueError(f"Parameter '{name}' must be positive, got {bound.arguments[name]}")
return func(*args, **kwargs)
return wrapper
return decorator
@validate_positive("width", "height")
def create_rectangle(width, height):
return width * height
print(create_rectangle(5, 3))
try:
create_rectangle(-1, 3)
except ValueError as e:
print(e)
Expected output:
15
Parameter 'width' must be positive, got -1
Rate Limiting
import functools
import time
def rate_limit(calls_per_second):
min_interval = 1.0 / calls_per_second
last_called = [0.0]
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
elapsed = time.time() - last_called[0]
wait = min_interval - elapsed
if wait > 0:
time.sleep(wait)
last_called[0] = time.time()
return func(*args, **kwargs)
return wrapper
return decorator
@rate_limit(calls_per_second=2)
def api_call(endpoint):
print(f"Calling {endpoint} at {time.time():.2f}")
for endpoint in ["/users", "/posts", "/comments"]:
api_call(endpoint)
Example output:
Calling /users at 1715900000.12
Calling /posts at 1715900000.62
Calling /comments at 1715900001.13
Each call is spaced at least 0.5 seconds apart, enforcing the two-calls-per-second limit.
The retry and rate limiting patterns shown here connect naturally to proper error handling. The exception handling guide covers how to structure try/except blocks inside decorated functions, including retry logic with exponential backoff.
Common Mistakes
Forgetting to Return the Result
# Wrong — the wrapped function always returns None
def broken_timer(func):
def wrapper(*args, **kwargs):
start = time.time()
func(*args, **kwargs) # result is discarded!
print(f"took {time.time() - start:.3f}s")
return wrapper
# Correct
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs) # capture the result
print(f"took {time.time() - start:.3f}s")
return result # return it
return wrapper
Calling the Decorator Instead of Applying It
# Wrong — timer() is called immediately, returns None
@timer()
def fetch_data():
pass
# Correct — timer is applied as a decorator
@timer
def fetch_data():
pass
This error is easy to make when switching between parameterized and non-parameterized decorators.
Forgetting functools.wraps
# Wrong — loses __name__, __doc__, and other metadata
def timer(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# Correct
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Quick Reference
| Pattern | When to Use |
|---|---|
| Basic decorator | Add behavior to any function without modifying it |
@functools.wraps | Always — preserves function metadata |
*args, **kwargs | Always — makes the decorator work with any function signature |
| Decorator with arguments | When the decorator needs configuration |
| Stacked decorators | When applying multiple independent behaviors |
| Class-based decorator | When the decorator needs to maintain state |
@functools.lru_cache | Caching pure functions with repeated inputs |
Wrap-Up
Decorators follow a consistent pattern once you see the underlying mechanics:
- A decorator is a function that takes a function and returns a function
- The inner
wrapperfunction adds behavior before or after calling the original *argsand**kwargsmake the wrapper work with any function signature@functools.wraps(func)preserves the original function’s metadata- Parameterized decorators add one more layer of nesting
The @ syntax is pure syntactic sugar — @timer above a function is exactly equivalent to func = timer(func) below it. Once that clicks, decorators stop feeling magical and start feeling mechanical.
For more on how Python executes function calls and manages scope — which connects directly to how closures work — see the deep dive on what happens when you run python script.py. For questions or future tutorial ideas, get in touch via the Contact page.