Skip to main content
โšก Calmops

Advanced Decorator Patterns in Python: Stacking and Parameterization

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