Decorator Factories

Deep dive · part of Python Decorators

A decorator factory is a function that takes configuration and returns a decorator. It allows decorators to accept arguments at the call site: @retry(times=3) rather than @retry. The shape is three nested functions.

A decorator factory is a function that accepts configuration and returns a decorator: @retry(times=3) requires three nested functions (factory, decorator, wrapper). This pattern powers parameterized cross-cutting concerns—retries, logging levels, access control roles—without one-off copy-paste wrappers.

Mastering the factory shape clarifies how frameworks like pytest.mark.parametrize and click.option work: outer call captures config, middle call receives the target, inner call runs at invocation time with functools.wraps preserving metadata.

Call shape: factory(*factory_args) -> decorator -> decorator(target) -> wrapped callable.

Use functools.wraps on the innermost wrapper so __name__, __doc__, and signatures stay intact.

Factories can return decorators for both functions and classes depending on what the inner decorator accepts.

Default arguments on the factory (times=3) make decorators optional: @retry equals @retry().

Closure variables hold per-decoration configuration; avoid mutating shared lists unless intentional.

Type checkers need ParamSpec and TypeVar for accurate typing of decorator factories (3.10+).

When the factory is also meant to be used bare (@retry without parens), return a decorator that detects whether the first argument is callable—a common dual-mode trick, but explicit @retry() is clearer for readers.

Stack order matters: @a @b def f applies b first, then a. Factories that alter signatures should document interaction with other decorators.

For async functions, the wrapper must be async def and await the underlying coroutine; sync wrappers around async defs break await semantics silently at call time.

Omitting functools.wraps, breaking introspection and pytest fixture name matching.

Forgetting parentheses on factories (@retry times=3) which passes invalid arguments to the factory.

Capturing loop variables incorrectly in closures inside factories (classic late-binding bug).

Applying sync retry wrappers to async functions without await.

Keep factory parameter names explicit (times, delay, exc) and validate ranges in the factory layer.

Unit-test the wrapper with injected failures, not only the happy path.

Re-raise the last exception after retries with context chaining (raise ... from e).

Publish minimal examples in docstrings showing both @deco and @deco() usage.

Re-read the examples below with these ideas in mind; change variable names and inputs to match your own project.

The program below demonstrates retry on exception. Read the comments on each line, run the code, then change names or values to see how the output shifts.

# Example: Retry on exception
# Run in the REPL or save as a .py file and execute with python.
import time, functools, random

def retry(times=3, delay=0.1, exc=Exception):
    def deco(fn):
        @functools.wraps(fn)
        def wrap(*a, **kw):
            for i in range(times):
                try:
                    return fn(*a, **kw)
                except exc as e:
                    print(f"attempt {i+1} failed: {e}")
                    time.sleep(delay)
            raise RuntimeError("all retries failed")
        return wrap
    return deco

@retry(times=5, exc=ValueError)
def flaky():
    if random.random() < 0.7:
        raise ValueError("transient")
    return "ok"

print(flaky())

This sample walks through validate arg types in a small, runnable script. Paste it into the REPL or save it as a .py file before you continue to the next block.

# Example: Validate arg types
# Run in the REPL or save as a .py file and execute with python.
import functools

def typed(**spec):
    def deco(fn):
        @functools.wraps(fn)
        def wrap(*a, **kw):
            bound = dict(zip(fn.__code__.co_varnames, a)) | kw
            for name, t in spec.items():
                if name in bound and not isinstance(bound[name], t):
                    raise TypeError(f"{name} must be {t.__name__}")
            return fn(*a, **kw)
        return wrap
    return deco

@typed(n=int, msg=str)
def greet(n, msg="hi"):
    return msg * n

print(greet(3, msg="ha"))
# greet("oops")  -> TypeError

Here is a hands-on illustration of retry factory. Follow the inline comments first; only then execute the snippet and compare the result with what you expected.

# Three-level nesting: factory -> decorator -> wrapper
import time, functools  # deps

def retry(times=3, delay=0.1):  # factory with config
    def deco(fn):  # real decorator
        @functools.wraps(fn)  # preserve metadata
        def wrap(*a, **kw):  # wrapper
            for i in range(times):  # attempts
                try:  # call
                    return fn(*a, **kw)  # success path
                except Exception as e:  # failure
                    print(f"try {i+1}: {e}")  # log
                    time.sleep(delay)  # backoff
            raise RuntimeError("failed")  # exhausted
        return wrap
    return deco

@retry(times=2)  # configure
def flaky():  # demo
        raise ValueError("nope")  # always fails

The program below demonstrates typed factory. Read the comments on each line, run the code, then change names or values to see how the output shifts.

# Factory can enforce argument types at runtime
import functools  # wraps

def typed(**spec):  # name->type map
    def deco(fn):  # decorator
        @functools.wraps(fn)
        def wrap(*a, **kw):
            bound = dict(zip(fn.__code__.co_varnames, a)) | kw  # bind args
            for name, t in spec.items():  # each rule
                if name in bound and not isinstance(bound[name], t):  # mismatch
                    raise TypeError(f"{name} must be {t.__name__}")  # error
            return fn(*a, **kw)  # call through
        return wrap
    return deco

@typed(n=int)  # require int n
def double(n):  # function
    return n * 2  # body
print(double(4))  # 8

« back to Python Decorators All tutorials