Writing a Decorator That Preserves the Wrapped Signature

TL;DR

Type the decorator with a ParamSpec P and a return TypeVar R: take Callable[P, R], annotate the wrapper’s *args: P.args, **kwargs: P.kwargs, and return Callable[P, R]. A plain Callable[..., R] throws the parameter list away, so callers lose argument checks. Add functools.wraps for correct runtime metadata, and reach for Concatenate to inject or strip a leading argument.

Decorators like @timed or @retry wrap a function and return a new callable. If you type that callable as Callable[..., R], every caller of the decorated function stops being checked — the ... means “any arguments”. PEP 612 (Python 3.10) fixed this with ParamSpec and Concatenate, which let the wrapper re-expose the wrapped function’s exact Callable signature. This guide builds a signature-preserving @timed and a @retry, then shows the Concatenate variant, noting what mypy and pyright see at each step.

Step 1 — See why Callable[..., R] is wrong

Start with the naive form. It type-checks, but the decorated function accepts anything. The ... inside Callable[..., R] is not “no parameters” — Callable[[], R] is the zero-argument form. It is the gradual parameter list: a callable that is compatible with every argument list, positional or keyword, of any arity. It is the parameter-level analogue of Any: the return type R survives, so reveal_type(fetch("x", 1)) still shows bytes, but not one argument is validated.

How the Callable ellipsis erases the parameter list The url and retries parameters flow into a Callable ellipsis box that erases them, so any call is accepted, while the return type bytes is preserved on a separate path. url: str retries: int Callable[..., R] parameters erased fetch(123) accepted — no check -> bytes return preserved
The ... collapses every parameter into "any arguments"; only the return type R survives.
# Python 3.10+, mypy 1.x — the lossy version
from collections.abc import Callable
from typing import TypeVar

R = TypeVar("R")

def timed(func: Callable[..., R]) -> Callable[..., R]:   # `...` erases parameters
    def wrapper(*args, **kwargs) -> R:
        return func(*args, **kwargs)
    return wrapper

@timed
def fetch(url: str, retries: int) -> bytes: ...
fetch(123)                           # NO error — `...` accepts any args (the bug)

mypy and pyright stay silent on fetch(123) even though url should be a str and a second argument is missing. The signature was discarded. Crucially, neither mypy --strict nor strict mode rescues you here: ... is not spelled Any, so disallow_any_explicit never fires, and there is no error code for the erasure — it is legal, intentional gradual typing. The only strict-mode complaint you may see is [no-untyped-def] on the bare def wrapper(*args, **kwargs), because the wrapper’s own parameters are unannotated; that is a separate problem from the signature loss on fetch. pyright behaves the same, optionally emitting reportMissingParameterType on the wrapper but nothing on the miscall.

This is not a contrived footgun — before PEP 612 shipped in Python 3.10, Callable[..., R] was the only way to type a wrapper, so typed decorators genuinely could not forward a signature. Libraries worked around it with hand-written @overload stacks or .pyi stubs. Everything below exists to replace that lost precision.

Step 2 — Capture the signature with ParamSpec

Replace ... with a ParamSpec. Now the wrapper’s parameters are tied to the wrapped function’s: Callable[P, R] in the parameter position binds P to fetch’s complete parameter list — (url: str, retries: int), including keyword names and defaults — and returning Callable[P, R] re-applies that same list to the decorated result. The wrapper’s *args: P.args, **kwargs: P.kwargs is the only legal way to spell “exactly those parameters” at the value level.

ParamSpec P threaded through the wrapper The wrapped function's parameter list P flows into the wrapper annotated with P.args and P.kwargs, and out again as Callable of P and R with the same parameters. P = (url: str, retries: int) · R = bytes func: Callable[P, R] binds P wrapper(*args: P.args, **kwargs: P.kwargs) forwards P unchanged Callable[P, R] same signature
P is captured on the way in and re-exposed on the way out, so the checker sees the original parameters.
# Python 3.10+, mypy 1.x / pyright
from typing import ParamSpec, TypeVar

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

