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 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. Return Callable[P, R].
  • Using P.args alone: It must be paired with **kwargs: P.kwargs on 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 ParamSpec like a TypeVar: P cannot stand in for a single type argument; the return type still needs its own TypeVar 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.

Back to Advanced Typing Patterns & Generics