Skip to main content

How to Chain Multiple Decorators in Python

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

# Equivalent to:
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]


# Metadata is preserved
print(process_data.__name__) # Output: 'process_data'
print(process_data.__doc__) # Output: 'Process a list of items.'
Without @wraps

Without @wraps, function introspection breaks:

print(process_data.__name__)  # 'wrapper' (wrong!)
print(process_data.__doc__) # None (lost!)

This can break documentation tools, debugging, and frameworks that rely on function names.

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'}


# Test with authenticated request
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"}
Decorator Order Matters

In the example above:

  1. @require_auth runs first (innermost) - checks authentication
  2. @log_request runs second - logs the request
  3. @json_response runs last (outermost) - formats the response

If @require_auth fails, the inner decorators never execute.

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):
# Apply decorators from right to left
return reduce(lambda f, d: d(f), reversed(decorators), func)
return decorator


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


# Equivalent to:
# @logger
# @timer
# @require_auth
# def complex_operation(request): ...

Order Guidelines

ScenarioRecommended 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

AspectDirectionDescription
DecoratingBottom → TopClosest decorator wraps first
ExecutingTop → BottomOutermost code runs first
@wrapsAlways usePreserves function metadata
OrderLogic-dependentAuth 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.