Stacking decorators on a single function is common in production Python. A route handler might carry @app.route, @login_required, and @cache all at once. But the order you stack them in determines both which function each decorator wraps and which logic runs first when the function is called. Get the order wrong and authentication checks silently vanish, caches serve data to the wrong users, or logging records requests that should have been rejected. This article explains the two-phase execution model, traces through the call stack step by step, and covers the specific ordering rules that frameworks like Flask impose.
The confusion around decorator order comes from the fact that there are two separate phases, and each phase runs in the opposite direction. Understanding both phases eliminates the guesswork.
The Two-Phase Model: Wrapping vs. Execution
When Python encounters stacked decorators, two things happen at different times. Phase 1 occurs at definition time, when the module is imported and the def statement is executed. Phase 2 occurs at call time, when someone calls the decorated function.
Phase 1: Wrapping (Bottom-to-Top)
At definition time, decorators are applied from the bottom of the stack upward. The decorator closest to the def line wraps the original function first. Each decorator above wraps the result of the one below it.
@decorator_a
@decorator_b
@decorator_c
def my_function():
pass
# Python translates this to:
my_function = decorator_a(decorator_b(decorator_c(my_function)))
The innermost call is decorator_c(my_function). Its result is passed to decorator_b. That result is passed to decorator_a. The final value assigned to my_function is whatever decorator_a returns. Think of it as wrapping a package: the bottom decorator is the first layer of wrapping paper, closest to the function. Each decorator above adds another layer. The top decorator is the outermost layer.
Phase 2: Execution (Top-to-Bottom)
When the decorated function is called, execution starts from the outermost wrapper and works inward. The top decorator's wrapper runs its before-logic first, then calls the next wrapper down the stack, which runs its before-logic, then calls the next, until the original function finally runs. After-logic then unwinds in reverse order, from the innermost wrapper back to the outermost.
This is the critical distinction: wrapping happens bottom-to-top, execution happens top-to-bottom. The decorator you see at the top of the stack runs first when the function is called.
my_function reference after the def statement executes?@alpha@beta@gammadef my_function(): passgamma (closest to def) wraps first, then beta wraps that result, then alpha wraps the final result. The outermost layer is the top decorator, not the bottom one.# The correct expansion:
my_function = alpha(beta(gamma(my_function)))
# ^outermost ^innermost (closest to def)
gamma wraps the raw function first, beta wraps that result, and alpha wraps the outermost layer. The @ syntax is syntactic sugar for nested function calls, with the bottom decorator as the innermost call.# Step by step:
# 1. gamma wraps my_function -> gamma(my_function)
# 2. beta wraps that result -> beta(gamma(my_function))
# 3. alpha wraps that result -> alpha(beta(gamma(my_function)))
my_function = alpha(beta(gamma(my_function)))
def (gamma) wraps first, then the next one up (beta), then the topmost one (alpha).# The correct expansion — always bottom-to-top:
my_function = alpha(beta(gamma(my_function)))
# gamma first, beta second, alpha outermost
Tracing Through the Call Stack
The best way to internalize this model is to trace through it with concrete code. Here are three decorators that print when they enter and exit, applied to a single function. Each decorator uses functools.wraps to preserve the wrapped function's metadata:
import functools
def decorator_a(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("A: before")
result = func(*args, **kwargs)
print("A: after")
return result
return wrapper
def decorator_b(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("B: before")
result = func(*args, **kwargs)
print("B: after")
return result
return wrapper
def decorator_c(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("C: before")
result = func(*args, **kwargs)
print("C: after")
return result
return wrapper
@decorator_a
@decorator_b
@decorator_c
def greet(name):
print(f"Hello, {name}")
return name
greet("Kandi")
The output is:
A: before
B: before
C: before
Hello, Kandi
C: after
B: after
A: after
The before-logic runs top-to-bottom (A, B, C). The original function runs in the middle. The after-logic unwinds bottom-to-top (C, B, A). This is exactly how nested function calls work: the outermost function call enters first, calls the next function, which calls the next, and then return values propagate back outward.
Here is the same logic broken into the explicit nested call form, which makes the order visible without any decorator syntax:
# Equivalent to the stacked decorators above:
greet = decorator_a(decorator_b(decorator_c(greet)))
# When greet("Kandi") is called:
# 1. decorator_a's wrapper runs -> prints "A: before"
# 2. calls decorator_b's wrapper -> prints "B: before"
# 3. calls decorator_c's wrapper -> prints "C: before"
# 4. calls original greet -> prints "Hello, Kandi"
# 5. decorator_c's wrapper continues -> prints "C: after"
# 6. decorator_b's wrapper continues -> prints "B: after"
# 7. decorator_a's wrapper continues -> prints "A: after"
If any decorator's wrapper does not call func(*args, **kwargs), the chain stops at that point. Decorators below it in the stack never execute. This is how authentication decorators work: if the auth check fails, the wrapper returns early (or raises an exception) without ever calling the function beneath it.
Return Values Pass Through Every Layer
Each wrapper receives the return value from the function it calls. This means decorators can transform, inspect, or replace the return value at every layer of the stack.
import functools
def uppercase(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
def add_greeting(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return f"Hello, {result}!"
return wrapper
@add_greeting
@uppercase
def get_name():
return "kandi"
print(get_name())
# Hello, KANDI!
The @uppercase decorator wraps get_name first (bottom-to-top). When called, add_greeting's wrapper runs first (top-to-bottom), which calls uppercase's wrapper, which calls the original function. The original returns "kandi". uppercase transforms it to "KANDI". add_greeting receives "KANDI" and wraps it into "Hello, KANDI!".
Reversing the order changes the result:
@uppercase
@add_greeting
def get_name():
return "kandi"
print(get_name())
# HELLO, KANDI!
Now add_greeting runs first and produces "Hello, kandi!". Then uppercase transforms the entire string to "HELLO, KANDI!". Same decorators, different order, different result.
print(process("data")) output?@add_brackets — wraps result in [ ]@add_quotes — wraps result in " "def process(x): return xadd_brackets is outermost, so its wrapper runs first, but it calls into add_quotes's wrapper before producing its own result. add_quotes wraps "data" into "\"data\"", and that result flows back to add_brackets, which wraps it into ["\"data\""].import functools
def add_brackets(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"[{func(*args, **kwargs)}]"
return wrapper
def add_quotes(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f'"{func(*args, **kwargs)}"'
return wrapper
@add_brackets
@add_quotes
def process(x):
return x
print(process("data")) # ["data"]
# add_quotes wraps "data" -> "data" (with quotes)
# add_brackets wraps that -> ["data"]
add_brackets runs before add_quotes transforms the return value, but that reverses the return-value flow. The innermost decorator (add_quotes) transforms the return value first, then the outermost (add_brackets) wraps that result. Return values flow from the inside out.# Return value flow (inside-out):
# 1. process("data") returns "data"
# 2. add_quotes wraps it -> '"data"'
# 3. add_brackets wraps it -> '["data"]'
# Result: ["data"] NOT "[data]"
add_quotes entirely. Both decorators run. add_quotes is the inner decorator, so it transforms the return value before add_brackets sees it. The quotes are added first, then the brackets wrap the quoted string.# Both decorators always run:
# process = add_brackets(add_quotes(process))
# 1. add_quotes transforms "data" -> '"data"'
# 2. add_brackets transforms '"data"' -> '["data"]'
# Result: ["data"]
Ordering Mistakes That Cause Real Bugs
Authentication Below Logging (Security Leak)
This is a genuine security concern in web applications. If a logging decorator is above an authentication decorator, logging runs before the auth check. Unauthenticated requests are logged with their headers, tokens, and query parameters exposed.
import functools
def log_request(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"LOG: {func.__name__} called with {args}")
return func(*args, **kwargs)
return wrapper
def require_auth(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if not user.get("authenticated"):
raise PermissionError("Not authenticated")
return func(user, *args, **kwargs)
return wrapper
# WRONG: logs unauthenticated requests
@log_request
@require_auth
def get_admin_data(user):
return {"sensitive": "data"}
# CORRECT: auth runs first, logging only sees authenticated requests
@require_auth
@log_request
def get_admin_data(user):
return {"sensitive": "data"}
In the wrong order, log_request's wrapper runs first and records the request. Then require_auth's wrapper checks the token and rejects the user. But the damage is done: the request was already logged. In the correct order, require_auth runs first. If the user is not authenticated, the function raises PermissionError immediately and log_request never runs.
Flask's @app.route Must Be Outermost
Flask's documentation states that @app.route must always be the outermost (topmost) decorator. The reason is that @app.route is a registration decorator, not a wrapping decorator. It registers whatever function it receives into the URL routing table. If another decorator wraps the function before @app.route sees it, the route table gets the unprotected function.
# CORRECT: @app.route is outermost
@app.route("/admin")
@login_required
def admin_panel():
return "Admin content"
# WRONG: @login_required wraps first, @app.route registers
# the UNWRAPPED function — login_required is silently bypassed
@login_required
@app.route("/admin")
def admin_panel():
return "Admin content"
The wrong order above compiles and runs without any error or warning. Flask silently registers the unprotected function, and @login_required is never invoked on incoming requests. This is a documented security hazard. Always place @app.route at the top of the stack.
Caching Above Authentication (Serving Wrong Data)
If a caching decorator is placed above an authentication decorator, the cache stores the result keyed only by the function arguments, not by the authenticated user. Subsequent calls from different users may receive cached data that belongs to someone else.
# WRONG: cache runs before auth — cached data served to anyone
@cache_result
@require_auth
def get_user_dashboard(user):
return build_dashboard(user)
# CORRECT: auth runs first, cache only stores per-authenticated-user
@require_auth
@cache_result
def get_user_dashboard(user):
return build_dashboard(user)
In the wrong order, cache_result's wrapper checks the cache before require_auth verifies the caller. If a cached result exists from a previous authenticated call, it gets returned to the current caller regardless of whether that caller is authenticated or authorized to see the data.
Read a decorator stack top-to-bottom as a pipeline: "First do this, then do this, then run the function." The top decorator's before-logic runs first. If your mental reading of the stack does not match the order you want, reverse the decorators. For the common web handler pattern, the pipeline reads: route registration, then authentication, then caching, then logging, then the handler function.
Recommended Ordering for Web Handlers
| Position | Decorator Type | Reason |
|---|---|---|
| Top (outermost) | Route registration (@app.route) | Must see the fully wrapped function |
| 2nd | Authentication (@login_required) | Block unauthenticated requests before any processing |
| 3rd | Authorization (@require_role) | Check permissions after confirming identity |
| 4th | Caching (@cache) | Cache only authenticated, authorized results |
| Bottom (innermost) | Logging / Timing (@timer) | Measure only the function itself, not auth overhead |
@app.route is correctly outermost, but @cache is above @login_required. This means the cache checks for a stored result before authentication runs. If a previous authenticated user's response is cached, it gets served to unauthenticated callers. Authentication must always run before caching.# WRONG — cache serves data to unauthenticated users:
@app.route("/dashboard")
@cache
@login_required # too late — cache already returned
@timer
def dashboard():
return build_dashboard()
@app.route must always be the outermost (topmost) decorator. It is a registration decorator that records whatever function it receives into the URL routing table. If @login_required is above it, @app.route registers the unwrapped function and @login_required is silently bypassed on every incoming request.# WRONG — @login_required is silently bypassed:
@login_required
@app.route("/dashboard") # registers UNWRAPPED function
@cache
@timer
def dashboard():
return build_dashboard()
# @app.route sees: cache(timer(dashboard))
# @login_required never runs on incoming requests
@app.route is outermost (required by Flask). @login_required runs first on every request, blocking unauthenticated callers before any processing. @cache only stores results from authenticated requests. @timer measures only the handler itself, not the auth or cache overhead.# CORRECT pipeline — read top-to-bottom:
@app.route("/dashboard") # 1. Register the fully wrapped function
@login_required # 2. Block unauthenticated requests
@cache # 3. Cache only authenticated results
@timer # 4. Time only the handler
def dashboard():
return build_dashboard()
Alternatives to Deep Decorator Stacks
The advice to "keep stacks shallow" appears in nearly every article on decorator ordering, usually followed by a vague suggestion to use middleware or a class. That advice is incomplete without concrete patterns. Here are four approaches that eliminate deep stacks while preserving the same cross-cutting behavior, each suited to a different kind of codebase.
Composite Decorators with functools.reduce
If you find yourself applying the same three or four decorators to every handler in a module, the ordering knowledge is scattered across every function definition. A composite decorator centralizes it into one place. The functools.reduce function can compose an arbitrary list of decorators into a single decorator that applies them in the correct order:
import functools
def compose(*decorators):
"""Apply decorators in the order given (first argument is outermost)."""
def composite(func):
return functools.reduce(
lambda f, dec: dec(f), reversed(decorators), func
)
return composite
# Define the standard pipeline once:
secured_endpoint = compose(require_auth, log_request, timer)
# Apply it to every handler — ordering is guaranteed:
@secured_endpoint
def get_user_profile(user):
return build_profile(user)
@secured_endpoint
def get_user_settings(user):
return build_settings(user)
The reversed() call ensures that the first decorator passed to compose ends up outermost, matching the top-to-bottom reading order. Ordering is now a data structure rather than a visual convention, so a single change to the compose call propagates everywhere.
Class-Based Decorators with __call__
When decorator behavior needs to carry state between calls (such as counting invocations, accumulating metrics, or holding a cache that is shared across wrapped functions), a class with a __call__ method is cleaner than nesting closures three or four levels deep:
import functools
class AuditedEndpoint:
"""Decorator that combines auth, logging, and timing in a
single class with shared state."""
def __init__(self, func):
functools.update_wrapper(self, func)
self._func = func
self.call_count = 0
def __call__(self, user, *args, **kwargs):
# Phase 1: Authentication
if not user.get("authenticated"):
raise PermissionError("Not authenticated")
# Phase 2: Logging
self.call_count += 1
print(f"LOG [{self.call_count}]: {self._func.__name__}")
# Phase 3: Execute
return self._func(user, *args, **kwargs)
@AuditedEndpoint
def get_admin_data(user):
return {"sensitive": "data"}
# State is accessible after calls:
get_admin_data({"authenticated": True})
print(get_admin_data.call_count) # 1
This pattern collapses what would otherwise be three stacked decorators into a single class. The ordering of auth, logging, and execution is explicit in sequential lines of code rather than implicit in a visual stack. The functools.update_wrapper call preserves the original function's __name__, __doc__, and other metadata, just as @functools.wraps does in function-based decorators.
Framework Middleware Instead of Decorator Stacks
For concerns that apply to every request (or large groups of requests), framework middleware is the correct abstraction. Middleware runs at the application level, before any view function is reached, so it removes those concerns from the decorator stack entirely. In FastAPI, the Depends() injection system replaces decorator stacks with explicit dependency declarations:
from fastapi import Depends, FastAPI
app = FastAPI()
def get_current_user(token: str):
user = verify_token(token)
if not user:
raise HTTPException(status_code=401)
return user
def require_admin(user=Depends(get_current_user)):
if user.role != "admin":
raise HTTPException(status_code=403)
return user
@app.get("/admin")
def admin_panel(user=Depends(require_admin)):
return {"data": "admin content"}
No decorator stacking is needed because the dependency chain handles authentication and authorization through function signatures. The execution order is determined by the dependency graph, not by the visual position of decorators. Django achieves the same result with its MIDDLEWARE setting, which defines an ordered list of middleware classes that process every request in sequence before any view function runs.
Explicit Pipeline Functions
When you want the ordering to be a first-class, testable data structure rather than a decorator convention, you can define a pipeline as a list and execute it explicitly:
def run_pipeline(steps, context):
"""Execute a list of (name, callable) steps in order.
Each step receives and returns the context dict.
A step can raise to halt the pipeline."""
for name, step in steps:
context = step(context)
return context
def authenticate(ctx):
if not ctx["user"].get("authenticated"):
raise PermissionError("Not authenticated")
return ctx
def log_access(ctx):
print(f"LOG: {ctx['endpoint']} by {ctx['user']['name']}")
return ctx
def fetch_data(ctx):
ctx["result"] = {"sensitive": "data"}
return ctx
admin_pipeline = [
("auth", authenticate),
("log", log_access),
("handler", fetch_data),
]
result = run_pipeline(admin_pipeline, {
"user": {"name": "Kandi", "authenticated": True},
"endpoint": "/admin",
})
This pattern makes ordering an explicit, iterable, and testable list. You can write unit tests that verify the pipeline order, insert or remove steps dynamically, or share the same pipeline definition across multiple endpoints. It trades the elegance of decorator syntax for complete transparency about execution order.
These patterns are not mutually exclusive. A composite decorator is ideal when a fixed set of decorators is applied to many functions. A class-based decorator works best when the cross-cutting behavior needs shared state. Framework middleware handles concerns that span the entire application. Explicit pipelines are best when ordering must be inspectable or dynamically configurable. Choose the pattern that matches the scope and variability of the behavior you are extracting from your decorator stack.
@require_auth, @log_request, and @timer to 15 different handler functions in the same module, always in the same order. What is the best refactoring approach?# Class-based = best when you need shared state:
class RateLimitedEndpoint:
def __init__(self, func):
self._func = func
self.call_count = 0 # state shared across calls
def __call__(self, *args, **kwargs):
self.call_count += 1
# ... state-dependent logic
# Middleware = best for application-wide concerns:
# These run on EVERY request, including /health, /public, etc.
# Not ideal when only 15 of 30 routes need auth + logging
compose call propagates to all 15 functions.import functools
def compose(*decorators):
def composite(func):
return functools.reduce(
lambda f, dec: dec(f), reversed(decorators), func
)
return composite
# Define once, apply everywhere:
secured = compose(require_auth, log_request, timer)
@secured
def get_profile(user): ...
@secured
def get_settings(user): ...
# Change the pipeline in one place -> all 15 handlers update
Key Takeaways
- Stacked decorators operate in two phases. At definition time, they are applied bottom-to-top: the decorator closest to
defwraps the function first, and each decorator above wraps the result. At call time, the wrappers execute top-to-bottom: the outermost wrapper's before-logic runs first, the original function runs in the middle, and after-logic unwinds bottom-to-top. - The
@syntax is syntactic sugar for nested function calls.@A @B @C def fis equivalent tof = A(B(C(f))). When in doubt about the order, write out the equivalent assignment and trace through the nesting. - Order has security implications. Authentication and authorization decorators should be above (outermost to) logging and caching decorators. Placing them below means unauthorized requests can be logged, cached, or partially processed before being rejected.
- Registration decorators must be outermost. Flask's
@app.routeregisters the function it receives into the URL table. If it is not the outermost decorator, it registers an unwrapped function and other decorators in the stack are silently bypassed. This is a well-documented source of security vulnerabilities in Flask applications. - Keep stacks shallow and use alternatives when stacks grow. Stacking more than three decorators on a single function makes the execution order difficult to trace. When stacks grow deeper, refactor to a composite decorator built with
functools.reduce, a class-based decorator with__call__and shared state, framework middleware or dependency injection (like FastAPI'sDepends()), or an explicit pipeline where ordering is a testable data structure rather than a visual convention.
Decorator stacking is not complicated once you accept the two-phase model. Wrapping is bottom-to-top. Execution is top-to-bottom. The top decorator in the source code is the first code that runs when the function is called. If you read the stack from top to bottom as "first do this, then do this, then run the function," the order in your source code will match the order in your runtime. Every ordering decision follows from that one principle.
How to Determine the Correct Order for Stacked Decorators
- Identify the two phases. Recognize that stacked decorators involve two separate phases: bottom-to-top wrapping at definition time, and top-to-bottom execution at call time. The decorator closest to the
defline wraps the function first. - Write out the equivalent nested call. Convert the
@syntax to the explicit assignment form.@A @B @C def fbecomesf = A(B(C(f))). This makes the wrapping order visible and traceable. - Place registration decorators outermost. If using a framework like Flask, place
@app.routeat the top of the stack. Registration decorators record the function they receive, so they must see the fully wrapped version. - Place security decorators above data-processing decorators. Authentication and authorization decorators should be above (outermost to) caching and logging decorators. This ensures unauthenticated requests are rejected before any processing occurs.
- Read the stack top-to-bottom as a pipeline. Verify your ordering by reading the stack from top to bottom: "first do this, then do this, then run the function." If the reading does not match the desired execution order, rearrange the decorators.
Frequently Asked Questions
In what order are stacked Python decorators applied?
Stacked decorators are applied bottom-to-top at definition time. The decorator closest to the def line wraps the function first, and each decorator above wraps the result of the one below it. The syntax @A @B @C def f(): pass is equivalent to f = A(B(C(f))).
In what order do stacked decorators execute when the function is called?
When the decorated function is called, execution proceeds top-to-bottom. The outermost decorator's wrapper runs first (its before-logic), then calls into the next wrapper, which calls into the next, until the original function runs. After-logic then unwinds in reverse, bottom-to-top.
Why does decorator order matter for security?
If an authentication decorator is placed below a logging decorator, logging runs before authentication. This means unauthenticated requests are logged, potentially exposing sensitive data like tokens or query parameters. Placing authentication above logging ensures that only authenticated requests reach the logging layer.
Where should Flask's @app.route decorator go in a decorator stack?
Flask's @app.route must always be the outermost (topmost) decorator. It registers the function it receives in the URL routing table. If any other decorator wraps the function before @app.route sees it, @app.route registers the unprotected function, and decorators like @login_required are silently bypassed.
How many decorators is too many on a single function?
There is no hard limit, but stacking more than three decorators on a single function makes the execution order difficult to trace and debug. If you find yourself stacking four or more, consider whether a middleware pipeline, a class-based approach, or a single composite decorator would express the same logic more clearly.
What are concrete alternatives to deep decorator stacks in Python?
Four alternatives handle deep stacks: composite decorators using functools.reduce to merge multiple decorators into one reusable unit with guaranteed ordering, class-based decorators with __call__ that combine concerns with shared state, framework middleware or dependency injection like FastAPI's Depends() that moves cross-cutting concerns out of the decorator stack entirely, and explicit pipeline functions where ordering is a testable list of callables rather than a visual stacking convention.