As your decorator library grows, you need to understand how multiple decorators interact when stacked, how to build stateful decorators using classes, and how to write decorator factories that accept arguments cleanly. These advanced patterns unlock the full power of Python’s decorator system. Understanding decorator stacking order is also critical when working with FastAPI โ route handlers often have multiple decorators from different sources (FastAPI, authentication, caching) and the order determines how they interact. Class-based decorators add state without closures, making certain patterns cleaner and more explicit.
Stacking Multiple Decorators
import functools
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
def underline(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<u>{func(*args, **kwargs)}</u>"
return wrapper
# โโ Stacking order: applied bottom-up, executed top-down โโโโโโโโโโโโโโโโโโโโโ
@bold # applied third (outermost wrapper)
@italic # applied second
@underline # applied first (innermost wrapper)
def greet(name: str) -> str:
return f"Hello, {name}"
# Equivalent to:
# greet = bold(italic(underline(greet_original)))
print(greet("Alice"))
# <b><i><u>Hello, Alice</u></i></b>
# Execution order (call greet("Alice")):
# bold wrapper โ calls italic wrapper
# italic wrapper โ calls underline wrapper
# underline wrapper โ calls original greet
# underline result โ "<u>Hello, Alice</u>"
# italic result โ "<i><u>Hello, Alice</u></i>"
# bold result โ "<b><i><u>Hello, Alice</u></i></b>"
# โโ Key rule: decorators are applied bottom to top โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# The decorator closest to 'def' is applied first (innermost)
# The decorator furthest from 'def' is applied last (outermost)
# When called, the outermost decorator runs first
@cache above @require_auth, the cache wrapper runs first โ it may serve cached responses to unauthenticated users (bypassing auth). The correct order is @require_auth above @cache, so auth runs first (outermost), then the cache layer, then the route handler. Always think about which wrapper should run first when stacking decorators in FastAPI.__init__ to accept arguments (replacing the outer factory function) and __call__ to implement the wrapper logic. The instance of the class is the decorator, and its attributes store the state.self parameter of the method is passed as the first positional argument to the decorator’s __call__ method. This means the decorator receives (self_of_decorated_class, *args, **kwargs) โ the first argument is the class instance, not the decorated class’s self. Use functools.wraps and test carefully when applying class-based decorators to methods.Class-Based Decorators
import functools
import time
class RateLimit:
"""Class-based decorator that enforces a rate limit on function calls."""
def __init__(self, max_calls: int, period_seconds: float):
self.max_calls = max_calls
self.period = period_seconds
self.call_times = []
def __call__(self, func):
"""Return a wrapper function that enforces the rate limit."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.time()
cutoff = now - self.period
# Remove calls outside the window
self.call_times = [t for t in self.call_times if t > cutoff]
if len(self.call_times) >= self.max_calls:
raise RuntimeError(
f"Rate limit exceeded: {self.max_calls} calls per {self.period}s"
)
self.call_times.append(now)
return func(*args, **kwargs)
return wrapper
# Usage โ RateLimit(10, 60) creates an instance, __call__ makes it a decorator
@RateLimit(max_calls=10, period_seconds=60)
def send_email(recipient: str, subject: str) -> bool:
print(f"Sending '{subject}' to {recipient}")
return True
# โโ CallCount โ simpler stateful decorator โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
class CallCount:
def __init__(self, func):
functools.update_wrapper(self, func) # same as @wraps for classes
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CallCount
def greet(name): return f"Hello, {name}"
greet("Alice")
greet("Bob")
print(greet.count) # 2
Decorator Factories with Optional Arguments
import functools
# Pattern: make decorator work both with and without arguments
def log(func=None, *, level: str = "INFO", prefix: str = ""):
"""Log function calls. Works as @log or @log(level="DEBUG")."""
if func is None:
# Called with arguments: @log(level="DEBUG")
# Return an actual decorator
return lambda f: log(f, level=level, prefix=prefix)
# Called without arguments: @log
tag = f"[{prefix}] " if prefix else ""
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"{tag}{level}: Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{tag}{level}: {func.__name__} returned")
return result
return wrapper
# Both forms work:
@log
def quick():
pass
@log(level="DEBUG", prefix="DB")
def database_query():
pass
# Called with wrong argument type triggers the right path
@log() # func=None, uses defaults
def another():
pass
Common Mistakes
Mistake 1 โ Wrong stacking order for auth + cache
โ Wrong โ cache runs before auth, serves cached responses to unauthenticated users:
@require_auth # runs second (inner) โ too late!
@cache # runs first (outer) โ cached before auth check!
โ Correct โ auth (outermost) runs first:
@cache # runs second โ only reached after auth passes โ
@require_auth # runs first (outermost) โ checks auth before caching
Mistake 2 โ Shared state between decorated instances
โ Wrong โ single class instance shared across all decorated functions:
rate_limiter = RateLimit(10, 60)
@rate_limiter # ALL uses of this decorator share the same call_times list!
def api_a(): pass
@rate_limiter # api_a and api_b share the rate limit pool
def api_b(): pass
โ Correct โ create a new instance per decoration:
@RateLimit(10, 60) # new RateLimit instance for each โ
def api_a(): pass
@RateLimit(10, 60) # separate rate limit pool โ
def api_b(): pass
Mistake 3 โ Applying class-based decorator to a method without testing
โ Wrong โ the class instance is passed as the first arg, not the method’s self:
class MyClass:
@CallCount # CallCount.__call__ receives (MyClass_instance, *args)
def method(self): # 'self' here is the MyClass instance, NOT the CallCount instance
pass # Descriptor protocol needed for this to work correctly
โ Correct โ use function-based decorators for methods, class-based for module-level functions.
Quick Reference โ Stacking Order
| Position | Applied | Runs When Called |
|---|---|---|
| Top (furthest from def) | Last | First (outermost) |
| Bottom (closest to def) | First | Last (innermost) |
| FastAPI auth + cache | @cache then @require_auth |
auth first, cache second |