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.