Advanced Python Decorators
Go beyond basic decorators: decorator factories, class decorators, functools.wraps, stacking behavior, and real-world patterns used in production Python code.
What you'll learn
- ✓How decorator factories work and when to use them
- ✓Class-based decorators and the descriptor protocol
- ✓Why functools.wraps matters and what breaks without it
- ✓Stacking decorators and execution order
- ✓Production patterns: retry, caching, validation, auth
Prerequisites
- •Python functions as first-class objects
- •Basic decorator syntax (@decorator)
- •Familiarity with *args and **kwargs
A decorator is a function that takes a function and returns a modified function. That one-sentence definition gets you through tutorials. Production code demands more: decorators that accept arguments, decorators that work on classes, decorators that preserve metadata, and decorators that compose cleanly.
Quick review: the bare decorator
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(3, 4)
# Calling add
# add returned 7
The @log_calls syntax is equivalent to add = log_calls(add). The original add is replaced by wrapper.
functools.wraps: preserving identity
Without functools.wraps, the wrapper replaces the original function’s metadata.
@log_calls
def add(a, b):
"""Add two numbers."""
return a + b
print(add.__name__) # "wrapper" -- wrong
print(add.__doc__) # None -- lost
functools.wraps copies the original function’s __name__, __doc__, __module__, __qualname__, __dict__, and __wrapped__ to the wrapper.
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@log_calls
def add(a, b):
"""Add two numbers."""
return a + b
print(add.__name__) # "add"
print(add.__doc__) # "Add two numbers."
print(add.__wrapped__) # <function add at 0x...> -- the original
Always use functools.wraps. There is no good reason to skip it.
Decorator factories (decorators with arguments)
A bare decorator takes a function. A decorator factory takes arguments and returns a decorator. It is an extra level of nesting.
import functools
import time
def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
"""Retry a function on failure."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt < max_attempts:
print(f"{func.__name__} failed (attempt {attempt}/{max_attempts}), retrying in {delay}s...")
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(max_attempts=5, delay=2.0, exceptions=(ConnectionError, TimeoutError))
def fetch_data(url):
# might fail transiently
response = requests.get(url, timeout=10)
response.raise_for_status()
return response.json()
The call chain: retry(max_attempts=5, ...) returns decorator, then decorator(fetch_data) returns wrapper.
Making the parentheses optional
Sometimes you want a decorator that works with or without arguments: @retry and @retry(max_attempts=5).
import functools
def retry(_func=None, *, max_attempts=3, delay=1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt < max_attempts:
time.sleep(delay)
raise last_exception
return wrapper
if _func is not None:
# Called without arguments: @retry
return decorator(_func)
# Called with arguments: @retry(max_attempts=5)
return decorator
@retry
def func_a():
pass
@retry(max_attempts=5)
def func_b():
pass
The trick is the _func parameter. When used as @retry, Python passes the decorated function as _func. When used as @retry(...), _func is None and the factory returns the decorator.
Class-based decorators
Any callable can be a decorator. A class with __call__ works.
import functools
class CacheResult:
"""Cache function results with a max size."""
def __init__(self, max_size=128):
self.max_size = max_size
self.cache = {}
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args):
if args in self.cache:
return self.cache[args]
result = func(*args)
if len(self.cache) >= self.max_size:
# Remove oldest entry
oldest_key = next(iter(self.cache))
del self.cache[oldest_key]
self.cache[args] = result
return result
wrapper.cache_clear = lambda: self.cache.clear()
wrapper.cache_info = lambda: {
"size": len(self.cache),
"max_size": self.max_size,
}
return wrapper
@CacheResult(max_size=256)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(100))
print(fibonacci.cache_info())
Class-based decorators shine when the decorator needs to maintain state across calls.
Decorating classes
Decorators can also be applied to classes. They receive the class object and return a modified (or new) class.
def add_repr(cls):
"""Auto-generate __repr__ from __init__ parameters."""
import inspect
params = list(inspect.signature(cls.__init__).parameters.keys())
params = [p for p in params if p != "self"]
def __repr__(self):
args = ", ".join(f"{p}={getattr(self, p)!r}" for p in params)
return f"{cls.__name__}({args})"
cls.__repr__ = __repr__
return cls
@add_repr
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
print(Point(3, 4)) # Point(x=3, y=4)
A more practical example: a singleton pattern.
def singleton(cls):
"""Ensure only one instance of the class exists."""
instances = {}
@functools.wraps(cls)
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class DatabaseConnection:
def __init__(self, url):
self.url = url
print(f"Connecting to {url}")
db1 = DatabaseConnection("postgres://localhost/mydb")
db2 = DatabaseConnection("postgres://localhost/mydb")
print(db1 is db2) # True -- same instance
Stacking decorators
When you stack decorators, they apply bottom-up but execute top-down.
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("world"))
# <b><i>Hello, world</i></b>
The application order is: greet = bold(italic(greet)). When called, bold’s wrapper runs first (outermost), calls italic’s wrapper, which calls the original greet.
Real-world patterns
Timing decorator
import functools
import time
def timed(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__} took {elapsed:.4f}s")
return result
return wrapper
@timed
def process_batch(items):
return [transform(item) for item in items]
Validation decorator
import functools
def validate_types(**type_hints):
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 param_name, expected_type in type_hints.items():
if param_name in bound.arguments:
value = bound.arguments[param_name]
if not isinstance(value, expected_type):
raise TypeError(
f"{param_name} must be {expected_type.__name__}, "
f"got {type(value).__name__}"
)
return func(*args, **kwargs)
return wrapper
return decorator
@validate_types(name=str, age=int)
def create_user(name, age, email=None):
return {"name": name, "age": age, "email": email}
create_user("Alice", 30) # works
create_user("Alice", "thirty") # TypeError: age must be int, got str
Rate limiting decorator
import functools
import time
from collections import deque
def rate_limit(max_calls, period=60):
"""Allow at most max_calls within period seconds."""
def decorator(func):
calls = deque()
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.time()
# Remove calls outside the window
while calls and calls[0] < now - period:
calls.popleft()
if len(calls) >= max_calls:
wait_time = calls[0] + period - now
raise RuntimeError(
f"Rate limit exceeded. Try again in {wait_time:.1f}s"
)
calls.append(now)
return func(*args, **kwargs)
return wrapper
return decorator
@rate_limit(max_calls=10, period=60)
def call_api(endpoint):
return requests.get(endpoint)
Authorization decorator (Flask/FastAPI style)
import functools
def require_role(*roles):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Assume current_user is available in context
user = get_current_user()
if user.role not in roles:
raise PermissionError(
f"Requires one of {roles}, user has {user.role}"
)
return func(*args, **kwargs)
return wrapper
return decorator
@require_role("admin", "moderator")
def delete_post(post_id):
db.posts.delete(post_id)
Async decorators
For async functions, your wrapper must also be async.
import functools
import asyncio
def async_retry(max_attempts=3, delay=1.0):
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return await func(*args, **kwargs)
except Exception as e:
last_exception = e
if attempt < max_attempts:
await asyncio.sleep(delay)
raise last_exception
return wrapper
return decorator
@async_retry(max_attempts=3, delay=2.0)
async def fetch_user(user_id):
async with aiohttp.ClientSession() as session:
async with session.get(f"/api/users/{user_id}") as resp:
resp.raise_for_status()
return await resp.json()
Debugging decorated functions
The __wrapped__ attribute (set by functools.wraps) lets you access the original function. This is valuable for testing and debugging.
@retry(max_attempts=3)
@timed
def my_function():
pass
# Access the original, undecorated function
original = my_function.__wrapped__.__wrapped__
# In tests, you might want to skip the retry/timing behavior
result = my_function.__wrapped__(test_input)
Decorators are one of Python’s most powerful metaprogramming features. Start simple, use functools.wraps every time, and keep each decorator focused on a single concern. The patterns here cover the vast majority of what you will need in production.
Related articles
- Python Python Decorators Deep Dive
A practical tour of Python decorators: how they work under the hood, when to use them, and how to write decorators that preserve metadata, accept arguments, and stack cleanly.
- Python Generators and Iterators in Python
A practical guide to Python iterators and generators — the iterator protocol, yield, generator expressions, memory benefits, and how to model infinite sequences.
- Python Python Concurrency: asyncio vs threading vs multiprocessing
Compare Python's concurrency models side by side. Learn when to use asyncio, threading, or multiprocessing with practical benchmarks and real-world examples.
- Python Python Environments: venv, pyenv, and Poetry Guide
Master Python environment management with venv for virtual environments, pyenv for version switching, and Poetry for dependency management.