Decorator stacking is the practice of applying multiple decorators to a single function by placing them one above another before the def keyword. Each decorator wraps the result of the one below it, building nested layers of behavior around the original function -- like wrapping a gift in multiple layers of paper, where each layer adds something new. Understanding how these layers compose is essential for writing correct decorator chains.
This article starts with the basic syntax, translates it into its manual equivalent so you can see the nesting structure, then walks through binding order versus execution order with concrete output. It covers parameterized decorator stacks, metadata preservation across layers, practical stacking patterns for production code, and the common mistakes that trip developers up when composing multiple decorators.
What Stacking Looks Like
A stacked decorator chain is any function with two or more @decorator lines above it:
@decorator_a
@decorator_b
@decorator_c
def my_function():
return "original"
Three decorators are stacked above my_function. Python processes this from the bottom up: decorator_c wraps my_function first, decorator_b wraps the result of that, and decorator_a wraps the result of that. When you call my_function(), you are calling whatever decorator_a returned -- which internally calls whatever decorator_b returned, which internally calls whatever decorator_c returned, which internally calls the original function.
The Desugared Equivalent
The @ syntax is shorthand. To understand stacking, it helps to see what Python is doing without the sugar. The stacked example above is exactly equivalent to:
def my_function():
return "original"
my_function = decorator_a(decorator_b(decorator_c(my_function)))
Reading the manual form from the inside out: decorator_c receives my_function and returns a wrapper. decorator_b receives that wrapper and returns another wrapper. decorator_a receives that wrapper and returns the final wrapper. The name my_function now points to the outermost wrapper.
The manual form decorator_a(decorator_b(decorator_c(func))) makes the nesting explicit. The @ syntax hides this nesting for readability, but the behavior is identical. When debugging stacking issues, writing out the desugared form often makes the problem obvious.
Bottom-Up Binding vs. Top-Down Execution
The phrase "decorators apply bottom-to-top" describes binding -- which decorator wraps which. But when the decorated function is called, execution flows top-to-bottom through the wrapper layers. These are two different phases, and confusing them is the source of nearly every stacking mistake.
Binding phase (happens once, at definition time): The decorator closest to def wraps the function first. Each decorator above wraps the result of the one below. This builds the onion structure from the inside out.
Execution phase (happens every time the function is called): The outermost wrapper runs first. Its pre-call code executes, then it calls the next wrapper inward, whose pre-call code executes, and so on until the original function runs. Return values propagate back outward -- post-call code in each wrapper runs in reverse order.
Proving the Order with Print Statements
The clearest way to see both phases in action is to add print statements at every stage:
import functools
def decorator_a(func):
print("BIND: decorator_a wrapping", func.__name__)
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("EXEC: decorator_a BEFORE")
result = func(*args, **kwargs)
print("EXEC: decorator_a AFTER")
return result
return wrapper
def decorator_b(func):
print("BIND: decorator_b wrapping", func.__name__)
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("EXEC: decorator_b BEFORE")
result = func(*args, **kwargs)
print("EXEC: decorator_b AFTER")
return result
return wrapper
@decorator_a
@decorator_b
def greet(name):
print(f"EXEC: greet({name})")
return f"Hello, {name}"
The moment Python reads this file, the binding phase produces:
# Binding output (at import/definition time):
# BIND: decorator_b wrapping greet
# BIND: decorator_a wrapping greet
Notice that decorator_b binds first (bottom-up). Now when you call the function:
greet("Alice")
# Execution output (at call time):
# EXEC: decorator_a BEFORE
# EXEC: decorator_b BEFORE
# EXEC: greet(Alice)
# EXEC: decorator_b AFTER
# EXEC: decorator_a AFTER
Execution enters at the outermost layer (decorator_a), passes through the middle layer (decorator_b), reaches the original function, then returns outward through each layer's post-call code. The pattern is symmetric: the first decorator to run its pre-call code is the last to run its post-call code.
Why Order Matters
Swapping the position of two decorators changes the behavior of the decorated function. Consider a timer and a retry decorator stacked together:
import time
import functools
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter() - start:.3f}s")
return result
return wrapper
def retry(max_tries=3):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_tries + 1):
try:
return func(*args, **kwargs)
except Exception:
if attempt == max_tries:
raise
time.sleep(0.1)
return wrapper
return decorator
Placing timer outside retry measures the total duration including all retry attempts and sleep intervals:
# Timer wraps retry -- measures TOTAL time including retries
@timer
@retry(max_tries=3)
def fetch_data():
import random
if random.random() < 0.6:
raise ConnectionError("timeout")
return {"status": "ok"}
# fetch_data took 0.247s (includes retries + sleeps)
Placing timer inside retry measures only a single attempt, with the timer running for each retry independently:
# Retry wraps timer -- measures EACH attempt individually
@retry(max_tries=3)
@timer
def fetch_data():
import random
if random.random() < 0.6:
raise ConnectionError("timeout")
return {"status": "ok"}
# fetch_data took 0.001s (just one attempt)
# fetch_data took 0.001s (second attempt)
# fetch_data took 0.001s (third attempt, succeeds)
Neither ordering is inherently wrong -- they answer different questions. The point is that swapping the two lines produces fundamentally different behavior, and understanding the nesting model is what lets you choose the right one.
Stacking a decorator that transforms the return type above a decorator that expects the original type will cause a runtime error. If @to_json converts a dict to a JSON string and @add_metadata expects a dict, placing @to_json below @add_metadata will pass a string where a dict is expected.
Stacking with Parameterized Decorators
Parameterized decorators -- those that accept their own arguments -- work the same way in a stack. The only difference is that the @decorator(args) call returns the actual decorator before the stacking rules apply:
import functools
def tag(name):
"""Wrap the return value in an HTML tag."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return f"<{name}>{result}</{name}>"
return wrapper
return decorator
@tag("div")
@tag("p")
@tag("b")
def greeting(name):
return f"Hello, {name}"
print(greeting("Alice"))
# <div><p><b>Hello, Alice</b></p></div>
Reading bottom-up: @tag("b") wraps the return value in <b> tags first. @tag("p") wraps that result in <p> tags. @tag("div") wraps the final result in <div> tags. The desugared equivalent is greeting = tag("div")(tag("p")(tag("b")(greeting))).
Preserving Metadata Through the Chain
Every decorator in a stack should use @functools.wraps(func) on its wrapper. Without it, each layer loses the metadata of whatever it wraps. In a stack, the problem compounds -- the outermost wrapper would carry the name of its own wrapper function, and the original function's __name__, __doc__, and __module__ would be completely lost.
import functools
def good_decorator(func):
@functools.wraps(func) # Metadata preserved
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def bad_decorator(func):
def wrapper(*args, **kwargs): # No @wraps -- metadata lost
return func(*args, **kwargs)
return wrapper
@good_decorator
@bad_decorator
def calculate(x, y):
"""Add two numbers."""
return x + y
print(calculate.__name__) # wrapper (metadata was lost at bad_decorator)
print(calculate.__doc__) # None (docstring was lost too)
The good_decorator at the top tried to copy metadata from its input -- but its input was already the bad_decorator's anonymous wrapper. The chain is only as strong as its weakest link. Every decorator in the stack needs @functools.wraps for the original metadata to survive:
@good_decorator
@good_decorator
def calculate(x, y):
"""Add two numbers."""
return x + y
print(calculate.__name__) # calculate
print(calculate.__doc__) # Add two numbers.
Practical Stacking Patterns
Authentication + Logging + Timing
A common production stack places authentication at the outermost layer (rejecting unauthorized calls before any work is done), logging in the middle, and timing at the innermost layer (measuring only the function's execution, not the auth check or logging overhead):
@require_auth("admin") # Outermost: reject early if unauthorized
@log_calls # Middle: log the call and return value
@timer # Innermost: measure only the function body
def delete_records(user, table_name):
# ... database operation
return f"Deleted all rows from {table_name}"
If the user is not an admin, require_auth raises a PermissionError immediately. The logging and timing decorators never execute. If the user is authorized, logging captures the call, timing measures the database operation, and the return value propagates back through both layers.
Validation + Caching
Placing validation outside caching ensures that invalid arguments are rejected before the cache is consulted. This prevents a scenario where bad input gets cached and returned on subsequent calls:
import functools
def validate_positive(func):
@functools.wraps(func)
def wrapper(n):
if n < 0:
raise ValueError(f"Expected positive integer, got {n}")
return func(n)
return wrapper
@validate_positive # Outermost: reject negatives before cache lookup
@functools.lru_cache(maxsize=128) # Innermost: cache valid results
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
print(factorial(5)) # 120
print(factorial(-3)) # ValueError raised -- never touches cache
Common Mistakes When Stacking
Forgetting Parentheses on Parameterized Decorators
A parameterized decorator like @retry(max_tries=3) must be called with parentheses. Writing @retry without parentheses passes the function directly to the outer factory function instead of to the inner decorator, causing a confusing TypeError:
# WRONG -- retry receives fetch_data as max_tries
@timer
@retry # Missing parentheses!
def fetch_data():
pass
# RIGHT
@timer
@retry(max_tries=3)
def fetch_data():
pass
Stacking a Return-Type-Transforming Decorator in the Wrong Position
If one decorator transforms the return value (for example, converting a dict to a JSON string), every decorator above it will receive the transformed type. A logging decorator that tries to access result["status"] will fail with a TypeError if the value is already a string:
import json
import functools
def to_json(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return json.dumps(func(*args, **kwargs))
return wrapper
def log_status(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
# This assumes result is a dict:
print(f"Status: {result['status']}") # TypeError if result is a string!
return result
return wrapper
# WRONG ORDER -- log_status receives a JSON string, not a dict
@log_status
@to_json
def get_health():
return {"status": "healthy", "uptime": 99.9}
# RIGHT ORDER -- log_status sees the dict, to_json converts afterward
@to_json
@log_status
def get_health():
return {"status": "healthy", "uptime": 99.9}
As a rule of thumb, decorators that transform the return type (serialization, formatting, type conversion) should be placed at the outermost position so that all inner decorators work with the original type. Decorators that observe without modifying (logging, timing) are generally safe in any position.
Placing Timing Decorators in the Wrong Layer
A timer placed at the outermost position measures everything inside it -- including authentication checks, logging overhead, cache lookups, and retry delays. If you want to measure only the function's own execution, the timer should be the innermost decorator (closest to def). If you want to measure total wall time including all wrapping behavior, place it outermost.
Key Takeaways
- Decorator stacking applies multiple decorators to one function. The
@lines stack visually from top to bottom, but Python binds them from bottom to top. The desugared formfunc = a(b(c(func)))makes the nesting explicit. - Binding is bottom-up; execution is top-down. The decorator closest to
defwraps the function first (binding). When the function is called, the outermost wrapper's pre-call code runs first, and post-call code runs in reverse (execution). This symmetric pattern is consistent for any number of stacked decorators. - Order changes behavior. Swapping two decorators alters what each one sees and wraps. A timer outside a retry measures total time; a timer inside a retry measures individual attempts. Choose the order based on what question you are trying to answer.
- Every decorator in the chain needs
@functools.wraps. Metadata propagation is only as reliable as the weakest link. One decorator without@wrapsbreaks the chain for every decorator above it. - Type-transforming decorators belong at the outermost layer. Decorators that change the return type (JSON serialization, string formatting) should wrap the entire chain so that inner decorators work with the original type.
- Observing decorators are position-flexible; gatekeeping decorators belong outermost. Authentication and authorization decorators should be outermost to reject invalid requests before any computation occurs. Logging and timing decorators can be placed at any layer depending on what you want to observe.
Decorator stacking is one of the most expressive composition tools in Python. The key to using it correctly is understanding the two-phase model: binding happens bottom-up at definition time, execution happens top-down at call time, and the order you choose determines what each layer sees and what it can do with the result. Once that model is clear, even complex stacks of five or six decorators become predictable and debuggable.