How to Write a Python Decorator with Arguments
python decorator with arguments: Learn the three-level factory pattern for writing a Python decorator with arguments, including closures, functools.wraps, class-based...
When you add arguments to a Python decorator, the callable structure gains an extra level of nesting. A plain decorator receives the target function directly. A decorator that accepts arguments must first return a decorator, and that returned decorator then produces the wrapper that replaces the original function. Understanding this three-layer arrangement is the core of working with python decorator with arguments.
The Three-Level Structure Required by Decorator Arguments
The canonical form of an argumented decorator is a factory function that returns a decorator, which in turn returns a wrapper:
import functools def retry(max_attempts): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_attempts): try: return func(*args, **kwargs) except Exception: if attempt == max_attempts - 1: raise return None return wrapper return decorator
The outer function retry is a decorator factory. It receives the configuration value max_attempts and returns decorator, which is a plain decorator. The inner wrapper is what actually executes when the decorated function is called. Without the middle layer, there is no place to bind the configuration value before the decorator sees the target function.
What Python Evaluates at Definition Time
When you write:
@retry(3) def fetch_data(): ...
Python performs two steps. First, it evaluates retry(3), which returns the decorator function. Second, it passes fetch_data to that returned decorator and binds the result back to the name fetch_data. The expression retry(3) is evaluated once, at module import time, not on every call. This means the configuration value is fixed when the module loads, and changing max_attempts later has no effect on the already-decorated function.
A Practical Example: Configurable Logging Decorator
A logging decorator with a configurable level demonstrates the pattern in a realistic setting:
import functools import time def log_duration(level="INFO"): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) duration = time.perf_counter() - start print(f"[{level}] {func.__name__} took {duration:.4f}s") return result return wrapper return decorator @log_duration(level="DEBUG") def compute(): ...
The level parameter is captured in the closure of decorator and remains accessible inside wrapper. This is the same closure mechanism that lets any nested function read variables from its enclosing scope. The decorator factory pattern simply uses that mechanism to carry configuration into the wrapper.
Preserving Metadata with functools.wraps
Without functools.wraps, the decorated function loses its original name, docstring, and signature. Tools that inspect functions—such as documentation generators, debuggers, and web frameworks that route by function name—will see wrapper instead of the original function. functools.wraps copies __name__, __doc__, __module__, and __qualname__ from the original function onto the wrapper, and it also updates __dict__ and __wrapped__. The __wrapped__ attribute is particularly useful because it lets inspect.signature() and other tools follow the chain back to the original callable.
Common Mistakes When Adding Arguments to Decorators
The most frequent error is omitting the middle layer. If you write:
def retry(func, max_attempts): ...
and then apply it with @retry(3), Python passes 3 as func and raises a TypeError when the decorator tries to call it. The decorator factory must be the outermost function, with the configuration parameters first and the target function received one level down.
Another common mistake is forgetting the parentheses. Applying @retry instead of @retry(3) passes the function itself to retry, which then treats the function as the configuration value. The result is a decorator that never gets applied, or a runtime error when the decorator is invoked.
Class-Based Decorators with Arguments
A class can serve the same purpose with a different structure:
import functools class Retry: def __init__(self, max_attempts): self.max_attempts = max_attempts def __call__(self, func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(self.max_attempts): try: return func(*args, **kwargs) except Exception: if attempt == self.max_attempts - 1: raise return None return wrapper
The __init__ method stores the configuration, and __call__ receives the function. This approach is useful when the decorator needs to maintain state across calls or when the configuration logic is complex enough to warrant methods. The tradeoff is that instances are slightly heavier than closures, and the code is more verbose for simple cases.
Runtime Cost and When to Avoid Argumented Decorators
Every call to a decorated function passes through wrapper, which adds a function call and a closure lookup. For functions called millions of times in a hot loop, this overhead is measurable, though usually small relative to the work the function performs. The decorator factory itself runs only once at import time, so its cost is negligible.
A more significant concern is that the wrapper hides the original function's signature from tools that do not use __wrapped__. If you are building a public API where callers rely on inspect.signature() to validate arguments, a decorator that does not preserve the signature can break that validation. In that case, either use functools.wraps and ensure the wrapper accepts *args, **kwargs, or avoid decorating functions whose signatures must remain visible.
When the configuration value is fixed and never varies per call site, a plain decorator without arguments is simpler and should be preferred. Add arguments only when different call sites genuinely need different behavior, such as different retry counts or different log levels.