A single decorator adds one layer of behavior around a function. Chaining decorators stacks multiple layers, each one wrapping the result of the layer below it. The technique turns individual, focused decorators into composable building blocks that combine logging, validation, timing, caching, and authentication on the same function without a single line of the original code being touched. This article walks through chaining from basic syntax to production-level ordering strategies, with runnable code at every step.
Python's @ syntax for applying decorators is syntactic sugar for passing a function into another function. Writing @decorator above a function definition is equivalent to reassigning the function name to the return value of calling decorator(func). When you stack multiple @ lines, each one wraps the result of the previous reassignment, producing a nested chain of wrappers around the original function. The rest of this article explores exactly how that nesting works and how to control it.
The Stacking Syntax
Chaining decorators requires no special syntax beyond placing multiple @decorator lines above a single def statement. Python reads them from top to bottom but applies them from bottom to top. The decorator closest to the function definition wraps the function first. Each decorator above it wraps the result of the one below.
from functools import wraps
def bold(func):
@wraps(func)
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def italic(func):
@wraps(func)
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
@bold
@italic
def greet(name):
return f"Hello, {name}"
print(greet("Kandi"))
# <b><i>Hello, Kandi</i></b>
The stacking @bold on top of @italic is equivalent to writing greet = bold(italic(greet)). The italic decorator wraps the original greet first, producing a function that returns an italicized string. The bold decorator then wraps that result, producing a function that returns a bolded string containing the italicized content. The nesting is visible in the output: the <b> tag is outermost, the <i> tag is innermost.
Reversing the stack order reverses the nesting:
@italic
@bold
def greet_reversed(name):
return f"Hello, {name}"
print(greet_reversed("Kandi"))
# <i><b>Hello, Kandi</b></i>
Now bold wraps first and italic wraps the bolded result. The order you write the decorators directly determines the structure of the output.
bold(italic(greet))("World") produce?Application Order vs. Execution Order
The distinction between application order and execution order is the concept that trips up programmers who are new to chaining. These are two separate phases that follow opposite directions.
Application order is the sequence in which Python calls each decorator function at definition time. This happens bottom-to-top. The bottommost decorator receives the original function. Each one above it receives the wrapper returned by the one below.
Execution order is the sequence in which the before-code and after-code of each wrapper runs when the decorated function is called. This flows top-to-bottom for before-code and bottom-to-top for after-code, because the outermost wrapper's before-code runs first, then it calls the next layer inward, and so on.
from functools import wraps
def decorator_a(func):
print("[DEFINITION] decorator_a is wrapping:", func.__name__)
@wraps(func)
def wrapper(*args, **kwargs):
print("[CALL] decorator_a before")
result = func(*args, **kwargs)
print("[CALL] decorator_a after")
return result
return wrapper
def decorator_b(func):
print("[DEFINITION] decorator_b is wrapping:", func.__name__)
@wraps(func)
def wrapper(*args, **kwargs):
print("[CALL] decorator_b before")
result = func(*args, **kwargs)
print("[CALL] decorator_b after")
return result
return wrapper
@decorator_a
@decorator_b
def process():
print("[CALL] original function body")
print("--- Now calling process() ---")
process()
Output:
[DEFINITION] decorator_b is wrapping: process
[DEFINITION] decorator_a is wrapping: process
--- Now calling process() ---
[CALL] decorator_a before
[CALL] decorator_b before
[CALL] original function body
[CALL] decorator_b after
[CALL] decorator_a after
At definition time, decorator_b runs first (bottom-to-top). At call time, decorator_a's before-code runs first (top-to-bottom). The after-code unwinds in reverse: decorator_b's after-code runs before decorator_a's after-code, because the inner wrapper completes before the outer wrapper resumes.
Think of chained decorators as nesting boxes. The bottom decorator is the innermost box (closest to the gift). The top decorator is the outermost box. Opening the outermost box first means encountering the top decorator's before-code first.
The Manual Equivalent
Every decorator chain has an equivalent manual form. Writing out the manual form can clarify what the @ syntax is doing when the chain gets long:
# These two forms are identical:
# Form 1: @ syntax
@decorator_a
@decorator_b
@decorator_c
def my_func():
pass
# Form 2: manual chaining
def my_func():
pass
my_func = decorator_a(decorator_b(decorator_c(my_func)))
The manual form reads inside-out: decorator_c wraps my_func first, then decorator_b wraps that result, then decorator_a wraps the outermost layer. The @ syntax reads top-to-bottom but applies in the same inside-out sequence.
@A, @B, @C (top to bottom) above def func(). Each prints a message before calling the wrapped function. Which message prints first when func() is called?Chaining Parameterized Decorators
A parameterized decorator (sometimes called a decorator factory) is a function that returns a decorator. It adds an extra layer of nesting because the outer function accepts configuration arguments and the inner function accepts the function to decorate. Parameterized decorators chain just like simple ones because each @factory(args) call resolves to a standard decorator before Python applies it.
import time
from functools import wraps
def retry(max_attempts, delay=1.0):
"""Retry the function up to max_attempts times on exception."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
print(f"[RETRY] {func.__name__} attempt {attempt} "
f"failed: {e}")
if attempt < max_attempts:
time.sleep(delay)
raise last_exception
return wrapper
return decorator
def log_call(label):
"""Log function entry with a custom label."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{label}] Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"[{label}] {func.__name__} returned {result!r}")
return result
return wrapper
return decorator
call_count = 0
@log_call("API")
@retry(max_attempts=3, delay=0.5)
def fetch_data(url):
global call_count
call_count += 1
if call_count < 3:
raise ConnectionError(f"Failed to reach {url}")
return {"status": "ok", "url": url}
result = fetch_data("https://example.com/api")
print(result)
Output:
[API] Calling fetch_data
[RETRY] fetch_data attempt 1 failed: Failed to reach https://example.com/api
[RETRY] fetch_data attempt 2 failed: Failed to reach https://example.com/api
[API] fetch_data returned {'status': 'ok', 'url': 'https://example.com/api'}
{'status': 'ok', 'url': 'https://example.com/api'}
The log_call("API") factory call returns a decorator. That decorator wraps the result of the retry(max_attempts=3, delay=0.5) factory call, which also returned a decorator that wrapped the original fetch_data. The logging happens once on the outside. The retry loop happens on the inside, calling the original function up to three times. All retry attempts are invisible to the logging decorator because they happen within the retry wrapper's single invocation.
When mixing simple decorators and parameterized decorators in a chain, remember that the parameterized ones require parentheses even if you want default arguments. Writing @retry instead of @retry() passes the decorated function to retry as its first argument, which will fail because retry expects max_attempts, not a function.
@retry(max_attempts=3) is stacked above @log_call("API"). How many times does the log message appear if the function fails twice, then succeeds on the third attempt?Metadata Preservation Across a Chain
Each decorator in a chain replaces the function it wraps with a new wrapper function. Without functools.wraps, the outermost wrapper determines the __name__, __doc__, and __module__ that external code sees. In a chain of three decorators without @wraps, the original function's metadata is completely buried under three layers of generic wrapper names.
# WITHOUT @wraps: metadata is lost
def decorator_no_wraps(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@decorator_no_wraps
@decorator_no_wraps
@decorator_no_wraps
def important_function():
"""This function does critical work."""
return 42
print(important_function.__name__) # wrapper
print(important_function.__doc__) # None
The function name reports as "wrapper" and the docstring is gone. This is not just a cosmetic problem. Frameworks that use function names for routing, logging systems that record function identity, and documentation generators that extract docstrings will all produce incorrect output.
# WITH @wraps: metadata is preserved through the chain
from functools import wraps
def decorator_with_wraps(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@decorator_with_wraps
@decorator_with_wraps
@decorator_with_wraps
def important_function():
"""This function does critical work."""
return 42
print(important_function.__name__) # important_function
print(important_function.__doc__) # This function does critical work.
With @wraps(func) in every decorator, the metadata propagates through the entire chain. Each @wraps copies the attributes from whatever function it receives as func. Since each func in the chain already has its @wraps applied, the original metadata passes cleanly from the innermost wrapper all the way to the outermost one. For a detailed walkthrough of how @wraps works internally, see how to use functools.wraps inside a decorator.
If even one decorator in the chain omits @wraps, the metadata breaks at that point. Every decorator above it will see the broken wrapper's identity instead of the original function's identity. When building decorator chains, treat @wraps(func) as non-negotiable in every decorator you write.
my_func. The middle decorator omits @functools.wraps, but the top and bottom decorators include it. What does my_func.__name__ return?Production Ordering Patterns
In production code, the order of decorators in a chain is a design decision with real consequences. The wrong order can log sensitive data before authentication runs, measure validation overhead in timing results, or cache unauthenticated responses. For implementations of these individual decorators, see decorator patterns for logging, caching, and rate limiting. The following patterns represent common ordering strategies.
Pattern 1: Authentication Before Logging
Placing the authentication decorator above the logging decorator means unauthenticated requests are rejected before they generate log entries. This prevents leaking sensitive request data into logs from unauthorized callers:
from functools import wraps
def require_auth(func):
"""Reject calls from unauthenticated users."""
@wraps(func)
def wrapper(user, *args, **kwargs):
if not user.get("is_authenticated"):
raise PermissionError(
f"User '{user.get('name', 'unknown')}' is not authenticated"
)
return func(user, *args, **kwargs)
return wrapper
def log_access(func):
"""Log function entry and successful completion."""
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[LOG] Accessing {func.__name__} with args: {args}")
result = func(*args, **kwargs)
print(f"[LOG] {func.__name__} completed successfully")
return result
return wrapper
# Correct order: auth ABOVE log
@require_auth
@log_access
def get_admin_dashboard(user):
return {"dashboard": "admin_panel", "user": user["name"]}
# Authenticated user: both auth and log run
admin = {"name": "Kandi", "is_authenticated": True}
print(get_admin_dashboard(admin))
# [LOG] Accessing get_admin_dashboard with args: ...
# [LOG] get_admin_dashboard completed successfully
# {'dashboard': 'admin_panel', 'user': 'Kandi'}
# Unauthenticated user: auth blocks before log runs
guest = {"name": "Guest", "is_authenticated": False}
try:
get_admin_dashboard(guest)
except PermissionError as e:
print(f"Blocked: {e}")
# Blocked: User 'Guest' is not authenticated
The guest user never reaches the log_access wrapper because require_auth raises PermissionError first. No log entry is created for the unauthorized attempt, which is the correct security behavior.
Pattern 2: Timing Inside Validation
When you need to measure how long the core function takes without including validation overhead, place the timer decorator below the validator so that timing starts only after validation passes:
import time
from functools import wraps
def validate_range(min_val, max_val):
"""Reject values outside the closed interval [min_val, max_val]."""
def decorator(func):
@wraps(func)
def wrapper(value, *args, **kwargs):
if not (min_val <= value <= max_val):
raise ValueError(
f"Value {value} out of range [{min_val}, {max_val}]"
)
return func(value, *args, **kwargs)
return wrapper
return decorator
def timer(func):
"""Print wall-clock execution time of the wrapped function."""
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"[TIMER] {func.__name__}: {elapsed:.6f}s")
return result
return wrapper
@validate_range(0, 100)
@timer
def compute_percentile(value):
"""Simulate an expensive percentile computation."""
time.sleep(0.05)
return value / 100
print(compute_percentile(75))
# [TIMER] compute_percentile: 0.050312s
# 0.75
try:
compute_percentile(150)
except ValueError as e:
print(f"Rejected: {e}")
# Rejected: Value 150 out of range [0, 100]
Invalid inputs are rejected before the timer starts. The timer only measures the actual computation, not the validation logic. If the decorators were reversed, the timer would measure validation time as well, and failed validations would still report a duration even though no useful work was performed.
Pattern 3: Cache Below Authentication
Caching decorators store function results keyed by arguments. If a cache decorator is placed above an authentication decorator, it might serve cached responses to unauthenticated users. The correct placement puts caching below authentication so that only authenticated calls benefit from the cache:
from functools import wraps
def simple_cache(func):
"""A basic cache keyed on positional and keyword arguments."""
_cache = {}
@wraps(func)
def wrapper(*args, **kwargs):
key = args + tuple(sorted(kwargs.items()))
if key in _cache:
print(f"[CACHE] Hit for {func.__name__}{args}")
return _cache[key]
print(f"[CACHE] Miss for {func.__name__}{args}")
result = func(*args, **kwargs)
_cache[key] = result
return result
return wrapper
def require_token(func):
"""Reject calls that do not carry a valid token."""
@wraps(func)
def wrapper(token, *args, **kwargs):
if token != "valid-token-123":
raise PermissionError("Invalid token")
return func(token, *args, **kwargs)
return wrapper
@require_token
@simple_cache
def get_user_profile(token, user_id):
print(f"[DB] Fetching profile for user {user_id}")
return {"user_id": user_id, "name": f"User_{user_id}"}
# First call: cache miss, hits database
print(get_user_profile("valid-token-123", 42))
# Second call: cache hit, skips database
print(get_user_profile("valid-token-123", 42))
# Invalid token: rejected before cache is checked
try:
get_user_profile("bad-token", 42)
except PermissionError as e:
print(f"Blocked: {e}")
The cache sits between the authentication layer and the original function. Authenticated calls benefit from caching. Unauthenticated calls are rejected before the cache is consulted, which means the cache never serves data to unauthorized callers.
| Stack Position | Decorator Type | Reasoning |
|---|---|---|
| Top (outermost) | Authentication / Authorization | Reject unauthorized calls before any work occurs |
| Second | Rate Limiting | Throttle only authenticated callers |
| Third | Logging / Monitoring | Record only permitted, non-throttled calls |
| Fourth | Caching | Cache results for logged, authorized calls |
| Bottom (innermost) | Timing / Retry | Measure or retry only the core function itself |
@require_auth on top and @simple_cache below it. An unauthenticated user calls the function with the same arguments a cached result exists for. What happens?Debugging Decorator Chains
When a chain of decorators produces unexpected behavior, the quickest diagnostic tool is a trace decorator that prints the entry and exit of each layer. By inserting it at different positions in the chain, you can see exactly which layer is producing the unexpected result:
from functools import wraps
def trace(label):
"""Insert at any point in a decorator chain to trace execution."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[TRACE:{label}] entering {func.__name__}")
result = func(*args, **kwargs)
print(f"[TRACE:{label}] exiting {func.__name__} -> {result!r}")
return result
return wrapper
return decorator
@trace("outer")
@trace("middle")
@trace("inner")
def add(a, b):
return a + b
add(3, 7)
# [TRACE:outer] entering add
# [TRACE:middle] entering add
# [TRACE:inner] entering add
# [TRACE:inner] exiting add -> 10
# [TRACE:middle] exiting add -> 10
# [TRACE:outer] exiting add -> 10
The trace output confirms the execution order: outer before-code first, then middle, then inner. On the way back out, inner after-code first, then middle, then outer. If any decorator in the chain were modifying the return value, the trace would show the value changing as it passes through each layer.
Key Takeaways
- Chaining is stacking. Place multiple
@decoratorlines above adefstatement. No additional syntax is required. Each decorator wraps the result of the one below it. - Application is bottom-to-top; execution is top-to-bottom. Decorators are applied from the innermost (closest to
def) to the outermost at definition time. At call time, before-code runs outermost first, and after-code unwinds innermost first. - Parameterized decorators chain normally. Each
@factory(args)call resolves to a decorator before it is applied. The resulting decorator participates in the chain the same way a simple decorator does. - Use
@functools.wraps(func)in every decorator. Without it, metadata breaks at the first decorator that omits it, and every decorator above it sees the wrong function name and docstring. - Order is a design decision. Place security decorators outermost so they run first. Place timing and retry decorators innermost so they measure only the core function. Logging and caching belong between these extremes, ordered according to whether you want to log cached results or only cache logged results.
Decorator chaining turns single-purpose wrappers into a composable toolkit. Each decorator handles one concern, and the chain combines them without any decorator needing to know about the others. The ordering rules are consistent and predictable: bottom-to-top application, top-to-bottom execution. Once you internalize that pattern, you can build chains of any length with confidence about exactly when each layer's code will run.
-
1
Write each decorator with functools.wraps
Create each decorator as a standalone function that accepts a function, defines an inner wrapper using
*argsand**kwargs, and applies@functools.wraps(func)to the wrapper before returning it. This preserves the original function's name and docstring through any number of chained layers. -
2
Stack the decorators above the function definition
Place each
@decoratoron its own line directly above thedefstatement. Python applies them bottom-to-top: the decorator closest todefwraps the function first, and each decorator above it wraps the previous result. -
3
Order the chain by execution priority
Place the decorator whose before-code should run first at the top of the stack. In production, this typically means authentication outermost, then rate limiting, then logging, then caching, and finally timing or retry innermost.
-
4
Use parentheses for parameterized decorators
If a decorator accepts configuration arguments (a decorator factory), call it with parentheses in the stack:
@retry(max_attempts=3). The factory call returns a standard decorator, which is then applied in the normal bottom-to-top order. -
5
Verify the chain with a trace decorator
Insert a parameterized trace decorator at each position in the chain to confirm the execution order. The trace output shows entry and exit messages for each layer, making it straightforward to verify that before-code runs top-to-bottom and after-code unwinds bottom-to-top.
- What does it mean to chain decorators in Python?
- Chaining decorators means stacking multiple
@decoratorlines above a single function definition. Each decorator wraps the result of the one below it, layering additional behavior around the original function without modifying its source code. - In what order are chained decorators applied?
- Chained decorators are applied bottom-to-top at definition time. The decorator closest to the
defkeyword wraps the function first, and each subsequent decorator above it wraps the previous result. At call time, execution flows top-to-bottom through each wrapper's before-code, then the original function runs, then after-code unwinds bottom-to-top. - Does the order of chained decorators matter?
- Yes. Changing the order of chained decorators changes the nesting structure and therefore the execution sequence. For example, placing an authentication decorator above a logging decorator means unauthenticated calls are rejected before they are logged. Reversing the order would log unauthorized requests before rejecting them.
- Can parameterized decorators be chained?
- Yes. Parameterized decorators (decorator factories) can be chained the same way as simple decorators. Each
@decorator(args)call returns a standard decorator, and that decorator is then applied in the normal bottom-to-top order. - Why is functools.wraps important when chaining decorators?
- When decorators are chained, each wrapper replaces the previous function's identity. Without
functools.wraps, the outermost wrapper's__name__and__doc__reflect the last wrapper function, not the original. Using@wraps(func)in every decorator preserves the original function's metadata through the entire chain.