Back to Blog
Python

Python Decorator Factory: Parameterized Decorators

python decorator factory: Learn to build a Python decorator factory for parameterized decorators, preserve metadata with functools.wraps, and handle common edge cases.

PythonDecoratorsDecorator Factoryfunctools.wrapsMetaprogramming
Illustration of a Python decorator factory with nested functions and configuration parameters

A plain Python decorator receives the function it wraps, but it cannot accept its own configuration arguments. When you need a decorator that behaves differently based on parameters—like a retry count, a log level, or a cache TTL—you need a decorator factory. This article explains how a python decorator factory works, how to structure it correctly, and what to watch for when using it in production code.

Why a Decorator Factory Is Needed

A standard decorator is a function that takes a callable and returns a new callable. For example:

def uppercase(func): def wrapper(*args, **kwargs): result = func(*args, **kwargs) return result.upper() return wrapper

This works when the behavior is fixed. But suppose you want the decorator to accept a prefix or a suffix. You cannot pass arguments to @uppercase directly because uppercase is called with the function as its argument. To solve this, you create a factory that returns a decorator. The factory takes the configuration arguments, and the returned decorator applies them to the wrapped function.

Basic Structure of a Decorator Factory

The factory is a function that returns a decorator. The decorator, in turn, returns a wrapper. Here is a minimal pattern:

def prefix_factory(prefix): def decorator(func): def wrapper(*args, **kwargs): return prefix + func(*args, **kwargs) return wrapper return decorator

Usage:

@prefix_factory(">> ") def greet(name): return f"Hello, {name}"

When Python processes the @ syntax, it calls prefix_factory(">> ") first. That call returns decorator, which is then applied to greet. The resulting wrapper replaces greet. This three-level nesting is the core of a decorator factory.

The factory can accept any number of arguments, including keyword arguments. The decorator and wrapper layers can access those values via closure, which keeps the configuration available at call time.

Preserving Function Metadata with functools.wraps

The wrapper returned by the factory does not automatically preserve the original function's metadata—its __name__, __doc__, and __annotations__. This can break tools like debuggers, documentation generators, and test frameworks that rely on those attributes. The standard fix is to apply functools.wraps to the wrapper:

from functools import wraps def prefix_factory(prefix): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): return prefix + func(*args, **kwargs) return wrapper return decorator

functools.wraps copies the metadata from func to wrapper and also updates wrapper.__wrapped__ to point to the original function. This is especially important when the decorator is part of a public API or when you stack multiple decorators. Without it, the inner function's name becomes wrapper, which makes stack traces harder to read and breaks introspection.

Handling Arguments and Configuration Options

A decorator factory often needs to accept multiple configuration options. You can design the factory to take any mix of positional and keyword arguments. For example, a retry decorator might accept max_attempts and delay:

import time from functools import wraps def retry_factory(max_attempts, delay=0.1): def decorator(func): @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 time.sleep(delay) return wrapper return decorator

Use it as:

@retry_factory(max_attempts=3, delay=0.2) def fetch_data(): # network call pass

The closure captures max_attempts and delay when the factory runs, so each decorated function gets its own copy. This pattern is flexible because the factory can validate or preprocess arguments before returning the decorator.

One subtlety: if the factory accepts an argument that is itself a callable, you might accidentally pass the function directly. For example, @retry_factory without parentheses would pass the function as max_attempts. To avoid this, always call the factory with parentheses, even if no arguments are needed. Alternatively, you can design the factory to detect whether it received a function and return a decorator directly, but that adds complexity and is rarely worth it.

Common Pitfalls and Edge Cases

A frequent mistake is forgetting to call the factory. Writing @retry_factory instead of @retry_factory() passes the function as the first argument, which will likely cause a runtime error when the factory tries to use it as a number. The error message may be confusing, so it helps to add a type check in the factory if you want a clearer failure.

Another pitfall is the order of multiple decorators. Decorators apply bottom-up, meaning the one closest to the function runs first. When you stack a factory-based decorator with a plain decorator, the order determines which wrapper is called first. For example:

@log_factory(level="info") @uppercase def process(): return "data"

Here, uppercase wraps process first, then log_factory wraps the result. If you need the logging to see the original return value, the order matters. Always reason about the call chain from the inside out.

Edge cases also arise with class methods and static methods. A decorator factory that wraps a method must handle the self parameter correctly. Since the wrapper uses *args, **kwargs, it will receive self as the first positional argument, which is fine as long as you don't accidentally treat it as a configuration value. If you need to inspect the instance, you can access args[0] inside the wrapper, but that couples the decorator to a specific class structure.

Performance and Runtime Considerations

Each use of a decorator factory creates a new decorator and a new wrapper at decoration time. This adds a small amount of overhead when the module is imported, but it is negligible compared to the cost of the wrapped function's execution. However, if you decorate many functions with the same configuration, you can reuse the same decorator instance to avoid repeated closure creation:

_shared_decorator = retry_factory(max_attempts=3) @_shared_decorator def func_a(): ... @_shared_decorator def func_b(): ...

This reduces memory and import time slightly, but the difference is rarely measurable unless you have thousands of decorated functions. The bigger performance concern is what the wrapper does at call time. For example, a retry decorator that sleeps between attempts will dominate the runtime. The factory itself does not add meaningful overhead.

Another consideration is that functools.wraps adds a small cost when the decorator is applied, but it is a one-time operation. If you are building a decorator that will be used in a hot path, avoid doing heavy work inside the wrapper that could be done once at decoration time. For instance, if the decorator needs to compile a regular expression based on a configuration argument, compile it in the factory and capture the compiled pattern in the closure, not inside the wrapper.

When to Use a Decorator Factory vs Simpler Alternatives

A decorator factory is the right tool when you need to parameterize behavior that would otherwise be duplicated across several functions. If you only need a fixed transformation, a plain decorator is simpler and easier to read. If you need different behaviors based on configuration, the factory avoids repeating the same wrapper logic with slight variations.

Before reaching for a factory, consider whether a regular function that takes the function and configuration as arguments would be more explicit. For example, instead of decorating with @retry_factory(max_attempts=3), you could call retry(fetch_data, max_attempts=3) inside the function body. That approach is more explicit but loses the declarative style that decorators provide. The choice depends on whether the configuration is known at definition time or at call time. If the configuration changes per call, a decorator factory is the wrong fit; use a regular function or a context manager instead.

A factory is also useful when you want to enforce consistent behavior across a codebase, such as logging every public method of a class. You can apply the factory at the class level or to each method. In that case, the factory centralizes the logic and reduces the chance of inconsistent configuration.

The decorator factory pattern is a core Python metaprogramming technique. It appears in many popular libraries, from Flask's route decorators to pytest's fixtures. Understanding how to build one gives you the ability to create clean, reusable abstractions that fit your specific needs without pulling in a heavy dependency.

python decorator factory: Practical Usage and Code Examples | RYUSLOG DEV