Table of Contents#
- Understanding Decorators: A Quick Recap
- What Are Nested Decorators?
- How Nested Decorators Work
- Syntax of Nested Decorators
- Practical Examples of Nested Decorators
- Common Practices
- Best Practices
- Conclusion
- References
1. Understanding Decorators: A Quick Recap#
Before diving into nested decorators, let’s recap what decorators are. A decorator is a function that takes another function as input and returns a new function with modified behavior. Decorators use the @ syntax (syntactic sugar) to apply this modification.
Basic Decorator Example: Logging#
def logger(func):
def wrapper(*args, **kwargs):
print(f"Calling function: {func.__name__}")
result = func(*args, **kwargs)
print(f"Function {func.__name__} returned: {result}")
return result
return wrapper
@logger
def add(a, b):
return a + b
add(2, 3)Output:
Calling function: add
Function add returned: 5
Here, logger is a decorator that wraps add, adding logging before and after execution. The @logger syntax is equivalent to add = logger(add).
2. What Are Nested Decorators?#
Nested decorators refer to the practice of applying multiple decorators to a single function or class. This allows you to combine the functionality of multiple decorators, such as logging and timing, or authentication and authorization.
For example, you might decorate a function with both a logger (to log activity) and a timer (to measure execution time). Nested decorators enable this by stacking decorators on top of each other.
3. How Nested Decorators Work#
The key to understanding nested decorators is recognizing the order of application and execution.
Order of Application#
When decorators are stacked with the @ syntax, they are applied from the bottom up. For example:
@decorator_a
@decorator_b
def my_func():
passThis is equivalent to:
my_func = decorator_a(decorator_b(my_func))Here, decorator_b is applied first (wraps my_func), and decorator_a is applied next (wraps the result of decorator_b(my_func)).
Order of Execution#
When the decorated function is called, the outermost decorator runs first, followed by the inner decorators, and finally the original function.
Using the example above:
- When
my_func()is called,decorator_a’s wrapper executes first. decorator_a’s wrapper callsdecorator_b’s wrapper.decorator_b’s wrapper calls the originalmy_func.
4. Syntax of Nested Decorators#
The syntax for nested decorators is straightforward: stack decorators using the @ symbol above the function definition. The bottom decorator is applied first, and the top decorator is applied last.
General Syntax:#
@decorator_1 # Applied last (outermost)
@decorator_2 # Applied first (innermost)
def my_function():
passEquivalent Functional Assignment:#
my_function = decorator_1(decorator_2(my_function))Note: The order of decorators matters! Swapping decorator_1 and decorator_2 will change the behavior of the decorated function.
5. Practical Examples of Nested Decorators#
Let’s explore real-world examples of nested decorators to see how they combine functionality.
Example 1: Logging + Timing#
Suppose you want to log a function’s execution and measure its runtime. We’ll use two decorators: logger (logs calls/returns) and timer (measures execution time).
Step 1: Define the Decorators#
import time
from functools import wraps # Preserves function metadata
def logger(func):
@wraps(func) # Critical: Preserves func's name, docstring, etc.
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 timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"[TIMER] {func.__name__} took {end - start:.4f} seconds")
return result
return wrapperStep 2: Apply Nested Decorators#
@logger # Outer decorator (applied last)
@timer # Inner decorator (applied first)
def slow_add(a, b):
time.sleep(1) # Simulate work
return a + b
slow_add(3, 5)Output:#
[LOG] Calling slow_add with args: (3, 5), kwargs: {}
[TIMER] slow_add took 1.0012 seconds
[LOG] slow_add returned: 8
Explanation:
timerwrapsslow_addfirst, adding timing logic.loggerthen wraps the result oftimer(slow_add), adding logging logic.- When
slow_add(3,5)is called:logger’s wrapper runs first, logging the call.- It calls
timer’s wrapper, which measures execution time. timer’s wrapper calls the originalslow_add, which returns8.timerlogs the runtime, then returns8tologger.loggerlogs the return value and returns8.
Example 2: Authentication + Authorization#
Web applications often require users to be logged in (authentication) and have specific permissions (authorization). Nested decorators can enforce both checks.
Step 1: Define Decorators#
from functools import wraps
def login_required(func):
@wraps(func)
def wrapper(user, *args, **kwargs):
if not user.get("logged_in", False):
raise PermissionError("User not logged in!")
return func(user, *args, **kwargs)
return wrapper
def admin_required(func):
@wraps(func)
def wrapper(user, *args, **kwargs):
if user.get("role") != "admin":
raise PermissionError("Admin access required!")
return func(user, *args, **kwargs)
return wrapperStep 2: Apply Nested Decorators#
@login_required # Outer: Check login first
@admin_required # Inner: Check admin second
def delete_database(user):
print(f"Deleting database (user: {user['name']})")
# Test with an admin user
admin_user = {"name": "Alice", "logged_in": True, "role": "admin"}
delete_database(admin_user) # Works
# Test with a logged-in non-admin
non_admin_user = {"name": "Bob", "logged_in": True, "role": "user"}
delete_database(non_admin_user) # Raises PermissionErrorOutput:#
Deleting database (user: Alice)
PermissionError: Admin access required!
Explanation:
admin_requiredwrapsdelete_databasefirst, checking if the user is an admin.login_requiredwraps the result, checking if the user is logged in.- Order matters here:
login_required(outer) runs first, ensuring the user is logged in before checking admin status.
Example 3: Input Validation + Caching#
For functions with expensive computations, you might want to validate inputs and cache results. We’ll use validate_input (ensures inputs are positive) and cache (stores results for reuse).
Step 1: Define Decorators#
from functools import wraps
def validate_input(func):
@wraps(func)
def wrapper(x, y):
if x <= 0 or y <= 0:
raise ValueError("Inputs must be positive!")
return func(x, y)
return wrapper
def cache(func):
cache_dict = {}
@wraps(func)
def wrapper(x, y):
key = (x, y)
if key not in cache_dict:
cache_dict[key] = func(x, y)
print(f"[CACHE] Stored result for ({x}, {y})")
else:
print(f"[CACHE] Using cached result for ({x}, {y})")
return cache_dict[key]
return wrapperStep 2: Apply Nested Decorators#
@cache # Outer: Cache results
@validate_input # Inner: Validate inputs first
def multiply(x, y):
print(f"Multiplying {x} and {y}")
return x * y
# First call (no cache, valid input)
multiply(3, 4) # Validates, computes, caches
# Second call (uses cache)
multiply(3, 4) # Uses cached result
# Invalid input
multiply(-1, 5) # Raises ValueErrorOutput:#
Multiplying 3 and 4
[CACHE] Stored result for (3, 4)
[CACHE] Using cached result for (3, 4)
ValueError: Inputs must be positive!
Explanation:
validate_input(inner) runs first, ensuring inputs are positive before computation.cache(outer) checks if the result is cached; if not, it callsvalidate_input’s wrapper, which callsmultiply.
6. Common Practices#
Order of Decorators#
The order of nested decorators is critical. Follow these guidelines:
- Apply "pre-processing" decorators first (e.g., validation, authentication) as inner decorators.
- Apply "post-processing" decorators last (e.g., logging, caching) as outer decorators.
- Test decorator order to ensure the desired behavior (e.g., login before admin checks).
Using functools.wraps#
Always use @wraps(func) in decorator wrappers to preserve the original function’s metadata (name, docstring, __module__, etc.). Without wraps, debuggers and tools like help() will show the wrapper function’s metadata instead of the original.
Composing Decorators#
For reusability, create decorator factories (functions that return decorators) or compose decorators into a single decorator. For example:
def compose(*decorators):
def wrapper(func):
for decorator in reversed(decorators):
func = decorator(func)
return func
return wrapper
# Usage: Apply decorators in the order they are passed
@compose(logger, timer) # Equivalent to @logger @timer
def my_func():
pass7. Best Practices#
-
Keep Decorators Simple and Focused
Each decorator should handle one responsibility (e.g., logging or timing, not both). This makes nested decorators easier to debug and maintain. -
Document Decorators
Clearly document what each decorator does, its side effects, and any assumptions (e.g., "Requires auserargument with alogged_inkey"). -
Avoid Over-Nesting
Too many nested decorators (e.g., 5+) can make code hard to read. If you need many decorators, consider refactoring or using a decorator composition utility. -
Test Decorators in Isolation
Test each decorator independently before combining them. This ensures issues in one decorator don’t cascade to others. -
Use Classes for Complex Decorators
For decorators with state (e.g., a cache that persists across calls), use a class with a__call__method instead of nested functions.
8. Conclusion#
Nested decorators are a powerful tool in Python for combining multiple cross-cutting concerns into a single function or class. By understanding their order of application and execution, you can write modular, reusable code that handles logging, timing, authentication, and more.
Key takeaways:
- Nested decorators stack functionality by applying decorators from bottom to top.
- Execution order is outer to inner (top decorator runs first).
- Use
functools.wrapsto preserve function metadata. - Order matters—test and document decorator behavior.
With these concepts, you’ll be able to leverage nested decorators to write cleaner, more maintainable Python code.