ParamSpec & Concatenate: Typing Signature-Preserving Callables
ParamSpec and Concatenate, introduced by PEP 612 in Python 3.10, let you capture and forward the
entire parameter list of a callable — every positional, keyword, and default — instead of
collapsing it to .... They are the tools that make a decorator’s wrapper share the exact
Callable signature of the function it wraps, so
callers still get argument checks and IDE completion through the decorator. This guide covers
ParamSpec, its P.args/P.kwargs members, Concatenate for adding or removing a leading argument,
and how mypy and pyright check them. For the wider context see
Advanced Typing Patterns & Generics.
ParamSpec carries the parameter list and a return TypeVar carries R, so the wrapper looks identical to the original.Syntax spec: ParamSpec with a return TypeVar
A signature-preserving decorator needs two parameters: a ParamSpec P for the callable’s
arguments and a TypeVar R for its
return type. The wrapper annotates its variadic parameters with P.args and P.kwargs.
# Python 3.10+, mypy 1.x / pyright
from collections.abc import Callable
from typing import ParamSpec, TypeVar
import functools
P = ParamSpec("P")
R = TypeVar("R")
def trace(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
@trace
def process_payload(name: str, retries: int = 3) -> bytes: ...
process_payload("job", retries=2) # fully checked through the decorator
On Python 3.12+ you can declare P inline with the
PEP 695 syntax: def trace[**P, R](func: Callable[P, R]) -> Callable[P, R].
P.args and P.kwargs are a matched pair
P.args and P.kwargs are not standalone types — they are the two halves of one parameter
specification. They may appear only as the annotations of *args and **kwargs on the same
function, and you must use both together.
# Python 3.10+, mypy 1.x
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: ... # correct
def broken(*args: P.args) -> R: ... # mypy error: [valid-type] / [misc]
# P.args cannot be used without a matching **kwargs: P.kwargs
Concatenate: adding or stripping a leading argument
Concatenate[X, P] means “a callable whose first parameter is X, followed by the parameters in
P”. Use it when the decorator injects an argument the wrapped function does not see, or strips one
the caller does not supply.
# Python 3.10+, mypy 1.x / pyright
from typing import Concatenate
# The wrapped function expects a ServiceConfig first; the decorator supplies it,
# so the decorated callable no longer takes that argument.
def with_config(
func: Callable[Concatenate[ServiceConfig, P], R],
) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(load_config(), *args, **kwargs)
return wrapper
@with_config
def handle(config: ServiceConfig, request_id: str) -> bytes: ...
handle("req-1") # config injected; only request_id remains
Concatenate always pins the leading types as positional. The trailing P still carries the rest of
the signature unchanged.
Analyzer behaviour
mypy
mypy implements PEP 612 fully and is strict about the placement rules. A P.args without its
P.kwargs partner, or a ParamSpec used somewhere it is not allowed (e.g. as a normal type
argument), is reported as [misc] or [valid-type]. A Concatenate whose positional arguments do
not line up at a call site is [arg-type]. Running under
mypy strict mode surfaces a
decorator that silently widens its return type.
# Python 3.10+, mypy 1.x
def bad(func: Callable[P, R]) -> Callable[..., R]: # drops P → loses arg checks
... # mypy strict flags the `...` widening
pyright
pyright also implements PEP 612 natively. Misusing ParamSpec — for instance binding P.args
without P.kwargs, or putting a ParamSpec in a position that requires an ordinary type — is
reportInvalidTypeVarUse or reportGeneralTypeIssues. A Concatenate argument-count or type
mismatch at a call is reportArgumentType. The two checkers agree on the common cases; remaining
divergences are tracked in
pyright vs mypy.
Strictness tuning
If a third-party decorator is typed as Callable[..., R] and you cannot fix it, scope the resulting
argument-check loss to that module rather than disabling it globally:
# pyproject.toml — tolerate an untyped vendor decorator in one module
[[tool.mypy.overrides]]
module = "integrations.vendor_hooks"
disable_error_code = ["arg-type"]
Debugging false positives
If callers lose argument checking after adding your decorator, the usual cause is a return annotation
of Callable[..., R] instead of Callable[P, R] — the ... erases the parameter list on purpose.
Re-thread P through both the input and output annotations and the checks return. Combining
ParamSpec with @overload lets a single
decorator preserve several distinct signatures.
Common pitfalls
- Returning
Callable[..., R]: This discards the captured parameters; callers stop getting[arg-type]errors they should get. ReturnCallable[P, R]. - Using
P.argsalone: It must be paired with**kwargs: P.kwargson the same function — mypy raises[valid-type]otherwise. - Forgetting
functools.wraps: It does not affect static types, but without it__name__and__doc__are wrong at runtime, breaking introspection and some frameworks. - Treating
ParamSpeclike aTypeVar:Pcannot stand in for a single type argument; the return type still needs its ownTypeVar R.
FAQ
When do I need ParamSpec instead of a plain TypeVar?
Whenever you must preserve a callable’s parameter list. A TypeVar captures one type; ParamSpec
captures the whole signature so the wrapper accepts exactly the same arguments.
What does Concatenate add over ParamSpec alone?
It models decorators that inject or remove a leading positional argument, so the decorated callable’s
signature differs from the wrapped one by exactly those leading parameters.
Can I use these before Python 3.10?
Yes — import ParamSpec and Concatenate from typing_extensions on 3.9 and earlier; the semantics
are identical.