Back to Blog
Python

Python Parameterized Decorator: Syntax and Usage

python parameterized decorator: Learn how to write a Python parameterized decorator using the three-layer nesting pattern, preserve metadata with functools.wraps, and...

decoratorsclosuresfunctoolsfunction-wrappingcode-design
Three nested layers of a Python parameterized decorator wrapping a function, with configuration arguments flowing through each layer.

The python parameterized decorator — a decorator that accepts its own arguments, such as @retry(attempts=3) — requires one more nesting level than a plain decorator. The difference is easy to miss at first: a plain decorator receives a function and returns a function, while a parameterized decorator receives the configuration arguments first, then returns a decorator that receives the function.

Why a Parameterized Decorator Needs Three Layers

A plain decorator is a single callable that takes a function and returns a replacement:

def log_calls(func): def wrapper(*args, **kwargs): print(f"Calling {func.__name__}") return func(*args, **kwargs) return wrapper

When you write @log_calls, Python calls log_calls with the function below it and replaces that function with the returned wrapper.

A parameterized decorator changes the contract. When you write @retry(attempts=3), Python evaluates retry(attempts=3) first. That call must return a decorator — a callable that accepts the function. That returned decorator then receives the function and returns the replacement. The extra layer exists because the configuration arguments must be captured before the function is even known.

Building a Minimal Parameterized Decorator

The canonical structure is three nested functions:

def retry(attempts=3): def decorator(func): def wrapper(*args, **kwargs): for i in range(attempts): try: return func(*args, **kwargs) except Exception: if i == attempts - 1: raise return None return wrapper return decorator

retry is the outermost function. It receives the configuration value attempts and returns decorator. The decorator function receives the original function and returns wrapper. The wrapper function receives the actual call arguments and contains the retry logic.

Usage looks identical to a plain decorator from the caller's perspective:

@retry(attempts=5) def fetch_data(): ...

The closure over attempts is what makes this work. wrapper can reference attempts because it is defined inside retry's scope, even though retry has already returned by the time wrapper runs.

How the Layers Interact at Call Time

The evaluation order matters. When Python sees @retry(attempts=5), it:

  1. Calls retry(attempts=5), which returns the decorator function.
  2. Calls decorator(fetch_data), which returns wrapper.
  3. Binds the name fetch_data to wrapper.

Every subsequent call to fetch_data() invokes wrapper, which calls the original fetch_data through the closure. The original function is never lost — it is held in decorator's closure and referenced by wrapper.

This evaluation order explains a common source of confusion: the outer function runs at decoration time, not at call time. If you put expensive setup logic in retry itself, it runs once when the module is imported. Logic that must run on every invocation belongs in wrapper.

Preserving Metadata with functools.wraps

Without intervention, wrapper replaces the original function's name and docstring. Tools that inspect function metadata — such as documentation generators, debuggers, and web frameworks that derive route names from function names — will see wrapper instead of fetch_data.

Applying functools.wraps copies __name__, __doc__, __module__, and other attributes from the original function to the wrapper:

import functools def retry(attempts=3): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for i in range(attempts): try: return func(*args, **kwargs) except Exception: if i == attempts - 1: raise return None return wrapper return decorator

The @functools.wraps(func) line sits directly above wrapper inside decorator. This is the same placement as in a plain decorator; the parameterized structure does not change where wraps goes.

Common Mistakes with Parameterized Decorators

The most frequent error is forgetting the middle layer. Writing a single function that tries to handle both @retry and @retry(attempts=3) with default arguments and branching leads to subtle failure modes. The three-layer structure is simpler to reason about and should be the default.

A second mistake is using a mutable default argument for configuration. If the decorator stores per-call state in a list or dictionary default, that state is shared across all decorated functions. Configuration values should be treated as immutable after decoration.

A third mistake is swallowing exceptions too broadly. The retry example above catches Exception, which includes programming errors like TypeError and AttributeError. Retrying those usually makes the problem worse. Catching a specific exception type, such as a timeout exception from an HTTP client library, is usually the right call.

Using a Class Instead of Nested Functions

A class with __call__ can replace the outer function and the decorator layer:

import functools class retry: def __init__(self, attempts=3): self.attempts = attempts def __call__(self, func): @functools.wraps(func) def wrapper(*args, **kwargs): for i in range(self.attempts): try: return func(*args, **kwargs) except Exception: if i == self.attempts - 1: raise return None return wrapper

The class approach keeps configuration state in instance attributes and can be easier to extend — for example, adding a delay parameter or a logger parameter without deepening the nesting. The tradeoff is that the control flow is less explicit than the nested-function version, and some developers find the function-based form easier to read.

When Parameterized Decorators Become Hard to Maintain

Three levels of nesting is manageable, but adding more configuration parameters increases cognitive load. If a decorator needs more than a few options, consider moving the logic into a helper class and keeping the decorator as a thin adapter. The decorator's job is to capture configuration and wrap the function; the actual behavior belongs in a separate, testable unit.

Parameterized decorators also interact with type checking. Static type checkers such as mypy can struggle to infer the signature of the wrapped function. Using functools.wraps helps, but for fully typed code, explicit Callable annotations on all three layers may be necessary. If the decorated function's signature must be preserved for type checking, annotate each layer's parameters and return types rather than relying on inference.

A parameterized decorator is ultimately a factory that produces a decorator. Keeping that relationship explicit — three layers, each with one responsibility — makes the pattern predictable and easy to debug when a wrapped function misbehaves.

python parameterized decorator: Practical Usage and Code Exa | RYUSLOG DEV