Skip to content
Home
Advanced Python Decorators: Args, Classes, and Nesting

Advanced Python Decorators: Args, Classes, and Nesting

Python Python 8 min read 1531 words Beginner ExcellentWiki Editorial Team

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 greet

Class-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
        pass

Classmethod 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():
        pass

Order 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)))
    pass

Decorator 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):
    pass

Decorators 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 wrapper

Metadata 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 cache

Production 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 wrapper

Observable 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 decorator

Stateful 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.

Section: Python 1531 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top