Python Decorators with Parameters

Learn how to build Python decorators that accept their own arguments by adding a factory function layer, enabling configurable behaviour like custom log levels and retry counts.

8 min read

Python decorators with parameters solve a limitation of the decorators built in the previous articles: a timer that always prints to the console and a logger that always uses the same format are useful but inflexible. What if you want the timer to log only when a function takes longer than a threshold, or to send its output to a file instead of the console? What if you want the logger to include a custom prefix or to use a specific logging level? You could write a separate decorator for each variation, but that leads to a proliferation of nearly identical functions that differ only in hardcoded values. The better solution is a decorator that accepts its own arguments, so the caller can configure its behaviour at the decoration site. Writing a parametrized timer above a function is more expressive than writing a plain timer and having the threshold buried inside the decorator definition.

A decorator with parameters is not a decorator in the strict sense. It is a function that returns a decorator. Python calls this outer function a decorator factory. When you write a decorator call with parentheses and arguments, Python evaluates the factory with those arguments, which must return a callable (the actual decorator), and then applies that callable to the function being defined. The syntax requires one extra layer of function nesting compared to a plain decorator: the outermost function receives the configuration parameters and returns the decorator, the middle function receives the function being decorated and returns the wrapper, and the innermost function is the wrapper that runs on every call.

The three-layer structure can feel abstract until you see it in code. Understanding why the extra layer exists is the key: a plain decorator receives one argument (the function) and the at-syntax supplies that argument automatically. A parametrized decorator receives configuration arguments first, and those must come from the parentheses after the at-sign. The parentheses trigger a function call, and the return value of that call becomes the decorator that Python applies to the function. This is why the at-syntax without parentheses works when the target is a decorator, but the same syntax with parentheses works when the target is a decorator factory.

The decorator factory pattern

A decorator factory has the following structure. The outermost function accepts the configuration parameters. Inside it, you define the actual decorator function, which accepts the function to be decorated. Inside that, you define the wrapper, which accepts the runtime arguments. The outermost function returns the decorator, and the decorator returns the wrapper. Here is a parametrized timer that accepts a threshold and a destination file:

pythonpython
import time
 
def timer(threshold=0.0, output=None):
    def decorator(func):
        def wrapper(*args, **kwargs):
            start = time.perf_counter()
            result = func(*args, **kwargs)
            elapsed = time.perf_counter() - start
            if elapsed >= threshold:
                message = f"{func.__name__} ran in {elapsed:.4f}s"
                if output:
                    with open(output, "a") as f:
                        f.write(message + "\n")
                else:
                    print(message)
            return result
        return wrapper
    return decorator

Walking through this from the outside in reveals how each layer contributes to the final behaviour. The timer function is the factory. It accepts threshold, which controls whether a timing message is emitted at all, and output, which specifies a file path for writing messages instead of printing them. Both parameters have sensible defaults: a threshold of zero means every call is reported, and an output of None means messages go to the console.

Inside the timer factory, the decorator function is the actual decorator. It receives the function being decorated, exactly as a plain decorator would. Because the decorator is defined inside the timer factory, it has access to threshold and output through closure. These values were fixed when the factory ran at definition time, and they remain available to the wrapper on every call. Inside the decorator, the wrapper function is identical in structure to the wrappers from creating your first Python decorator: it accepts star-args and double-star-kwargs, calls the original function, captures the result, and returns it. The difference is that the wrapper now checks whether the elapsed time exceeds the threshold before emitting a message, and it writes to a file instead of printing when an output path is set.

The usage is straightforward. To time every call of a function with a message printed to the console, you write the at-timer syntax with empty parentheses or without them if the factory handles both. To time only slow calls that take more than half a second, you pass a threshold value. To send timing data to a log file, you pass both a threshold and an output path. The configuration is visible at the decoration site, and changing the behaviour of multiple functions requires changing only their decorator arguments, not the decorator implementation.

A parametrized retry decorator

Another common use case for parametrized decorators is retry logic. A function that makes a network request or accesses a resource that might be temporarily unavailable benefits from being retried a few times before giving up. The number of retries, the delay between attempts, and which exceptions trigger a retry are all configuration values that belong in decorator parameters. Here is a parametrized retry decorator:

pythonpython
import time
import functools
 
def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_exception = e
                    if attempt < max_attempts - 1:
                        time.sleep(delay)
            raise last_exception
        return wrapper
    return decorator

This decorator factory accepts max_attempts (default 3), delay in seconds between attempts (default 1.0), and a tuple of exception types that trigger a retry (default all exceptions). The wrapper loops up to max_attempts times, calling the original function inside a try block. If the call succeeds, the result is returned immediately and the loop exits. If the call raises an exception that matches the configured types, the wrapper catches it, sleeps for the delay duration if there are attempts remaining, and tries again. If all attempts are exhausted, the wrapper re-raises the last exception so the caller can handle it.

