Writing a Decorator That Preserves the Wrapped Signature
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.
# 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.
# 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.
# 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.
# 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.
# 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
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.
- Decorators with arguments: As in
@retry(3), the outer callable returns the decorator, so theCallable[[Callable[P, R]], Callable[P, R]]return type holds theParamSpec— declarePso it binds in the innerdecorate, never on the outer factory (which has no target to bind against). - Methods: On instance methods,
Pcaptures the parameters afterself; the descriptor protocol bindsselfseparately when the attribute is accessed, so you do not add it toConcatenate. The same holds forclassmethod/staticmethod— stack@classmethodoutermost so it wraps the already-decorated function, and letPcover the post-clsparameters. - Async functions: Type the return as
Callable[P, Awaitable[R]]and keep the wrapperasync;Pstill carries the parameters, andreturn await func(*args, **kwargs)type-checks because the awaited result isR. UsingCoroutine[Any, Any, R]is more precise if you need the send/throw types, butAwaitable[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.
- Returning
Callable[..., R]: The single most common bug — it silences all argument checks. Callers passing wrong types get no[arg-type]/reportArgumentType. ReturnCallable[P, R]. - Annotating only
*args: P.args:P.argsrequires a matching**kwargs: P.kwargson the same function. Omitting it raises mypy[valid-type]/ pyrightreportInvalidTypeVarUse— “ParamSpec ‘P’ is not allowed here; P.args and P.kwargs must be used together”. - Adding
selftoConcatenateon a method: The descriptor already bindsself; including it double-counts the first parameter and produces[arg-type]at every call. - Using
Pwhere a single type belongs:Pis not aTypeVar— it cannot appear aslist[P], as a lone return annotation, or as an ordinary type argument. Those raise mypy[misc]/ pyrightreportInvalidTypeVarUse. Keep a separate returnTypeVar R; forgetting it and typing the return asAnyquietly 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.