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.
Syntax spec: ParamSpec with a return TypeVar
A signature-preserving decorator needs two independent type variables: a ParamSpec P that stands
for the callable’s entire parameter list and an ordinary
TypeVar R for its return type. The
split matters — a ParamSpec only ever models parameters, never a return, so you cannot fold the two
into one variable. The decorator takes Callable[P, R], the wrapper annotates its variadics with
P.args and P.kwargs, and the decorator returns Callable[P, R] so the decorated object presents
the same call interface.
# 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
The name passed to ParamSpec("P") must match the variable it is bound to, exactly as with TypeVar;
mypy raises [misc] on a mismatch. A ParamSpec accepts none of the extra keywords a TypeVar
takes — there is no bound= and no constraints, because a parameter list is not a single type. It
can take a default under PEP 696
(ParamSpec("P", default=...)), which is available natively in Python 3.13 and via
typing_extensions earlier.
On Python 3.12+ you can declare P inline with the
PEP 695 syntax using the
double-star spelling for a ParamSpec, dropping both module-level declarations entirely:
# Python 3.12+, mypy 1.x / pyright — inline ParamSpec with **P
from collections.abc import Callable
import functools
def trace[**P, R](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
Before Python 3.10 the names live in typing_extensions with identical semantics: from typing_extensions import ParamSpec back to Python 3.7. The runtime object created by ParamSpec("P")
is introspectable — a decorated generic exposes it through __type_params__ on 3.12+ — but like all
annotations it imposes no runtime checking.
What P captures is the whole calling convention, not just a flat list of types. Positional-only
parameters (those before a /), keyword-only parameters (those after a bare *), and every default
value are all preserved, so a wrapped def f(a: int, /, b: str, *, c: bool = False) remains callable
through the decorator exactly as written — f(1, "x") and f(1, b="x", c=True) both type-check, and
f(a=1, b="x") is still rejected because a is positional-only. This fidelity is the whole point:
Callable[..., R] would accept any of those calls indiscriminately, whereas Callable[P, R] reproduces
the wrapped function’s arity, ordering, and keyword rules. The one thing P cannot do is let you
name an individual parameter — it is opaque, an all-or-nothing capture — which is why
Concatenate exists to peel off a leading
argument you do want to name.
P.args and P.kwargs are a matched pair
P.args and P.kwargs are not standalone types you can use anywhere — they are the two halves of a
single parameter specification, and the type system treats them as an indivisible pair. They may
appear only as the annotations of *args and **kwargs on the same function, and both must be
present. Using one without the other, or attaching them to a normal parameter, is rejected by both
checkers because a half-specified parameter list has no meaning.
# 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]
# "The variance of a ParamSpec cannot be split": P.args needs **kwargs: P.kwargs
pyright reports the same lone-P.args misuse as reportInvalidTypeVarUse. Other illegal placements
are caught too: annotating a regular parameter as x: P.args, or swapping them so *args: P.kwargs,
produces [valid-type] in mypy and reportGeneralTypeIssues in pyright. The ordering is fixed —
P.args belongs on *args, P.kwargs on **kwargs — because they model the positional and keyword
halves of the same call respectively.
There is one important consequence for how you call through such a wrapper. Inside the body, args
has the opaque type P.args and kwargs the type P.kwargs; you can forward them as
func(*args, **kwargs) but you cannot index args[0] or read kwargs["name"] and expect the
element type to be known — the checker only guarantees the tuple/dict as a whole reconstitutes P.
This is deliberate: it keeps the parameter list an atomic unit so the wrapper cannot silently reorder
or drop arguments. If you need to inspect individual arguments with types, capture them explicitly
rather than through *args: P.args.
Concatenate: adding or stripping a leading argument
Concatenate[X, P] means “a callable whose first parameter is X, followed by the parameters in
P”. It is the escape hatch for decorators whose public signature differs from the wrapped one by a
fixed number of leading positional arguments — the decorator either injects an argument the wrapped
function needs (so it disappears from the caller’s view) or demands one the wrapped function does not
declare. Everything after the concatenated prefix travels unchanged inside P.
# 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 prefix types as positional parameters — they cannot be passed by
keyword through the boundary, which is why the injected load_config() value is forwarded first,
before *args. There are two firm placement rules the checkers enforce. First, a ParamSpec must be
the last element: Concatenate[X, P] is valid, Concatenate[P, X] is not (mypy [valid-type],
pyright reportGeneralTypeIssues). Second, Concatenate is only meaningful in a callable’s parameter
position; using it as an ordinary type argument is an error. On Python 3.11+ Concatenate and
ParamSpec gained the ability to model removing a leading argument as cleanly as adding one, and
under PEP 695 you can spell the same decorator as def with_config[**P, R](func: Callable[Concatenate[ServiceConfig, P], R]) -> Callable[P, R].
Analyzer behaviour
Both mainstream checkers implement PEP 612 natively, so the common cases agree — but the diagnostic codes they emit differ, which matters when you suppress or grep for them in CI.
mypy
mypy implements PEP 612 fully and is strict about the placement rules. A P.args without its
P.kwargs partner is [valid-type]; a ParamSpec used where an ordinary type is required (for
instance list[P]) is [misc]. A Concatenate whose positional arguments do not line up at a call
site is [arg-type]. Running under
mypy strict mode additionally
surfaces a decorator that silently widens its return type — the warn_return_any and no-implicit-...
behaviours flag the Callable[..., R] mistake that plain mode would let slide.
# Python 3.10+, mypy 1.x
def bad(func: Callable[P, R]) -> Callable[..., R]: # drops P → loses arg checks
... # mypy strict flags the `...` widening
Note the version floor: mypy has supported ParamSpec since 0.9x, but early releases had bugs around
Concatenate and nested decorators; pin mypy 1.0+ for reliable PEP 612 checking, and if you use the
PEP 695 [**P, R] spelling set python_version = "3.12" so mypy parses the brackets at all.
pyright
pyright also implements PEP 612 natively. Misusing ParamSpec — 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. Unlike mypy, pyright will not emit an error for a decorator that widens its
return to Callable[..., R]; it simply loses the checks silently, which is why the strict-mode
column above is the one that catches the design mistake. The two checkers agree on the common cases;
remaining divergences are tracked in
pyright vs mypy.
Strictness tuning
When a third-party decorator is typed as Callable[..., R] and you cannot fix it upstream, the goal
is to contain the argument-check loss rather than disable it globally. The decision is a short one:
fix the annotation if it is yours, vendor a stub if the shape is knowable, and only fall back to a
scoped disable_error_code for the module that genuinely cannot be typed.
# pyproject.toml — tolerate an untyped vendor decorator in one module
[[tool.mypy.overrides]]
module = "integrations.vendor_hooks"
disable_error_code = ["arg-type"]
The equivalent in pyright is a per-file # pyright: reportArgumentType=false comment or a
executionEnvironments entry scoped to that path — both keep the rest of the tree strict. Whichever
you choose, track the count of suppressions so the debt is visible; a decorator typed Callable[..., R] is a permanent hole in argument checking for every function it wraps, so the override should be a
temporary measure while you upstream a fix or write a stub.
Debugging false positives
The single most common report — “callers stopped getting argument errors after I added the
decorator” — is almost never a checker bug. It is the ... return-type erasure. Walk the wrapper’s
annotations from the outside in: if the decorator returns Callable[..., R], the parameter list was
deliberately discarded and no call through it can be checked. Re-thread P through both the input
and the output annotation and the checks reappear.
# Python 3.10+, mypy 1.x — the two annotations that must both carry P
def broken(func: Callable[P, R]) -> Callable[..., R]: ... # input keeps P, output drops it
def fixed(func: Callable[P, R]) -> Callable[P, R]: ... # both carry P → calls checked
A subtler false positive appears when a decorator stacks on another whose return you have widened —
the erasure propagates outward, so fix the innermost lossy annotation first. Combining ParamSpec
with @overload lets a single decorator
preserve several distinct signatures; if only one overload loses its checks, that overload’s return
type is the one to inspect. For methods, remember P captures the parameters after self, so a
“lost self” symptom points to a Concatenate[Self, P] you should not have written — the descriptor
protocol binds self for you.
Common pitfalls
The failure modes cluster into four recurring shapes. Each has a characteristic symptom and a one-line fix; internalising the mapping turns a confusing checker message into an obvious correction.
- Returning
Callable[..., R]: This discards the captured parameters; callers stop getting[arg-type]errors they should get. ReturnCallable[P, R]. This is the erasure covered above and is by far the most frequent cause of “the decorator broke my type checking”. - Using
P.argsalone: It must be paired with**kwargs: P.kwargson the same function — mypy raises[valid-type], pyrightreportInvalidTypeVarUseotherwise. Never annotate a normal parameter withP.args, and never swap the two onto the wrong variadic. - Forgetting
functools.wraps: It does not affect static types, but without it__name__,__doc__, and__wrapped__are wrong at runtime, breaking introspection,help(), and some frameworks (Flask routing, pytest fixtures) that key on the function name. - Treating
ParamSpeclike aTypeVar:Pcannot stand in for a single type argument such aslist[P]or a bare return type; that is[misc]in mypy. The return still needs its ownTypeVar R, declared and used independently ofP. See Generics and TypeVar for the single-type case.
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.