The functools.wraps line is important here and in any decorator that replaces one function with another. Without it, the decorated function's metadata (its name, docstring, and other attributes) would reflect the wrapper rather than the original function, which makes debugging and introspection harder. The wraps decorator copies the original function's metadata onto the wrapper, so help calls and name lookups still return the expected values. Applying wraps is a best practice for every decorator, but it is especially important for parametrized decorators that are likely to be used broadly and debugged by developers who rely on function metadata.

Building a decorator that works with and without parentheses

Some decorators in the Python ecosystem, like the lru_cache decorator from functools and the dataclass decorator, work both with and without parentheses. You can write the lru-cache decorator above a function to get default caching, or pass a maxsize argument to customize the cache size. This dual-use pattern requires the factory to detect whether it was called with a function (no parentheses) or with configuration arguments (with parentheses). Here is a parametrized logger that supports both forms:

pythonpython
def log(prefix=None):
    if callable(prefix):
        return log()(prefix)
 
    def decorator(func):
        def wrapper(*args, **kwargs):
            label = f"[{prefix}] " if prefix else ""
            print(f"{label}Calling {func.__name__}")
            return func(*args, **kwargs)
        return wrapper
    return decorator

The factory checks whether its first argument is a callable. If it is, the factory was used without parentheses, and the argument is actually the function to be decorated. The factory handles this case by calling itself with no arguments to build a default decorator, then applying that decorator to the function immediately. If the argument is not a callable, the factory was used with parentheses, and the normal three-layer structure applies. This pattern adds branching complexity to the factory, so it should only be used when the dual-use convenience is worth the added cognitive load. Most internal decorators are fine with requiring parentheses.

The detection logic can become more nuanced when the configuration parameter itself could be a callable. If your decorator accepts a callback function as a parameter, checking whether the argument is callable would confuse a passed callback with the no-parentheses case. In such situations, it is safer to require parentheses and avoid the ambiguity entirely. The Python standard library's dataclass decorator uses a more sophisticated approach with sentinel objects to distinguish between the two calling conventions, but the complexity is significant and rarely worth replicating in project-level decorators.

Nesting and readability

The three-layer nesting of a parametrized decorator is the deepest nesting most Python code reaches, and it can strain readability if the layers are not named clearly. The convention of naming the outermost function after the decorator's purpose (timer, retry, log), the middle function decorator, and the innermost function wrapper helps readers identify which layer does what. Each function should be short, and the wrapper should contain the runtime logic while the outer layers handle configuration and setup.

If a parametrized decorator becomes too complex, consider whether the configuration logic can be moved into a class-based decorator. A class that defines the call method can serve as a decorator, and its init method receives the configuration parameters while the call method receives the function. Class-based decorators trade the three-layer function nesting for a class with two methods, which some developers find more readable for decorators with many configuration options or complex state management.

The parametrized decorators you have built in this article, a configurable timer, a retry mechanism, and a flexible logger, are all production-ready patterns that appear in real Python codebases every day. Here is how you might use the timer in practice:

pythonpython
@timer(threshold=0.1, output="performance.log")
def process_batch(items):
    results = []
    for item in items:
        results.append(transform(item))
    return results

With the ability to create decorators that accept parameters, you can build a library of reusable behaviour wrappers that adapt to different contexts without duplicating code. The next article in the series covers preserving function metadata with functools.wraps in detail, explaining why it matters for debugging, documentation, and introspection tools.

Rune AI

Rune AI

Key Insights

  • A parametrized decorator is a function that returns a decorator, creating a three-layer structure: factory, decorator, wrapper.
  • The @decorator(arg) syntax calls the factory with arg, which returns the actual decorator, which then wraps the function.
  • Common parametrized decorators include configurable loggers, retry decorators with custom counts, and timers with custom thresholds.
  • A decorator that works both with and without parentheses must detect whether it received a callable or a configuration value.
  • The functools.wraps decorator should be applied inside the innermost decorator layer to preserve the original function's metadata.
RunePowered by Rune AI

Frequently Asked Questions

How do I pass arguments to a Python decorator?

You pass arguments to a decorator by adding an extra layer of function nesting. Instead of writing a function that takes func and returns a wrapper, you write a function that takes your configuration arguments and returns a decorator. The @decorator(arg) syntax calls the outer function with the arguments, which returns the actual decorator, which then receives the function. This pattern is called a decorator factory.

What is the difference between @decorator and @decorator()?

@decorator applies a decorator directly, passing the defined function to it. @decorator() calls the decorator factory with no arguments, which returns a decorator, which is then applied to the defined function. The two forms are equivalent when the factory handles the no-argument case by detecting whether it received a callable or not. Some decorators are designed to work both with and without parentheses.

Conclusion

Building decorators that accept parameters adds a layer of nesting, turning a simple decorator into a decorator factory, but the extra layer buys a significant increase in flexibility. A single parametrized decorator can replace an entire family of similar decorators, and the configuration lives at the decoration site where it is visible and easy to adjust. The three-layer structure (outer factory receives config, middle decorator receives func, inner wrapper receives args) is the template for every parametrized decorator you will write.