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.

How ParamSpec preserves a signature through a decorator The decorator captures the wrapped callable's parameters as P and return as R, and the wrapper re-exposes the same P and R. Callable[P, R] original function capture P, R wrapper(*args: P.args, **kwargs: P.kwargs) re-expose P, R Callable[P, R] same signature
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 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.

ParamSpec P maps the parameters, TypeVar R maps the return The wrapped function's parameter list is captured by P and its return type by R, and both are re-emitted onto the wrapper. def f(name: str, retries: int = 3) -> bytes name: str, retries: int = 3 -> bytes ParamSpec P whole parameter list TypeVar R the return type Callable[P, R] re-emits both onto the wrapper
P absorbs the parameter list as one unit; R carries only the return.
# 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.

P.args and P.kwargs must appear together A wrapper annotating both star-args as P.args and double-star-kwargs as P.kwargs is accepted, while a wrapper with only P.args is rejected. accepted *args: P.args **kwargs: P.kwargs pair complete ✓ rejected *args: P.args no **kwargs: P.kwargs partner missing [valid-type] ✗
The two members are one unit — declare both, on the same wrapper, or neither.
# 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.

Concatenate stacks and then strips a leading argument The wrapped callable is Concatenate of ServiceConfig and P; the decorator supplies ServiceConfig so the decorated callable exposes only P. wrapped: Concatenate[ServiceConfig, P] ServiceConfig P (request_id, ...) decorator injects ServiceConfig decorated: Callable[P, R] ServiceConfig removed P (request_id, ...) caller writes handle("req-1") — config already supplied Concatenate always pins the prefix as positional; P carries the rest the leading type moves from the wrapped side to the decorator side
Concatenate peels a fixed leading argument off the front of the signature.
# 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 versus pyright diagnostic codes for ParamSpec misuse A grid mapping four misuses to the mypy error code and the pyright rule name each checker reports. misuse mypy pyright lone P.args [valid-type] reportInvalidTypeVarUse P as ordinary type arg [misc] reportGeneralTypeIssues Concatenate arg mismatch [arg-type] reportArgumentType return widened to ... strict: [misc] silent (checks lost)
The same misuse, two different codes — suppress with the checker's own spelling.

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.

Decision tree for an untyped vendor decorator Branches from whether you own the decorator, to fixing it, writing a stub, or scoping a per-module override. Callable[..., R] decorator, checks lost you own it third-party re-thread Callable[P, R] the real fix signature knowable? yes no write a .pyi stub restores checks scope disable_error_code one module only
Prefer a fix or a stub; a scoped override is the last resort, never a global disable.
# 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.

Diagnosing lost argument checks through a decorator A four-stage pipeline from the symptom to inspecting the return annotation, spotting the ellipsis, and re-threading P. symptom: no arg errors read return annotation see Callable [..., R] re-thread P checks return the ... erases the parameter list — put P back on both ends
Trace the symptom to the ... in the return annotation, then re-thread P.
# 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.

Four ParamSpec pitfalls mapped to symptom and fix A three-column table listing each pitfall, the symptom it produces, and the corrective action. pitfall symptom fix return Callable[..., R] no arg errors return Callable[P, R] P.args alone [valid-type] add **kwargs: P.kwargs no functools.wraps wrong __name__ @functools.wraps(func) P used like a TypeVar [misc] add a separate TypeVar R
Each pitfall has one symptom and one fix — read the row, apply the correction.
  • Returning Callable[..., R]: This discards the captured parameters; callers stop getting [arg-type] errors they should get. Return Callable[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.args alone: It must be paired with **kwargs: P.kwargs on the same function — mypy raises [valid-type], pyright reportInvalidTypeVarUse otherwise. Never annotate a normal parameter with P.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 ParamSpec like a TypeVar: P cannot stand in for a single type argument such as list[P] or a bare return type; that is [misc] in mypy. The return still needs its own TypeVar R, declared and used independently of P. 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.

Back to Advanced Typing Patterns & Generics