py4u blog

Nested Decorators in Python: A Comprehensive Guide

Decorators are a powerful feature in Python that allow you to modify the behavior of functions or classes without changing their source code. They are widely used for cross-cutting concerns like logging, authentication, caching, and timing. While single decorators are common, nested decorators take this a step further by enabling the combination of multiple decorators to add layered functionality to a single function or class.

This blog will demystify nested decorators, explaining how they work, their syntax, practical examples, and best practices. Whether you’re a beginner looking to understand decorators or an experienced developer aiming to write cleaner, more modular code, this guide will help you master nested decorators in Python.

2026-07

Table of Contents#

  1. Understanding Decorators: A Quick Recap
  2. What Are Nested Decorators?
  3. How Nested Decorators Work
  4. Syntax of Nested Decorators
  5. Practical Examples of Nested Decorators
  6. Common Practices
  7. Best Practices
  8. Conclusion
  9. 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():
    pass

This 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 calls decorator_b’s wrapper.
  • decorator_b’s wrapper calls the original my_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():
    pass

Equivalent 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 wrapper

Step 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:

  • timer wraps slow_add first, adding timing logic.
  • logger then wraps the result of timer(slow_add), adding logging logic.
  • When slow_add(3,5) is called:
    1. logger’s wrapper runs first, logging the call.
    2. It calls timer’s wrapper, which measures execution time.
    3. timer’s wrapper calls the original slow_add, which returns 8.
    4. timer logs the runtime, then returns 8 to logger.
    5. logger logs the return value and returns 8.

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 wrapper

Step 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 PermissionError

Output:#

Deleting database (user: Alice)
PermissionError: Admin access required!

Explanation:

  • admin_required wraps delete_database first, checking if the user is an admin.
  • login_required wraps 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 wrapper

Step 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 ValueError

Output:#

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 calls validate_input’s wrapper, which calls multiply.

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():
    pass

7. Best Practices#

  1. 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.

  2. Document Decorators
    Clearly document what each decorator does, its side effects, and any assumptions (e.g., "Requires a user argument with a logged_in key").

  3. 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.

  4. Test Decorators in Isolation
    Test each decorator independently before combining them. This ensures issues in one decorator don’t cascade to others.

  5. 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.wraps to 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.

9. References#