def timed(func: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return func(*args, **kwargs)
    return wrapper

@timed
def fetch(url: str, retries: int) -> bytes: ...
fetch(123, 2)                        # mypy error: [arg-type]
# pyright: reportArgumentType — argument "url" expects str, got int

The analyzer now checks calls through the decorator: 123 for url: str is rejected exactly as if the decorator were not there. mypy reports [arg-type]“Argument 1 to ‘fetch’ has incompatible type ‘int’; expected ‘str’” — while a missing or extra argument is [call-arg]. Keyword calls and defaults survive too: fetch(url="https://x", retries=2) and fetch("https://x") (relying on no default here would itself be [call-arg], since retries has none) behave just like the undecorated function. Running reveal_type(timed(fetch)) prints def (url: str, retries: int) -> bytes.

Declare P however your runtime allows. On Python 3.10+ import ParamSpec from typing; on 3.9 and earlier import it from typing_extensions — the semantics are identical. On Python 3.12+ you can skip the module-level object entirely with PEP 695 syntax, writing the ParamSpec inline with a double star:

# Python 3.12+, PEP 695 inline form
def timed[**P, R](func: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return func(*args, **kwargs)
    return wrapper

One subtlety: P is opaque. Inside the wrapper you may only call func(*args, **kwargs) — passing concrete arguments like func("x", 1) is rejected ([arg-type] / reportCallIssue), because the checker cannot see through P to know which literals are valid. P.args and P.kwargs are likewise inseparable: each is meaningless without the other, which is why they must annotate *args and **kwargs on the same function.

Step 3 — Add functools.wraps for runtime metadata

functools.wraps copies __name__, __doc__, and __wrapped__ onto the wrapper. It changes nothing about the static type, but frameworks and help() rely on it. Concretely, wraps (a thin wrapper over functools.update_wrapper) copies __module__, __name__, __qualname__, __annotations__, and __doc__ from the original onto your wrapper, merges __dict__, and sets wrapper.__wrapped__ = func. That last assignment is what lets inspect.signature(decorated) follow the chain (its default follow_wrapped=True) and report the real parameters at runtime.

Runtime metadata without versus with functools.wraps Without wraps the decorated name is wrapper and introspection is broken; with wraps the name is fetch and inspect.signature and __wrapped__ resolve correctly. without @wraps __name__ == "wrapper" __doc__ is None signature() shows *args Flask / Sphinx / logging break with @wraps(func) __name__ == "fetch" __doc__ copied over __wrapped__ = func signature() resolves correctly
The static type is identical either way; @wraps only fixes runtime introspection.
# Python 3.10+, mypy 1.x
import functools

def timed(func: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return func(*args, **kwargs)
    return wrapper

Applying @functools.wraps(func) does not disturb the static signature: in current typeshed (shipped with mypy 1.x and pyright) wraps is typed as an identity-preserving updater, so the wrapper keeps the P/R shape from Step 2. (Very old stubs once typed it as returning Callable[..., T], which erased the parameters — another reason to keep your type stubs current.) Omitting wraps leaves decorated.__name__ == "wrapper", which quietly breaks anything keyed on the function name: Flask and Click deduplicate view/command registrations by __name__ and raise on collision if every route ends up named wrapper; Sphinx autodoc, pytest fixtures, and structured logging all report the wrong identifier. There are no version constraints — functools.wraps predates the typing module entirely and works identically on every supported Python.

Step 4 — Apply the same shape to @retry

A @retry decorator that re-invokes on failure uses the identical Callable[P, R] -> Callable[P, R] shape; only the wrapper body differs. Because retry itself takes a configuration argument (attempts), it is a decorator factory: retry(attempts) must return a decorator, so the ParamSpec lives one level deeper, inside the inner decorate, not on retry. The outer return type is therefore Callable[[Callable[P, R]], Callable[P, R]] — a function that takes the target callable and gives back one with the same signature.

Retry control flow as a state machine Calling func either returns R on success or, on exception, checks whether attempts remain; if so it loops back to call func, otherwise it raises the last exception. call func(*args) raises? no -> return R attempts left? decrement yes -> retry no raise last exception success path returns R with the wrapped signature intact
The retry loop cycles until it succeeds or exhausts its attempts, then re-raises — all while P keeps the signature.
# Python 3.10+, mypy 1.x / pyright
def retry(attempts: int) -> Callable[[Callable[P, R]], Callable[P, R]]:
    def decorate(func: Callable[P, R]) -> Callable[P, R]:
        @functools.wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            last: Exception | None = None
            for _ in range(attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last = exc
            raise last  # type: ignore[misc]  # last is non-None after the loop
        return wrapper
    return decorate

Because retry takes an argument, the outer function returns a decorator, so the ParamSpec lives one level deeper — but the preservation pattern is unchanged. The # type: ignore[misc] marks a real limitation: the checker cannot prove the loop body runs at least once (if attempts <= 0 it never does), so last may still be None at the raise, and raising an Optional[Exception] is [misc] under mypy / reportGeneralTypeIssues under pyright. The cleaner fix is to narrow it explicitly with assert last is not None (or restructure to raise inside the loop on the final attempt), which removes the need to suppress anything.

The same skeleton extends to async targets — return Callable[P, Awaitable[R]], make the wrapper async def, and return await func(*args, **kwargs); P still carries the parameters. Real libraries lean on exactly this: tenacity’s typed @retry and backoff both forward a ParamSpec so your decorated coroutine keeps its argument checks. When retry wraps an overloaded function, be aware mypy collapses the result to the implementation signature while pyright preserves the overloads more faithfully.

Step 5 — Inject a leading argument with Concatenate

When the wrapper supplies an argument the caller should not pass, use Concatenate to strip it from the public signature. Concatenate[Session, P] reads as “a callable whose first positional parameter is a Session, followed by whatever is in P”. The wrapper injects open_session() as that leading argument, and by returning Callable[P, R]without the Session — it removes that parameter from what callers must supply.

Concatenate strips the injected leading argument The wrapped signature stacks Session on top of sql; the decorator injects Session, so the public signature that callers see contains only sql. wrapped signature session: Session Concatenate head sql: str (in P) inject open_session() drop the head public signature sql: str (only) run_query("SELECT 1") — Session supplied by the decorator
Concatenate subtracts the injected leading parameter, so the caller sees a shorter signature.
# Python 3.10+, mypy 1.x / pyright
from typing import Concatenate

def with_session(
    func: Callable[Concatenate[Session, P], R],
) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return func(open_session(), *args, **kwargs)
    return wrapper

@with_session
def run_query(session: Session, sql: str) -> list[Row]: ...
run_query("SELECT 1")                # session injected; only sql remains
Runtime vs static analysis None of this changes runtime behaviour. ParamSpec, Concatenate, and the P.args/P.kwargs annotations are erased at runtime — the wrapper still receives whatever Python passes it, and no argument checking happens during execution. They exist purely so mypy and pyright can re-impose the wrapped signature statically. functools.wraps is the only line here with a real runtime effect (copying metadata); the type annotations are advisory.

Concatenate only ever prepends positional parameters, and the injected leads become positional-only from the caller’s point of view once removed — you cannot Concatenate a keyword-only argument. You can inject more than one: Concatenate[Request, Config, P] supplies two leading values in order. If a caller mistakenly passes the injected argument, mypy reports [call-arg] (“Too many arguments”) or [arg-type], and pyright reportCallIssue. Import Concatenate from typing on 3.10+ and from typing_extensions on 3.9 and earlier; on 3.11+ the form Concatenate[int, ...] (a trailing ...) is also allowed to model “one known leading parameter, then an unknown rest”. Under PEP 695 you write the whole thing inline as def with_session[**P, R](func: Callable[Concatenate[Session, P], R]) -> Callable[P, R].

Edge cases

The Callable[P, R] -> Callable[P, R] skeleton covers most decorators, but three situations shift where P lives or what the wrapper returns. Knowing which is which prevents the checker from silently dropping — or double-counting — a parameter.

Where ParamSpec lives across three edge cases A table comparing decorator-with-arguments, method, and async decorators by their return type and where the ParamSpec is declared. case return type P lives in decorator with args Callable[P, R] inner decorate method Callable[P, R] P = params after self async Callable[P, Awaitable[R]] the decorator
Three edge cases differ only in their return type and where the ParamSpec is introduced.
  • Decorators with arguments: As in @retry(3), the outer callable returns the decorator, so the Callable[[Callable[P, R]], Callable[P, R]] return type holds the ParamSpec — declare P so it binds in the inner decorate, never on the outer factory (which has no target to bind against).
  • Methods: On instance methods, P captures the parameters after self; the descriptor protocol binds self separately when the attribute is accessed, so you do not add it to Concatenate. The same holds for classmethod/staticmethod — stack @classmethod outermost so it wraps the already-decorated function, and let P cover the post-cls parameters.
  • Async functions: Type the return as Callable[P, Awaitable[R]] and keep the wrapper async; P still carries the parameters, and return await func(*args, **kwargs) type-checks because the awaited result is R. Using Coroutine[Any, Any, R] is more precise if you need the send/throw types, but Awaitable[R] is usually enough.

Common mistakes

Almost every “my decorator stopped checking arguments” report traces back to one of a handful of annotation slips. Walk the decision points below — the fix is nearly always to re-thread P correctly rather than to reach for # type: ignore.

Diagnosing lost argument checks From the symptom of lost argument checks, three questions branch to their fixes: return Callable of P and R, pair P.args with P.kwargs, and remove self from Concatenate. lost arg checks? check three things returned Callable[..., R]? only *args: P.args? [valid-type] self in Concatenate? on a method return Callable[P, R] add **kwargs: P.kwargs remove self
Three questions isolate the cause; each has a one-line fix that restores checking.
  • Returning Callable[..., R]: The single most common bug — it silences all argument checks. Callers passing wrong types get no [arg-type] / reportArgumentType. Return Callable[P, R].
  • Annotating only *args: P.args: P.args requires a matching **kwargs: P.kwargs on the same function. Omitting it raises mypy [valid-type] / pyright reportInvalidTypeVarUse“ParamSpec ‘P’ is not allowed here; P.args and P.kwargs must be used together”.
  • Adding self to Concatenate on a method: The descriptor already binds self; including it double-counts the first parameter and produces [arg-type] at every call.
  • Using P where a single type belongs: P is not a TypeVar — it cannot appear as list[P], as a lone return annotation, or as an ordinary type argument. Those raise mypy [misc] / pyright reportInvalidTypeVarUse. Keep a separate return TypeVar R; forgetting it and typing the return as Any quietly reintroduces the erasure you were trying to avoid.

FAQ

Does functools.wraps help the type checker? No — it only copies runtime metadata. The static signature comes entirely from ParamSpec. Keep both: wraps for runtime correctness, ParamSpec for static correctness.

Why does my decorated method lose self? You probably typed the decorator with Callable[..., R] or added self into Concatenate. Use Callable[P, R] and let P capture the post-self parameters.

Back to ParamSpec and Concatenate