Stacking multiple decorators on a single function is one of the more common patterns in Python codebases. Frameworks like Flask, Django, and FastAPI depend on it. Logging, authentication, caching, and input validation layers all get stacked on top of each other with the @ syntax. But the moment you chain two or more decorators together, you need a precise mental model of what happens and when. Getting the order wrong can introduce silent bugs that range from broken caching to authentication bypasses.
This article walks through the full mechanics of chained decorator execution order in Python, from the wrapping phase through the call phase, with code you can run and verify at every step.
The Fundamental Rule: Wrapping Is Bottom-to-Top
Python's decorator syntax is syntactic sugar for function composition. When you write this:
@decorator_a
@decorator_b
@decorator_c
def my_function():
pass
Python translates it into this equivalent expression:
my_function = decorator_a(decorator_b(decorator_c(my_function)))
The innermost call is decorator_c(my_function), meaning the decorator closest to the def statement applies first. Then decorator_b wraps the result of that, and decorator_a wraps the result of everything below it. This is the wrapping phase, and it always proceeds from the bottom decorator upward.
You can confirm this by printing inside each decorator's body (not inside the wrapper, but at the decorator level itself):
def decorator_a(func):
print("Applying decorator_a")
def wrapper_a(*args, **kwargs):
print("wrapper_a: before call")
result = func(*args, **kwargs)
print("wrapper_a: after call")
return result
return wrapper_a
def decorator_b(func):
print("Applying decorator_b")
def wrapper_b(*args, **kwargs):
print("wrapper_b: before call")
result = func(*args, **kwargs)
print("wrapper_b: after call")
return result
return wrapper_b
@decorator_a
@decorator_b
def process():
print("process: running")
print("--- Calling process() ---")
process()
The output makes both phases visible. During wrapping, decorator_b applies first (bottom-to-top). During execution, wrapper_a runs its pre-call logic first (top-to-bottom), then wrapper_b, then the original function, and the post-call logic reverses back out. This symmetrical entry-and-exit pattern is the onion model.
@alpha
@beta
@gamma
def fn():
pass
The Onion Model Explained
Think of each decorator as a concentric layer wrapping the original function. When you call the decorated function, execution enters through the outermost shell, passes through each layer inward until it reaches the core function, then returns outward through each layer's post-call logic. The entry order is the reverse of the wrapping order.
A three-decorator stack demonstrates this clearly:
from functools import wraps
def layer(name):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f" entering {name}")
result = func(*args, **kwargs)
print(f" exiting {name}")
return result
return wrapper
return decorator
@layer("outer")
@layer("middle")
@layer("inner")
def core():
print(" --- core function ---")
core()
The "outer" decorator was the last to wrap the function, but it is the first to execute pre-call logic. Execution then tunnels inward to "middle," then "inner," then the core function. Post-call logic reverses the order exactly. This is identical in structure to nested function calls.
Why Order Changes Behavior
In production code, decorator order is not cosmetic. It determines which layer sees what data. Consider a function that needs both authentication and caching:
# Wrong order: cache sits inside the authentication boundary
@authenticate
@cache
def get_user_data(user_id):
return db.query(user_id)
With this ordering, cache wraps the function first, then authenticate wraps the cached version. During execution, authentication runs first, which seems fine. But the cache sits inside the authentication boundary. If user A requests data and it gets cached, and then user B requests the same endpoint, the cache might return user A's data before authentication for user B can even make decisions about scope or permissions.
# Correct order: authentication gates every uncached call
@cache
@authenticate
def get_user_data(user_id):
return db.query(user_id)
Now authentication wraps the function first (it is closest to def). During execution, the cache layer runs its check first. If there is no cached result, it passes control inward to the authentication layer, which verifies the caller before the database query runs.
The same principle applies to retry and logging combinations. If you want to log every retry attempt, the logger must wrap the function before the retry decorator wraps both:
@retry(max_attempts=3)
@log_failure
def send_notification(msg):
external_api.send(msg)
Here log_failure wraps the function first. Each time the retry decorator re-invokes the function, it is calling the logged version, so every failed attempt gets recorded. Reversing the order would mean log_failure sits outside retry, and it would only see the final outcome after all retries have been exhausted.
@auth over @cache — cached data leaks across users@cache over @auth — auth runs on every uncached call@log over @retry — only logs final failure@retry over @log — logs every retry attempt@validate over @transform — validates transformed data, not raw input@transform over @validate — validates raw input before transformingPreserving Metadata with functools.wraps
When you chain decorators, each wrapper replaces the function object. Without intervention, the final decorated function loses its original name, docstring, module, and annotations. Python's functools.wraps solves this by copying metadata from the wrapped function to the wrapper.
from functools import wraps
def decorator_a(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def decorator_b(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@decorator_a
@decorator_b
def fetch_records(table_name: str) -> list:
"""Fetch all records from the specified table."""
return []
print(fetch_records.__name__)
print(fetch_records.__doc__)
print(fetch_records.__wrapped__)
Without @wraps(func) in both decorators, fetch_records.__name__ would return "wrapper" and the docstring would be None. The __wrapped__ attribute that functools.wraps adds also gives you a reference to the original unwrapped function, which is useful for testing and introspection.
Every decorator you write should use @functools.wraps(func) on its inner wrapper function. This is not optional in professional code. Debuggers, logging frameworks, serializers like pickle, and documentation generators all rely on accurate function metadata.
functools.wraps. What does my_func.__name__ return?def outer(func):
def wrapper_outer(*args, **kwargs):
return func(*args, **kwargs)
return wrapper_outer
def inner(func):
def wrapper_inner(*args, **kwargs):
return func(*args, **kwargs)
return wrapper_inner
@outer
@inner
def my_func():
"""Original docstring."""
return 42
print(my_func.__name__)
Parameterized Decorators in Chains
When a decorator accepts arguments, it adds an extra layer of function nesting. A parameterized decorator is a factory: you call it with arguments, and it returns the real decorator. This means the factory executes at decoration time, producing the decorator that then wraps the function.
from functools import wraps
def repeat(n):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
def tag(label):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{label}] calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
@tag("AUDIT")
@repeat(3)
def save_record(data):
print(f" saving: {data}")
save_record("user_001")
The tag("AUDIT") factory runs first during module load and returns a decorator. Then repeat(3) runs and returns its decorator. The wrapping phase proceeds bottom-to-top: repeat's decorator wraps save_record, then tag's decorator wraps the result. At call time, the tag wrapper prints the audit log once, then calls into the repeat wrapper, which invokes the original function three times.
Building a Debug Trace for Decorator Chains
When debugging complex chains, a reusable trace decorator helps you see the exact execution flow without modifying every decorator in the stack:
from functools import wraps
def trace(label):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{label}] >> entering (calling {func.__name__})")
result = func(*args, **kwargs)
print(f"[{label}] << exiting (result: {result})")
return result
return wrapper
return decorator
@trace("auth")
@trace("validate")
@trace("transform")
def handle_request(payload):
return {"status": "ok", "data": payload.upper()}
handle_request("test_input")
Notice that func.__name__ reports handle_request at every level because functools.wraps propagates the name through each wrapper. Without it, the middle decorator would report calling wrapper instead.
Class-Based Decorators in Chains
Decorators do not have to be functions. A class with a __call__ method works as a decorator too. When chaining class-based decorators, the same rules apply: bottom-to-top wrapping, top-to-bottom execution.
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.call_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
print(f"Call #{self.call_count} to {self.func.__name__}")
return self.func(*args, **kwargs)
def uppercase_result(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper() if isinstance(result, str) else result
return wrapper
@uppercase_result
@CountCalls
def greet(name):
return f"hello, {name}"
print(greet("kandi"))
print(greet("reader"))
The CountCalls class instance wraps greet first. The uppercase_result function then wraps that instance. When called, uppercase_result runs first, which calls the CountCalls instance's __call__ method, which calls the original function. The result travels back outward: the raw string from greet passes through CountCalls unchanged, then uppercase_result transforms it.
Note the use of functools.update_wrapper(self, func) in the class-based decorator. This is the class equivalent of @functools.wraps and ensures that greet.__name__ still returns "greet" instead of the class name.
func(*args, **kwargs). What value does the outermost decorator receive as the function's return value?from functools import wraps
def outer(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
print(f"outer got: {result}")
return result
return wrapper
def broken_middle(func):
@wraps(func)
def wrapper(*args, **kwargs):
func(*args, **kwargs) # no return
return wrapper
def inner(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@outer
@broken_middle
@inner
def compute():
return 99
Verifying Order with the __wrapped__ Chain
When every decorator in a chain uses functools.wraps, you can walk the __wrapped__ attribute to inspect each layer of the decorator stack:
from functools import wraps
def auth(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
wrapper._decorator_name = "auth"
return wrapper
def validate(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
wrapper._decorator_name = "validate"
return wrapper
@auth
@validate
def api_endpoint():
return {"ok": True}
# Walk the wrapper chain
current = api_endpoint
while hasattr(current, "_decorator_name"):
print(f"Layer: {current._decorator_name}")
current = current.__wrapped__
print(f"Core: {current.__name__}")
This technique is useful in testing and in framework code where you need to verify that a function has the expected decorator chain applied in the correct order.
Common Pitfalls
Forgetting That Wrapping and Execution Are Separate Phases
Code that runs inside the decorator body (but outside the wrapper) executes during module import, not during function calls. If a decorator performs setup like database connections or file reads in its body instead of in the wrapper, that work runs once at import time in wrapping order (bottom-to-top), regardless of how many times the decorated function is called.
Swallowed Return Values
If any wrapper in the chain forgets to return func(*args, **kwargs), every decorator above it in the stack receives None instead of the real return value. In a long chain, this can be difficult to trace.
from functools import wraps
def broken_logger(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
func(*args, **kwargs) # missing return!
return wrapper
That missing return statement silently converts any decorated function's return value to None. When another decorator above broken_logger tries to transform or cache the result, it operates on None and the real data is lost.
Decorator Factories Without the Call
Applying a decorator factory without calling it is another common mistake:
# Wrong: passes the function to the factory, not the decorator
@repeat # missing (3) or (n=3)
def do_work():
pass
Without the parentheses and argument, repeat receives do_work as its n parameter, and the decorator returned by repeat never receives the function. This usually produces a TypeError at call time, but in chains with multiple decorators, the error message can be misleading because it surfaces at a different layer.
Key Takeaways
- Wrapping is bottom-to-top: The decorator closest to
defwraps the function first. Each decorator above it wraps the result of the one below. The equivalent desugared form isa(b(c(func))). - Execution follows the onion model: Pre-call logic runs top-to-bottom (outermost wrapper first). Post-call logic runs bottom-to-top (innermost wrapper first). This creates the symmetrical enter-then-exit pattern.
- Order is a design decision: Placing authentication inside or outside a cache layer has real security consequences. Place outermost decorators for cross-cutting concerns (caching, rate limiting) and innermost decorators for concerns tightly coupled to the function's data (validation, transformation).
- Always use functools.wraps: Without it, chained decorators destroy function metadata. This breaks debuggers, introspection, serialization, and any code that relies on
__name__or__doc__. - Always return from wrappers: A single missing
returnin any wrapper silently injectsNoneinto the entire chain above it.
When deciding the order for a decorator chain, read the stack top-to-bottom as a pipeline that requests flow through from outside to inside. The outermost decorator (the one at the top of the stack) is the first gate a call passes through. A general ordering pattern that works for backend API functions: caching or rate limiting on the outside, then authentication, then authorization, then input validation, then the function itself.
@rate_limit(max_per_minute=60)
@authenticate
@authorize(role="admin")
@validate_input(schema=UserSchema)
def update_user(user_id, data):
return db.update(user_id, data)
Read top to bottom as "first rate-limit, then authenticate, then check authorization, then validate input, then run the function." The wrapping order is the reverse, but the execution order matches the top-to-bottom reading of the source code.
Frequently Asked Questions
What order do chained decorators execute in Python?
Chained decorators in Python apply bottom-to-top during the wrapping phase, meaning the decorator closest to the function definition wraps first. However, during function execution, the outermost wrapper (the top decorator) runs its pre-call logic first, creating a top-to-bottom then bottom-to-top pattern often called the onion model.
Why does decorator order matter in Python?
Decorator order matters because each decorator wraps the result of the one below it. Changing the order changes which decorator sees the original function versus a previously wrapped version. This affects security, caching, logging, and data validation logic in production applications.
How do I preserve function metadata when chaining decorators?
Use functools.wraps as a decorator on your inner wrapper function. This copies the original function's __name__, __doc__, __module__, __qualname__, and __annotations__ to the wrapper, ensuring that introspection tools, debuggers, and help() display correct information through the entire decorator chain.
What is the onion model for Python decorators?
The onion model describes how chained decorators form concentric layers around a function. When the function is called, execution enters through the outermost layer first, passes inward through each wrapper's pre-call logic, reaches the original function at the center, then returns outward through each wrapper's post-call logic in reverse order.
How to Determine Chained Decorator Execution Order in Python
Step 1: Identify the decorator closest to the def statement
Look at the decorator stack and find the decorator directly above the def line. This is the innermost decorator and it wraps the original function first. In Python's desugared form, this decorator is the innermost function call.
Step 2: Read upward to build the wrapping chain
Move upward through the decorator stack one decorator at a time. Each decorator wraps the result of the one below it. The topmost decorator wraps last and becomes the outermost layer. Write out the desugared form as nested calls: top(middle(bottom(fn))).
Step 3: Trace execution order using the onion model
When the decorated function is called, execution enters through the outermost wrapper first and moves inward. Pre-call logic runs top-to-bottom. After the original function executes, post-call logic runs bottom-to-top. This creates a symmetrical enter-then-exit pattern.
Step 4: Apply functools.wraps to every wrapper
Add @functools.wraps(func) to each inner wrapper function in the chain. This preserves the original function's __name__, __doc__, __module__, __qualname__, and __annotations__ through every layer, and adds the __wrapped__ attribute for introspection.
Step 5: Verify the chain with a debug trace or __wrapped__ walk
Insert a reusable trace decorator into the chain to print entry and exit at each layer, or walk the __wrapped__ attribute chain to confirm each decorator was applied in the expected order.