Python ParamSpec: Preserve Signatures

Published on: August 28, 2026
Reading time: 4 minutes
A close-up view of a person's hand signing a business contract on a desk with a pen.

Decorators, callbacks, and higher-order functions often receive one function and return another. The static typing challenge is preserving every parameter of the original callable without falling back to Callable[..., T], which accepts any signature and discards useful information. typing.ParamSpec represents a callable’s complete parameter list, including positional, keyword, variadic, and defaulted parameters.

This guide explains ParamSpec in decorators, synchronous and asynchronous wrappers, factories, Protocol, methods, and generic utilities. It also covers P.args, P.kwargs, TypeVar, Concatenate, and common mistakes.

The problem with Callable and ellipsis

from collections.abc import Callable
from typing import TypeVar

R = TypeVar("R")

def log_call(function: Callable[..., R]) -> Callable[..., R]:
    def wrapper(*args, **kwargs):
        print("called")
        return function(*args, **kwargs)
    return wrapper

The result type R is preserved, but the checker no longer knows which arguments the wrapper accepts. Invalid calls can pass static analysis.

Creating a ParamSpec

from collections.abc import Callable
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def log_call(function: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print("called")
        return function(*args, **kwargs)
    return wrapper

Callable[P, R] means a callable whose parameters are described by P and whose return type is R. The wrapper receives the same parameters and returns the same result.

What P.args and P.kwargs mean

P.args annotates the captured positional arguments. P.kwargs annotates keyword arguments. They are special typing markers and should be used together on *args and **kwargs that correspond to the same ParamSpec.

def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
    return function(*args, **kwargs)

They are not ordinary tuples or mappings of types for arbitrary manipulation.

A timing decorator

from functools import wraps
from time import perf_counter

P = ParamSpec("P")
R = TypeVar("R")

def timed(function: Callable[P, R]) -> Callable[P, R]:
    @wraps(function)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start = perf_counter()
        try:
            return function(*args, **kwargs)
        finally:
            duration = perf_counter() - start
            print(f"{function.__name__}: {duration:.6f}s")
    return wrapper

functools.wraps preserves runtime metadata, while ParamSpec preserves the static signature. They solve different but complementary problems.

A decorator factory

def repeat(times: int):
    def decorate(function: Callable[P, R]) -> Callable[P, R]:
        @wraps(function)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            result: R
            for _ in range(times):
                result = function(*args, **kwargs)
            return result
        return wrapper
    return decorate

The outer function accepts configuration, while the inner decorator remains generic over the decorated signature.

Asynchronous callables

from collections.abc import Awaitable

async def run_with_logging(
    function: Callable[P, Awaitable[R]],
    *args: P.args,
    **kwargs: P.kwargs,
) -> R:
    print("starting")
    result = await function(*args, **kwargs)
    print("finished")
    return result

ParamSpec preserves the coroutine function’s arguments, and Awaitable[R] describes the awaitable result.

Turning sync code into async code

import asyncio


def in_thread(
    function: Callable[P, R],
) -> Callable[P, Awaitable[R]]:
    @wraps(function)
    async def wrapper(
        *args: P.args,
        **kwargs: P.kwargs,
    ) -> R:
        return await asyncio.to_thread(function, *args, **kwargs)
    return wrapper

The parameter signature stays the same, but the return type changes from R to Awaitable[R].

Typed callback execution

class Executor:
    def run(
        self,
        callback: Callable[P, R],
        *args: P.args,
        **kwargs: P.kwargs,
    ) -> R:
        return callback(*args, **kwargs)

The method accepts any callback but requires arguments compatible with that callable. Missing and extra arguments can be reported statically.

ParamSpec in Protocol

from typing import Protocol

class Middleware(Protocol[P, R]):
    def __call__(
        self,
        next_handler: Callable[P, R],
    ) -> Callable[P, R]: ...

This contract describes objects that receive a function and return another function with the same signature. Protocol is useful for stateful decorator objects and framework extensions.

Decorator classes

class Counter:
    def __init__(self, function: Callable[P, R]) -> None:
        self.function = function
        self.calls = 0

    def __call__(
        self,
        *args: P.args,
        **kwargs: P.kwargs,
    ) -> R:
        self.calls += 1
        return self.function(*args, **kwargs)

Inference for decorator classes may differ between checkers and versions. Public libraries should include dedicated type-checking tests.

Methods and self

When a decorator is applied to a method, the receiver is part of the captured signature. A wrapper usually does not need special handling for self.

def audit(function: Callable[P, R]) -> Callable[P, R]:
    @wraps(function)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(function.__qualname__)
        return function(*args, **kwargs)
    return wrapper

If a decorator explicitly adds or removes a leading parameter, use Concatenate.

Adding a parameter with Concatenate

from typing import Concatenate

class Context:
    user: str

def inject_context(
    function: Callable[Concatenate[Context, P], R],
) -> Callable[P, R]:
    @wraps(function)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        context = Context()
        context.user = "system"
        return function(context, *args, **kwargs)
    return wrapper

The original function requires Context first, while the decorated callable hides it from callers.

ParamSpec versus TypeVar

A TypeVar represents one type. A ParamSpec represents a complete list of function parameters. A TypeVar cannot capture two positional arguments, a keyword-only option, and arbitrary keywords as one relationship.

T = TypeVar("T")
# T may be int, str, User, and so on

P = ParamSpec("P")
# P may represent (id: int, *, active: bool)

ParamSpec versus Callable[…, R]

Callable[..., R] is appropriate when parameters genuinely do not matter or when an API dynamically accepts every call shape. For decorators that promise to preserve an interface, ParamSpec is safer and more useful.

Keyword-only parameters

def send(destination: str, *, urgent: bool = False) -> int:
    ...

typed_send = log_call(send)
typed_send("queue", urgent=True)

The ParamSpec captures that urgent is keyword-only. The checker can reject an incompatible positional call.

Overloads and ParamSpec

A ParamSpec-based decorator often preserves the inferred signature of overloads, provided the checker can apply the decorator to the overload declarations. Very complex public APIs may still require explicit overloads on the decorator itself.

Changing the return type

def suppress_errors(
    function: Callable[P, R],
) -> Callable[P, R | None]:
    @wraps(function)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
        try:
            return function(*args, **kwargs)
        except Exception:
            return None
    return wrapper

The parameters remain identical, but callers must now handle None. The annotation should expose every return transformation.

Behavior beyond the signature

ParamSpec does not describe exceptions, side effects, retries, latency, or thread safety. Static parameters are only one part of a wrapper’s contract. Document behavioral changes separately.

Version compatibility

ParamSpec is available in modern versions of typing. Libraries supporting older interpreters can use typing_extensions.ParamSpec. The traditional ParamSpec("P") form has broad compatibility.

Common mistakes

  • Using Callable[…, R]: argument validation is lost.
  • Forgetting P.args or P.kwargs: the wrapper no longer forwards the captured signature correctly.
  • Using ParamSpec in unsupported positions: it has specific callable-related contexts.
  • Changing parameters without Concatenate: the annotation stops matching reality.
  • Confusing wraps with typing: wraps preserves metadata, not the complete static relationship.
  • Hiding a changed return: callers need the transformed result type.

Complete typed retry decorator

from functools import wraps
from time import sleep

P = ParamSpec("P")
R = TypeVar("R")

def retry(attempts: int, delay: float = 0.0):
    if attempts < 1:
        raise ValueError("attempts must be positive")

    def decorate(function: Callable[P, R]) -> Callable[P, R]:
        @wraps(function)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            last_error: Exception | None = None
            for index in range(attempts):
                try:
                    return function(*args, **kwargs)
                except Exception as error:
                    last_error = error
                    if index + 1 < attempts and delay:
                        sleep(delay)
            assert last_error is not None
            raise last_error
        return wrapper
    return decorate

The decorator adds retry behavior without losing the original parameters or return type. Production code should restrict captured exceptions and consider idempotency.

Testing typing

In addition to runtime tests, create type-checking fixtures with valid and intentionally invalid calls. Run mypy or pyright in continuous integration. Tools such as reveal_type() help confirm that a decorated function keeps the expected signature.

Conclusion

typing.ParamSpec makes it possible to write decorators and higher-order functions that preserve complete signatures. It removes much of the unsafe reliance on Callable[..., R] and improves autocomplete, documentation, and error detection.

The official Python ParamSpec documentation describes its supported contexts. Combine ParamSpec with TypeVar for results, functools.wraps for runtime metadata, and Concatenate only when a wrapper truly adds or removes parameters.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Close-up of hands typing on a laptop keyboard, Python book in sight, coding in progress.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python TypeIs: Narrow Both Branches

    Learn Python TypeIs to narrow true and false branches, compare it with TypeGuard, and build sound reusable type predicates.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    Hands typing code on a laptop in a workspace. Indoor setting focused on software development.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python TypeGuard: Refine Types Safely

    Learn Python TypeGuard to narrow types and validate collections, TypedDict, Protocol, and external data with runtime checks and static safety.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    Close-up view of a computer screen displaying code in a software development environment.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python typing.Self: Fluent Return Types

    Learn Python typing.Self for fluent methods, classmethods, builders, clones, Protocol, context managers, generics, and subclass-preserving returns.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    A conceptual image showing error code projected on binary data with smoke.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python ExceptionGroup: Multiple Errors

    Learn Python ExceptionGroup for multiple errors, except*, nested groups, TaskGroup, filtering, logging, API compatibility, and batch validation.

    Ler mais

    Tempo de leitura: 4 minutos
    28/08/2026
    Close-up view of a computer screen displaying code in a software development environment.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python Path.walk: Traverse Directories

    Learn Python Path.walk to traverse directories, prune folders, handle errors and symlinks, calculate sizes, delete safely, and support older versions.

    Ler mais

    Tempo de leitura: 4 minutos
    28/08/2026
    A developer typing code on a laptop with a Python book beside in an office.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python asyncio.timeout: Control Deadlines

    Learn Python asyncio.timeout for deadlines, timeout_at, rescheduling, TaskGroup, cleanup, retries, shielding, and safe asynchronous cancellation.

    Ler mais

    Tempo de leitura: 4 minutos
    28/08/2026