Skip to content

How to Chain Multiple Decorators in Python

Learn how Python decorator chaining works, including execution order, metadata preservation with functools.wraps, and practical examples for authentication, logging, and caching.

Python allows you to stack multiple decorators on a single function to combine functionality like authentication, logging, caching, and timing. Understanding the order of execution is crucial for correct behavior.

Understanding the Stack Order

This is the most confusing aspect of decorator chaining:

  1. Decorating (Bottom-up): The decorator closest to the function wraps it first
  2. Executing (Top-down): When called, code runs from the outermost decorator inward
@decorator_a
@decorator_b
@decorator_c
def my_function():
    pass

my_function = decorator_a(decorator_b(decorator_c(my_function)))

Basic Example: Text Formatting

Stack decorators that add HTML tags:

def make_bold(func):
    def wrapper():
        return f"<b>{func()}</b>"
    return wrapper


def make_italic(func):
    def wrapper():
        return f"<i>{func()}</i>"
    return wrapper


def make_underline(func):
    def wrapper():
        return f"<u>{func()}</u>"
    return wrapper


@make_bold
@make_italic
@make_underline
def greet():
    return "Hello World"


print(greet())

Output:

<b><i><u>Hello World</u></i></b>

Execution Trace

  1. greet() returns "Hello World"
  2. make_underline wraps it → "<u>Hello World</u>"
  3. make_italic wraps that → "<i><u>Hello World</u></i>"
  4. make_bold wraps that → "<b><i><u>Hello World</u></i></b>"

Preserving Function Metadata with functools.wraps

Decorators replace the original function, losing metadata like __name__ and docstrings. Always use @wraps:

from functools import wraps


def logger(func):
    @wraps(func)  # Preserves original function's metadata
    def wrapper(*args, **kwargs):
        print(f"Calling: {func.__name__}")
        return func(*args, **kwargs)
    return wrapper


def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        import time
        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


@logger
@timer
def process_data(items):
    """Process a list of items."""
    return [item * 2 for item in items]


print(process_data.__name__)  # Output: 'process_data'
print(process_data.__doc__)   # Output: 'Process a list of items.'

Practical Example: Web Endpoint

A realistic example combining authentication, logging, and JSON response formatting:

from functools import wraps
import json


def require_auth(func):
    @wraps(func)
    def wrapper(request, *args, **kwargs):
        if not request.get('user'):
            return {'error': 'Unauthorized'}, 401
        return func(request, *args, **kwargs)
    return wrapper


def log_request(func):
    @wraps(func)
    def wrapper(request, *args, **kwargs):
        print(f"Request to {func.__name__} from {request.get('user', 'anonymous')}")
        return func(request, *args, **kwargs)
    return wrapper


def json_response(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        if isinstance(result, tuple):
            data, status = result
        else:
            data, status = result, 200
        return json.dumps(data), status
    return wrapper


@json_response
@log_request
@require_auth
def get_user_data(request):
    """Fetch user data from database."""
    return {'user': request['user'], 'data': 'secret info'}


request = {'user': 'alice'}
response, status = get_user_data(request)
print(f"Status: {status}, Response: {response}")

Output:

Request to get_user_data from alice
Status: 200, Response: {"user": "alice", "data": "secret info"}

Decorators with Arguments

Chaining decorators that accept parameters:

from functools import wraps


def repeat(times):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            results = []
            for _ in range(times):
                results.append(func(*args, **kwargs))
            return results
        return wrapper
    return decorator


def prefix(text):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return f"{text}: {result}"
        return wrapper
    return decorator


@repeat(3)
@prefix("Result")
def generate_number():
    import random
    return random.randint(1, 100)


print(generate_number())

Output:

['Result: 29', 'Result: 46', 'Result: 22']

Creating a Decorator Chain Helper

For complex applications, create a utility to chain decorators programmatically:

from functools import wraps, reduce


def chain_decorators(*decorators):
    """Chain multiple decorators into one."""
    def decorator(func):
        return reduce(lambda f, d: d(f), reversed(decorators), func)
    return decorator


@chain_decorators(logger, timer, require_auth)
def complex_operation(request):
    return "Done"

Order Guidelines

Scenario Recommended Order (Top to Bottom)
Auth + Logging @log → @auth (log all attempts)
Auth + Response @json_response → @auth (format errors too)
Cache + Timer @timer → @cache (time cache hits)
Retry + Timeout @retry → @timeout (retry timeouts)

Summary

Aspect Direction Description
Decorating Bottom → Top Closest decorator wraps first
Executing Top → Bottom Outermost code runs first
@wraps Always use Preserves function metadata
Order Logic-dependent Auth before formatting, etc.
  • Always use @wraps from functools to preserve function metadata.
  • Order matters: Think about which checks should happen first.
  • Test individually before chaining to ensure each decorator works correctly.
Navigation