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 wrapperThe 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 wrapperCallable[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 wrapperfunctools.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 decorateThe 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 resultParamSpec 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 wrapperThe 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 wrapperIf 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 wrapperThe 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 wrapperThe 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 decorateThe 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.







