Skip to content
← All articles

wraps: the eight attributes a decorator has to carry

A fifteen-minute recap of the metadata functools.wraps copies, why __wrapped__ is the one that makes introspection work, and what ParamSpec adds on top.

A decorator replaces a function object with a different function object. Every piece of metadata that pointed at the original now points at the wrapper — which is why an undecorated wrapper turns help(fn) into “wrapper(args, *kwargs)”, breaks pydoc, breaks any framework that routes on fn.__name__, and breaks inspect.signature.

functools.wraps copies the metadata across. The full list it handles:

Attribute Why it matters
__name__ logging, error messages, framework routing
__qualname__ tracebacks and repr
__doc__ help(), pydoc, doctests
__module__ pickling by reference, pydoc
__dict__ attributes other decorators attached (updated, not replaced)
__annotations__ anything reading types at runtime
__type_params__ PEP 695 type parameters — added to the list in 3.12
__wrapped__ set to the original, so introspection can unwrap

The first six are copied (__dict__ is updated, so several stacked decorators accumulate rather than clobber). __wrapped__ is the one wraps adds, and it is the one that makes the rest work properly: inspect.signature follows __wrapped__ by default and reports the original signature, not the wrapper’s (*args, **kwargs).

from functools import wraps

def logged(fn: Callable[P, R]) -> Callable[P, R]:
    @wraps(fn)
    def inner(*args: P.args, **kwargs: P.kwargs) -> R:
        return fn(*args, **kwargs)
    return inner
💡inspect.signature follows __wrapped__. When is that the wrong click to reveal

answer? When the decorator genuinely changes the signature. A decorator that injects a dependency (@inject_db turning f(db, x) into f(x)) or one that adds a parameter reports the pre-decoration signature, which is now a lie — and frameworks that build call sites from signature() (dependency injectors, CLI generators, web routers, pytest fixtures) will construct the wrong call.

Two ways out. Pass follow_wrapped=False to inspect.signature at the point where you want the wrapper’s real shape. Or, in the decorator, set inner.__signature__ explicitly to the signature you actually expose — signature() prefers __signature__ over unwrapping.

The general rule: use wraps when the wrapper is signature-preserving (which is most of the time), and take responsibility for __signature__ when it is not.

What ParamSpec adds

wraps fixes runtime introspection. It does nothing for the type checker — Callable[..., Any] in and out means every call site of the decorated function stops being checked, and the return type degrades to Any, which then propagates into the caller.

ParamSpec (PEP 612) fixes the static half:

P = ParamSpec("P")
R = TypeVar("R")

def logged(fn: Callable[P, R]) -> Callable[P, R]: ...

P captures the entire parameter list — positional, keyword, defaults, *args, **kwargs — and replays it on the wrapper, so calls to the decorated function are checked exactly as if the decorator were not there.

The two are complementary and you want both on essentially every decorator: wraps for the runtime metadata, ParamSpec for the static signature. See item 2.11 for the full treatment, including Concatenate for decorators that add or remove leading parameters.

💡Your decorator adds a keyword-only retries parameter to the click to reveal

functions it wraps. Callable[P, R] -> Callable[P, R] is now wrong. What does the type system offer? Not much, and it is worth knowing where the edge is. Concatenate handles parameters added or removed at the front, positionally:

def with_db(fn: Callable[Concatenate[Connection, P], R]) -> Callable[P, R]: ...

That is exactly right for a decorator that supplies a leading argument. There is no equivalent for adding a keyword-only parameter — PEP 612 has no syntax for “P plus one more keyword”, and the usual workaround is an explicit Protocol with a __call__ that spells out the new shape, which gives up genericity over the wrapped signature.

The practical consequence is a design nudge: decorators that add keyword options are hard to type, so prefer configuring the decorator itself (@retry(times=3)) over having it inject an option into the wrapped function’s call signature. The typeable design is usually also the clearer one.