Advanced Decorator Patterns in Python: Stacking and Parameterization
Decorators are one of Python’s most powerful features, yet many developers use them without fully understanding how they work. Basic decorators are straightforward, but advanced patternsโstacking multiple decorators and creating parameterized decoratorsโunlock sophisticated programming techniques.
This guide explores these advanced patterns, showing you how to combine decorators effectively and create flexible, reusable decorator factories. By the end, you’ll be able to implement complex decorator patterns that solve real-world problems.
Understanding Decorator Basics
Before diving into advanced patterns, let’s review how decorators work.
What is a Decorator?
A decorator is a function that takes another function as input, modifies its behavior, and returns a new function:
def simple_decorator(func):
"""A simple decorator that prints when a function is called"""
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@simple_decorator
def greet(name):
return f"Hello, {name}!"
# This is equivalent to:
# greet = simple_decorator(greet)
result = greet("Alice")
# Output:
# Calling greet
# Hello, Alice!
The Decorator Syntax
The @decorator syntax is syntactic sugar:
# These two are equivalent:
# Using @ syntax
@my_decorator
def my_function():
pass
# Equivalent functional form
def my_function():
pass
my_function = my_decorator(my_function)
Understanding this equivalence is crucial for understanding decorator stacking.
Decorator Stacking: Order and Execution
When you apply multiple decorators to a function, they execute in a specific order. Understanding this order is essential.
How Stacking Works
def decorator_a(func):
def wrapper(*args, **kwargs):
print("A: Before")
result = func(*args, **kwargs)
print("A: After")
return result
return wrapper
def decorator_b(func):
def wrapper(*args, **kwargs):
print("B: Before")
result = func(*args, **kwargs)
print("B: After")
return result
return wrapper
@decorator_a
@decorator_b
def my_function():
print("Function executing")
my_function()
Output:
A: Before
B: Before
Function executing
B: After
A: After
Understanding the Execution Order
The key to understanding stacking is remembering that decorators are applied bottom-to-top during definition, but execute top-to-bottom during function calls.
# This stacked decorator:
@decorator_a
@decorator_b
def my_function():
pass
# Is equivalent to:
def my_function():
pass
my_function = decorator_a(decorator_b(my_function))
# So the execution order is:
# 1. decorator_a's wrapper is called first
# 2. Which calls decorator_b's wrapper
# 3. Which calls the original function
Visual Representation
Definition (bottom-to-top):
@decorator_a โ Applied second
@decorator_b โ Applied first
def my_function():
pass
Execution (top-to-bottom):
decorator_a wrapper
โ
decorator_b wrapper
โ
my_function
Practical Example: Logging and Timing
import time
from functools import wraps
def log_calls(func):
"""Decorator that logs function calls"""
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[LOG] Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"[LOG] {func.__name__} returned {result}")
return result
return wrapper
def time_execution(func):
"""Decorator that measures execution time"""
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"[TIME] {func.__name__} took {elapsed:.4f} seconds")
return result
return wrapper
@log_calls
@time_execution
def slow_function(n):
"""A function that takes time to execute"""
time.sleep(0.1)
return n * 2
result = slow_function(5)
Output:
[LOG] Calling slow_function with args=(5,), kwargs={}
[TIME] slow_function took 0.1001 seconds
[LOG] slow_function returned 10
Notice that time_execution runs first (measuring the actual function), then log_calls wraps around it (logging the call and result).
Parameterized Decorators
Parameterized decorators accept arguments that customize their behavior. This requires an additional layer of nesting.
The Three-Layer Structure
A parameterized decorator has three layers:
def parameterized_decorator(param1, param2):
"""Layer 1: Decorator factory - accepts parameters"""
def decorator(func):
"""Layer 2: Actual decorator - accepts the function"""
@wraps(func)
def wrapper(*args, **kwargs):
"""Layer 3: Wrapper - executes around the function"""
print(f"Using param1={param1}, param2={param2}")
return func(*args, **kwargs)
return wrapper
return decorator
# Usage
@parameterized_decorator("value1", "value2")
def my_function():
print("Function executing")
my_function()
Output:
Using param1=value1, param2=value2
Function executing
Understanding the Layers
# This:
@parameterized_decorator("value1", "value2")
def my_function():
pass
# Is equivalent to:
def my_function():
pass
my_function = parameterized_decorator("value1", "value2")(my_function)
# Breaking it down:
# 1. parameterized_decorator("value1", "value2") returns a decorator
# 2. That decorator is applied to my_function
Practical Example 1: Retry Decorator
import time
from functools import wraps
def retry(max_attempts=3, delay=1):
"""Decorator that retries a function if it fails"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts:
print(f"Failed after {max_attempts} attempts")
raise
print(f"Attempt {attempt} failed: {e}. Retrying in {delay}s...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def unreliable_function():
import random
if random.random() < 0.7:
raise ConnectionError("Network error")
return "Success!"
# This will retry up to 3 times
result = unreliable_function()
Practical Example 2: Rate Limiting Decorator
import time
from functools import wraps
from collections import defaultdict
def rate_limit(calls_per_second=1):
"""Decorator that limits how often a function can be called"""
min_interval = 1.0 / calls_per_second
last_called = [0.0] # Use list to allow modification in nested function
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
elapsed = time.time() - last_called[0]
if elapsed < min_interval:
time.sleep(min_interval - elapsed)
last_called[0] = time.time()
return func(*args, **kwargs)
return wrapper
return decorator
@rate_limit(calls_per_second=2)
def api_call():
print(f"API called at {time.time():.2f}")
# These calls will be rate-limited to 2 per second
for _ in range(5):
api_call()
Practical Example 3: Validation Decorator
from functools import wraps
def validate_types(**type_checks):
"""Decorator that validates argument types"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Combine args and kwargs with parameter names
import inspect
sig = inspect.signature(func)
bound_args = sig.bind(*args, **kwargs)
bound_args.apply_defaults()
# Check types
for param_name, expected_type in type_checks.items():
if param_name in bound_args.arguments:
value = bound_args.arguments[param_name]
if not isinstance(value, expected_type):
raise TypeError(
f"Parameter '{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):
return f"User: {name}, Age: {age}"
# This works
print(create_user("Alice", 30)) # User: Alice, Age: 30
# This raises TypeError
try:
create_user("Bob", "thirty")
except TypeError as e:
print(f"Error: {e}")
Combining Stacking and Parameterization
The real power comes from combining both patterns.
Example 1: Authentication and Logging
from functools import wraps
def require_auth(role="user"):
"""Parameterized decorator that checks authentication"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Simulate authentication check
current_user = {"role": "admin"} # In real code, get from context
if current_user["role"] != role:
raise PermissionError(f"Requires {role} role")
return func(*args, **kwargs)
return wrapper
return decorator
def log_access(level="INFO"):
"""Parameterized decorator that logs access"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{level}] Accessing {func.__name__}")
result = func(*args, **kwargs)
print(f"[{level}] {func.__name__} completed")
return result
return wrapper
return decorator
# Stack both decorators with parameters
@log_access(level="DEBUG")
@require_auth(role="admin")
def delete_user(user_id):
return f"User {user_id} deleted"
# Execution order:
# 1. log_access wrapper executes
# 2. require_auth wrapper executes
# 3. delete_user executes
result = delete_user(123)
Output:
[DEBUG] Accessing delete_user
User 123 deleted
[DEBUG] delete_user completed
Example 2: Caching with TTL
import time
from functools import wraps
def cache_with_ttl(seconds=60):
"""Decorator that caches results for a specified time"""
def decorator(func):
cache = {}
cache_time = {}
@wraps(func)
def wrapper(*args, **kwargs):
# Create a cache key from arguments
key = (args, tuple(sorted(kwargs.items())))
# Check if cached and not expired
if key in cache:
elapsed = time.time() - cache_time[key]
if elapsed < seconds:
print(f"[CACHE HIT] {func.__name__}")
return cache[key]
else:
print(f"[CACHE EXPIRED] {func.__name__}")
del cache[key]
del cache_time[key]
# Compute and cache result
print(f"[CACHE MISS] {func.__name__}")
result = func(*args, **kwargs)
cache[key] = result
cache_time[key] = time.time()
return result
return wrapper
return decorator
@cache_with_ttl(seconds=2)
def expensive_computation(n):
print(f"Computing for n={n}...")
time.sleep(0.5)
return n ** 2
# First call - cache miss
print(expensive_computation(5)) # Computing for n=5...
# Second call - cache hit
print(expensive_computation(5)) # [CACHE HIT]
# After 2 seconds - cache expired
time.sleep(2.1)
print(expensive_computation(5)) # [CACHE EXPIRED]
Example 3: Complex Pipeline with Multiple Decorators
from functools import wraps
import time
def timer(func):
"""Measures execution time"""
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"โฑ๏ธ {func.__name__} took {elapsed:.4f}s")
return result
return wrapper
def validate_input(min_value=0, max_value=100):
"""Validates input is within range"""
def decorator(func):
@wraps(func)
def wrapper(value, *args, **kwargs):
if not (min_value <= value <= max_value):
raise ValueError(
f"Value must be between {min_value} and {max_value}"
)
return func(value, *args, **kwargs)
return wrapper
return decorator
def log_result(func):
"""Logs the result"""
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f"๐ {func.__name__} returned: {result}")
return result
return wrapper
# Stack all three decorators
@timer
@log_result
@validate_input(min_value=0, max_value=100)
def process_score(score):
"""Process a test score"""
time.sleep(0.1)
return score * 1.1
# Execute
result = process_score(85)
Output:
๐ process_score returned: 93.5
โฑ๏ธ process_score took 0.1005s
Common Pitfalls and Solutions
Pitfall 1: Forgetting @wraps
# Bad: Loses function metadata
def bad_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@bad_decorator
def my_function():
"""This is my function"""
pass
print(my_function.__name__) # wrapper (wrong!)
print(my_function.__doc__) # None (wrong!)
# Good: Preserves function metadata
from functools import wraps
def good_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@good_decorator
def my_function():
"""This is my function"""
pass
print(my_function.__name__) # my_function (correct!)
print(my_function.__doc__) # This is my function (correct!)
Pitfall 2: Mutable Default Arguments
# Bad: Shared state across calls
def bad_cache(func):
cache = {} # Shared across all decorated functions!
def wrapper(*args, **kwargs):
key = args
if key not in cache:
cache[key] = func(*args, **kwargs)
return cache[key]
return wrapper
# Good: Each function gets its own cache
def good_cache(func):
def decorator(f):
cache = {} # Fresh cache for each function
@wraps(f)
def wrapper(*args, **kwargs):
key = args
if key not in cache:
cache[key] = f(*args, **kwargs)
return cache[key]
return wrapper
return decorator(func)
Pitfall 3: Incorrect Decorator Factory Syntax
# Bad: Forgetting to return the decorator
def bad_parameterized(param):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
# Missing: return decorator
# Good: Return the decorator
def good_parameterized(param):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
return decorator # Return the decorator
Pitfall 4: Debugging Stacked Decorators
When debugging stacked decorators, add print statements to understand execution order:
def debug_decorator(name):
"""Decorator that helps debug execution order"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"โ Entering {name}")
result = func(*args, **kwargs)
print(f"โ Exiting {name}")
return result
return wrapper
return decorator
@debug_decorator("A")
@debug_decorator("B")
@debug_decorator("C")
def my_function():
print(" Function executing")
my_function()
Output:
โ Entering A
โ Entering B
โ Entering C
Function executing
โ Exiting C
โ Exiting B
โ Exiting A
Performance Considerations
Decorator Overhead
Decorators add a small performance overhead:
import timeit
def simple_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@simple_decorator
def decorated_function():
return 42
def undecorated_function():
return 42
# Measure overhead
decorated_time = timeit.timeit(decorated_function, number=1000000)
undecorated_time = timeit.timeit(undecorated_function, number=1000000)
print(f"Decorated: {decorated_time:.4f}s")
print(f"Undecorated: {undecorated_time:.4f}s")
print(f"Overhead: {(decorated_time - undecorated_time) / undecorated_time * 100:.1f}%")
The overhead is typically small (1-5%) but can matter in performance-critical code.
Caching Decorator Results
For expensive decorators, cache the result:
from functools import lru_cache, wraps
def expensive_decorator(func):
@wraps(func)
@lru_cache(maxsize=128)
def wrapper(*args, **kwargs):
# Expensive operation
return func(*args, **kwargs)
return wrapper
Best Practices
1. Always Use @wraps
from functools import wraps
def my_decorator(func):
@wraps(func) # Always include this
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
2. Document Decorator Parameters
def my_decorator(param1, param2=None):
"""
A decorator that does something.
Args:
param1: Description of param1
param2: Description of param2 (default: None)
Example:
@my_decorator("value", param2="other")
def my_function():
pass
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
return decorator
3. Keep Decorators Simple
# Good: Single responsibility
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
# Bad: Too many responsibilities
def complex_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Logging
# Caching
# Validation
# Rate limiting
# Authentication
# ... too much!
return func(*args, **kwargs)
return wrapper
4. Test Decorators Thoroughly
import unittest
from functools import wraps
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
class TestMyDecorator(unittest.TestCase):
def test_preserves_function_name(self):
@my_decorator
def my_function():
pass
self.assertEqual(my_function.__name__, "my_function")
def test_preserves_return_value(self):
@my_decorator
def add(a, b):
return a + b
self.assertEqual(add(2, 3), 5)
def test_preserves_arguments(self):
@my_decorator
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
self.assertEqual(greet("Alice"), "Hello, Alice!")
self.assertEqual(greet("Bob", greeting="Hi"), "Hi, Bob!")
Conclusion
Advanced decorator patternsโstacking and parameterizationโare powerful tools for writing clean, maintainable Python code. By understanding how decorators compose and how to create flexible decorator factories, you can solve complex problems elegantly.
Key takeaways:
- Decorator stacking applies decorators bottom-to-top during definition, but executes top-to-bottom during calls
- Parameterized decorators use a three-layer structure: factory โ decorator โ wrapper
- Always use @wraps to preserve function metadata
- Combine stacking and parameterization for sophisticated patterns
- Keep decorators simple and focused on a single responsibility
- Test thoroughly to ensure decorators work correctly
- Document parameters clearly for other developers
With these patterns in your toolkit, you can implement authentication, logging, caching, rate limiting, validation, and countless other cross-cutting concerns elegantly and reusably.
Comments