ParamSpec preserves a callable’s complete signature, but some decorators intentionally change the visible parameters. They may inject a request context, hide a database connection, add a lock, or adapt a handler for a framework. typing.Concatenate describes a transformation at the beginning of a callable’s parameter list.
This guide shows how to combine Concatenate with ParamSpec and TypeVar, build dependency-injection, authentication, and synchronization decorators, work with async wrappers and callbacks, and understand the feature’s limitations.
The context-injection problem
from collections.abc import Callable
class Context:
user: str
def inject_context(function: Callable):
def wrapper(*args, **kwargs):
context = Context()
context.user = "system"
return function(context, *args, **kwargs)
return wrapperThe original function requires Context first, while the decorated function should hide that argument. Without precise typing, autocomplete and argument validation are lost.
Concatenate with ParamSpec
from collections.abc import Callable
from typing import Concatenate, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def inject_context(
function: Callable[Concatenate[Context, P], R],
) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
context = Context()
context.user = "system"
return function(context, *args, **kwargs)
return wrapperConcatenate[Context, P] means that the original callable receives Context followed by every parameter captured in P. The public wrapper exposes only P.
Why Concatenate appears inside Callable
Concatenate is designed for callable parameter lists. It normally appears as the first argument of Callable and ends with a ParamSpec.
Callable[Concatenate[Context, P], R]Explicit types come before P. Concatenate does not insert parameters in the middle or at the end of a captured signature.
Injecting a connection
class Connection:
def execute(self, sql: str) -> list[tuple[object, ...]]:
...
def with_connection(
function: Callable[Concatenate[Connection, P], R],
) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
connection = open_connection()
try:
return function(connection, *args, **kwargs)
finally:
connection.close()
return wrapperThe caller does not supply the connection. The decorator owns its creation and cleanup, while the business function receives an explicitly typed dependency.
Preserving runtime metadata
from functools import wraps
@wraps(function)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
...wraps preserves name, documentation, and the reference to the original function at runtime. Concatenate, ParamSpec, and TypeVar preserve the static relationship. Use both.
Injecting a lock
from threading import Lock
def with_lock(
function: Callable[Concatenate[Lock, P], R],
) -> Callable[P, R]:
lock = Lock()
@wraps(function)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
with lock:
return function(lock, *args, **kwargs)
return wrapperThe lock is shared across calls to the decorated function. Document details such as granularity and reentrancy because the type annotation cannot express them.
Injecting the authenticated user
class User:
id: int
name: str
def require_user(
function: Callable[Concatenate[User, P], R],
) -> Callable[P, R]:
@wraps(function)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
user = current_user()
if user is None:
raise PermissionError("authentication required")
return function(user, *args, **kwargs)
return wrapperThe business function explicitly declares that it needs a User, while the public entry point obtains that dependency from the environment.
Injecting multiple parameters
def inject_services(
function: Callable[
Concatenate[User, Connection, P],
R,
],
) -> Callable[P, R]:
@wraps(function)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
user = get_user()
connection = open_connection()
try:
return function(user, connection, *args, **kwargs)
finally:
connection.close()
return wrapperSeveral explicit types may appear before P. Their order must exactly match the actual call.
The inverse transformation
A wrapper can also add a parameter for callers while hiding it from the inner function. The received callable uses P, and the returned callable uses Concatenate.
def require_token(
function: Callable[P, R],
) -> Callable[Concatenate[str, P], R]:
@wraps(function)
def wrapper(
token: str,
*args: P.args,
**kwargs: P.kwargs,
) -> R:
validate_token(token)
return function(*args, **kwargs)
return wrapperThe caller now supplies a token before the original parameters.
Asynchronous wrappers
from collections.abc import Awaitable
class AsyncContext:
trace_id: str
def with_trace(
function: Callable[
Concatenate[AsyncContext, P],
Awaitable[R],
],
) -> Callable[P, Awaitable[R]]:
@wraps(function)
async def wrapper(
*args: P.args,
**kwargs: P.kwargs,
) -> R:
context = AsyncContext()
context.trace_id = create_trace_id()
return await function(context, *args, **kwargs)
return wrapperThe parameter relationship matches the synchronous version, while the result remains awaitable.
Concatenate in callback APIs
def register_handler(
handler: Callable[Concatenate[Event, P], None],
*args: P.args,
**kwargs: P.kwargs,
) -> None:
event = wait_for_event()
handler(event, *args, **kwargs)The registration function owns the Event, and consumers provide the remaining arguments compatible with P.
Methods and self
For methods, self or cls already belongs to the captured signature. A decorator that only forwards arguments normally needs ParamSpec alone. Concatenate becomes relevant when the beginning of the visible signature really changes.
Method descriptors bind the receiver automatically, so dependency injection after self can be awkward to describe with a generic prefix. A method-specific Protocol or explicit overload may be clearer. Always test the binding behavior with the checker used by the project.
Limitation: prefixes only
Concatenate adds types before P. It cannot insert a parameter after the first captured argument or express a new keyword-only parameter at the end. More complex transformations may require explicit overloads, dedicated Protocols, or checker plugins.
Named parameters
The explicit types added by Concatenate behave as leading positional parameters in the callable relationship. Names, defaults, and keyword-only details may not be represented as desired. If public names matter, define a Protocol with an explicit __call__ signature.
Concatenate versus overload
Concatenate models a generic signature transformation. Overload models a finite set of alternative call shapes. Use Concatenate when any P is preserved after adding or removing a prefix. Use overload when a small number of concrete signatures have different results.
Concatenate versus Protocol
Protocol is more detailed for callables with specific parameter names, extra attributes, or methods. Concatenate is compact for generic decorators. The two features can also work together.
Concatenate versus functools.partial
functools.partial binds arguments at runtime. Concatenate describes the static relationship between the input and output callables. A typed helper that builds partials can use ParamSpec and Concatenate, though complex keyword binding may vary between checkers.
Changing the return type
def with_optional_context(
function: Callable[Concatenate[Context, P], R],
) -> Callable[P, R | None]:
@wraps(function)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
context = try_context()
if context is None:
return None
return function(context, *args, **kwargs)
return wrapperConcatenate handles the parameters; the return annotation must expose any result transformation.
Common mistakes
- Using Concatenate without a final ParamSpec: the construction is incomplete.
- Trying to insert in the middle: Concatenate represents prefixes.
- Changing the order: explicit types must match the real call.
- Forgetting P.args and P.kwargs: remaining parameters are not preserved.
- Ignoring method binding: descriptors can change runtime behavior.
- Assuming typing validates runtime objects: the wrapper must still construct correct dependencies.
Complete unit-of-work example
from collections.abc import Callable
from functools import wraps
from typing import Concatenate, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
class UnitOfWork:
def __enter__(self):
return self
def commit(self) -> None:
...
def __exit__(self, exc_type, exc, tb) -> None:
if exc is not None:
self.rollback()
def rollback(self) -> None:
...
def transactional(
function: Callable[Concatenate[UnitOfWork, P], R],
) -> Callable[P, R]:
@wraps(function)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
with UnitOfWork() as uow:
result = function(uow, *args, **kwargs)
uow.commit()
return result
return wrapper
@transactional
def create_order(
uow: UnitOfWork,
customer_id: int,
item_ids: list[int],
) -> int:
...Callers see only customer_id and item_ids. The inner function still declares its dependency explicitly and remains easy to test.
Testing the signature
Use reveal_type(), mypy or pyright fixtures, and intentionally invalid calls. Runtime tests should also verify argument order, method binding, and dependency lifecycle.
Conclusion
typing.Concatenate complements ParamSpec by modeling callables that add or remove parameters at the beginning of a signature. It is useful for context injection, authentication, locks, connections, and adapters.
The official Python Concatenate documentation defines the construction. Also read the guide to Python ParamSpec, and choose Protocol or overloads when a transformation cannot be expressed as a generic prefix.







