Advanced Python Decorators: Args, Classes, and Nesting
Building on basic decorator knowledge, advanced patterns unlock more powerful abstractions: configurable decorators, decorators that work on functions and methods, class-based decorators, and decorators that compose cleanly with other language features.
Decorator Factories (Parameterized Decorators)
When a decorator needs arguments, wrap it in a factory function:
import functools
def retry(max_attempts=3, delay=1):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts:
raise
print(f"Attempt {attempt} failed: {e}")
time.sleep(delay)
return None
return wrapper
return decorator
@retry(max_attempts=5, delay=2)
def fetch_data(url):
return requests.get(url).json()The @retry(max_attempts=5, delay=2) syntax calls retry(5, 2), which returns the actual decorator. That decorator then receives fetch_data.
Optional Arguments Pattern
For decorators usable with or without arguments:
def cache(func=None, *, ttl=300):
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
key = (fn.__name__, args, tuple(sorted(kwargs.items())))
if key in wrapper._cache:
value, timestamp = wrapper._cache[key]
if time.time() - timestamp < ttl:
return value
result = fn(*args, **kwargs)
wrapper._cache[key] = (result, time.time())
return result
wrapper._cache = {}
return wrapper
if func is not None:
return decorator(func) # @cache (no parens)
return decorator # @cache(ttl=600)
@cache
def slow_function(x):
time.sleep(2)
return x * 2
@cache(ttl=600)
def api_call(endpoint):
return requests.get(endpoint).json()Class-Based Decorators
Decorators can be classes implementing __call__:
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call {self.count} of {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def greet(name):
print(f"Hello, {name}!")
greet("Alice") # Call 1 of greet
greet("Bob") # Call 2 of greetClass-Based Decorator Factory
class RateLimit:
def __init__(self, max_calls=10, period=60):
self.max_calls = max_calls
self.period = period
self.calls = []
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.time()
self.calls = [t for t in self.calls if now - t < self.period]
if len(self.calls) >= self.max_calls:
raise Exception("Rate limit exceeded")
self.calls.append(now)
return func(*args, **kwargs)
return wrapper
@RateLimit(max_calls=5, period=10)
def api_request():
return "response"Decorators on Methods
When decorating class methods, self is just the first positional argument. Most decorators work unchanged. But some need special handling.
The self Problem
def requires_permission(permission):
def decorator(func):
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
if not self.user.has_permission(permission):
raise PermissionError(f"Missing {permission}")
return func(self, *args, **kwargs)
return wrapper
return decorator
class DocumentService:
@requires_permission("documents:write")
def update_document(self, doc_id, content):
# self is available through the wrapper
passClassmethod and Staticmethod Compatible
import inspect
def logged(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
class Utils:
@logged
def instance_method(self):
pass
@logged
@classmethod
def class_method(cls):
pass
@logged
@staticmethod
def static_method():
passOrder matters: @logged should be the outermost decorator (closest to the function definition), or above @classmethod/@staticmethod.
Decorator Nesting and Composition
Multiple decorators stack from bottom to top:
@log_calls
@timing
@validate_args
def process_order(order_id, amount):
# process_order = log_calls(timing(validate_args(process_order)))
passDecorator Composition Utility
def compose(*decorators):
"""Apply multiple decorators right-to-left."""
def decorator(func):
for dec in reversed(decorators):
func = dec(func)
return func
return decorator
@compose(log_calls, timing, validate_args)
def process_order(order_id, amount):
passDecorators with Async/Await
import asyncio
def async_retry(max_attempts=3):
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return await func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts:
raise
print(f"Attempt {attempt} failed: {e}")
await asyncio.sleep(1)
return wrapper
return decorator
@async_retry(max_attempts=3)
async def fetch_user(user_id):
async with aiohttp.ClientSession() as session:
async with session.get(f"/api/users/{user_id}") as resp:
return await resp.json()Async Timing Decorator
def async_timing(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
start = time.perf_counter()
result = await func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.3f}s")
return result
return wrapperMetadata and Introspection
Preserve function metadata properly:
def decorator(func):
@functools.wraps(func) # copies __name__, __doc__, __module__, __qualname__
def wrapper(*args, **kwargs):
"""Wrapper doc"""
return func(*args, **kwargs)
return wrapper
@decorator
def example(a: int, b: str) -> bool:
"""Example docstring"""
return True
print(example.__name__) # "example" (not "wrapper")
print(example.__doc__) # "Example docstring" (not "Wrapper doc")
print(example.__annotations__) # {'a': int, 'b': str, 'return': bool}Decorators with State
Decorators can maintain mutable state across calls:
def memoize(func):
cache = {}
@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
wrapper.cache = cache # expose for inspection
wrapper.clear_cache = lambda: cache.clear()
return wrapper
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50))
fibonacci.clear_cache() # clear the cacheProduction Patterns
Circuit Breaker
class CircuitBreaker:
def __init__(self, failure_threshold=5, reset_timeout=30):
self.failure_threshold = failure_threshold
self.reset_timeout = reset_timeout
self.failures = 0
self.last_failure_time = None
self.state = "closed" # closed, open, half-open
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if self.state == "open":
if time.time() - self.last_failure_time > self.reset_timeout:
self.state = "half-open"
else:
raise Exception("Circuit breaker is open")
try:
result = func(*args, **kwargs)
if self.state == "half-open":
self.state = "closed"
self.failures = 0
return result
except Exception as e:
self.failures += 1
self.last_failure_time = time.time()
if self.failures >= self.failure_threshold:
self.state = "open"
raise
return wrapperObservable Decorator
def observable(event_name=None):
"""Emit metrics when the decorated function is called."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
result = func(*args, **kwargs)
metrics.counter(f"call.{func.__name__}.success").inc()
return result
except Exception as e:
metrics.counter(f"call.{func.__name__}.error").inc()
raise
finally:
metrics.timing(f"call.{func.__name__}.duration",
time.perf_counter() - start)
return wrapper
return decoratorStateful Decorators
Decorators can maintain state across calls using closures. A rate-limiting decorator tracks timestamps of recent calls and blocks execution if the limit is exceeded. A memoization decorator builds a cache dictionary that persists across invocations. The state is stored in the closure’s enclosing scope and is unique to each decorated function.
Decorator Use Cases in Web Frameworks
Web frameworks like Flask and FastAPI use decorators extensively for routing, middleware, and validation. Flask’s @app.route('/path') registers URL handlers. FastAPI’s @app.get('/items/{id}') adds automatic request parsing and OpenAPI documentation. Custom decorators add authentication checks, request logging, rate limiting, and response transformation. In Django, @login_required and @permission_required control access to views. These framework patterns demonstrate decorators as a mechanism for declarative metadata attachment — the decorator annotates the function with behavior that the framework discovers at registration time.
Summary
Advanced decorators provide configurable, reusable, and composable wrappers. Decorator factories handle arguments cleanly. Class-based decorators maintain state naturally. Async support requires special attention but follows the same patterns. functools.wraps preserves metadata. With these patterns, decorators become a powerful tool for cross-cutting concerns — caching, retry, rate limiting, circuit breaking, and observability — without coupling them to business logic.
Related: Python Decorators Guide | Python Generators Guide
Decorator Performance Profiling
Measure decorated function overhead using Python’s timeit module or a profiling decorator. A well-written decorator adds 50-200 nanoseconds per call. Nested decorators compound this overhead linearly. For hot paths called millions of times per second, consider inlining the decorator’s logic into the function body. Use functools.lru_cache or functools.cache for memoization — these are implemented in C and add minimal overhead. Profile decorated code with cProfile to identify whether decorator overhead is meaningful in your specific application.
Testing Decorators Isolated
Test decorators independently by applying them to simple stub functions and verifying behavior. For parameterized decorators, test with different argument combinations. Use unittest.mock to verify side effects — mock the decorated function, apply the decorator, and assert the mock was called with expected arguments. For decorators that modify return values, compare decorated vs undecorated output. Property-based testing with hypothesis helps find edge cases in decorator logic.
Decorator Anti-Patterns
Avoid common pitfalls: modifying function arguments in-place (causes subtle bugs), failing to preserve __wrapped__ for double-wrapping detection, using mutable default arguments in decorator factories, and decorating a class method without handling self. Always return the result of the decorated function — omitting return func(*args, **kwargs) causes hard-to-debug NoneType errors. Document whether your decorator changes function behavior (timing, caching) or adds side effects (logging, metrics) so users understand what to expect.
FAQ
When should I use a class-based decorator instead of a function-based one?
Use class-based decorators when you need to maintain complex mutable state (counters, timers, rate-limit buckets) or expose methods on the decorated function. Use function-based decorators for simple wrapping, parameterized decorators, and stateless transformations. Class-based decorators are more explicit about state but require more boilerplate.
How do I debug a decorated function?
Use functools.wraps to preserve function metadata. Set breakpoints inside the wrapper. Use inspect.signature to get the original function’s signature. For deep debugging, temporarily remove the decorator or add logging inside the wrapper. The @debug decorator pattern (printing args/kwargs) helps trace calls.
Can decorators affect performance?
Yes — each decorator adds a function call per invocation. For performance-critical code called millions of times, minimize decorator nesting depth. functools.lru_cache and functools.cache are optimized and add minimal overhead. Profile before and after adding decorators to measure impact.
How do I write a decorator that works with both sync and async functions?
Inspect the function with inspect.iscoroutinefunction(func) and return either a sync wrapper or an async wrapper. This pattern is used by libraries like pytest and fastapi to support both calling conventions transparently.
Decorators in CLI Applications
Libraries like Click and Typer use decorators for command-line interface definition. @click.command() registers a CLI command. @click.option('--name') adds arguments with automatic help text generation. Typer builds on this with type-annotated Python for fully typed CLI definitions. These frameworks demonstrate how decorators can move metadata (command name, options, help text) out of function bodies and into declarative annotations.<
Inspect the function with inspect.iscoroutinefunction(func) and return either a sync wrapper or an async wrapper. This pattern is used by libraries like pytest and fastapi to support both calling conventions transparently.