Defining Callback Protocols with call
A Protocol with a __call__ method types a callback far more precisely than Callable[...]. Callable[[str, int], None] can only say “two positional parameters of these types”; a call Protocol lets you name each parameter, mark some keyword-only, give defaults, and even declare overloads. Use a call Protocol whenever a callback’s keyword arguments or optional parameters are part of its contract.
The Callable form is compact but lossy: it collapses a signature into an ordered list of types and a return type. Real callbacks in Python are rarely that flat — they take keyword-only flags, optional parameters with defaults, and sometimes more than one valid shape. A callback Protocol — a Protocol whose only member is __call__ — captures all of that. This page sits under Protocol and structural subtyping and complements the callable signatures fundamentals.
The lossy Callable form
Suppose you accept a logging hook that takes an event name and an optional severity keyword. The Callable annotation cannot express the keyword — you are forced to pretend both parameters are positional.
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Callable
Hook = Callable[[str, int], None] # both positional; 'level' name is lost
def record(event: str, *, level: int = 0) -> None:
print(event, level)
def install(hook: Hook) -> None:
hook("startup", level=2) # mypy: [call-arg] unexpected keyword 'level'
mypy rejects the keyword call with [call-arg] (Unexpected keyword argument "level"), because Callable[[str, int], None] promises two positional parameters. The very shape of record is invisible to the annotation.
The limitation is structural, not a checker quirk. Callable[[str, int], None] has exactly three degrees of freedom: an ordered list of positional parameter types, the special form ... meaning “any parameters at all”, and one return type. There is no slot in that syntax for a parameter name, for the * that would make a parameter keyword-only, for a = 0 default, or for a second acceptable signature. Anything a real function expresses beyond bare positional types has nowhere to live in the annotation, so the checker treats it as absent. Because every entry is positional, Callable also cannot mark a parameter as positional-only; the callable signatures reference covers what the compact form can and cannot say.
The escape hatches make the loss explicit rather than fixing it. Callable[..., None] accepts any arguments, which silences the [call-arg] error but also disables all argument checking — every call site of that value becomes unchecked. Concatenate[str, P] lets you pin leading positional types while forwarding the rest through a ParamSpec, but it still cannot name a keyword parameter or attach a default. And remember that a callable’s parameters are contravariant: a value typed Callable[[str, int], None] may be satisfied by a function accepting wider parameters, but never by one that demands an extra required keyword the type never mentions.
Two version notes worth pinning down. Since PEP 585 (Python 3.9) you can subscript collections.abc.Callable directly, so from collections.abc import Callable is preferred over the older typing.Callable; both describe the same lossy shape. Under from __future__ import annotations the annotation is stored as a string regardless of which Callable you import, so the choice is purely one of import hygiene and does not change what the checker sees.
The precise callback Protocol
Define a Protocol whose __call__ mirrors the real signature. Now level is keyword-only with a default, and any callable matching that shape conforms structurally.
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from typing import Protocol
class LoggingHook(Protocol):
def __call__(self, event: str, *, level: int = 0) -> None: ...
def record(event: str, *, level: int = 0) -> None:
print(event, level)
def install(hook: LoggingHook) -> None:
hook("startup", level=2) # accepted — keyword-only 'level' is known
hook("ready") # accepted — default supplies level=0
install(record) # record matches LoggingHook structurally
The parameter name event also becomes part of the contract, so a caller may write hook(event="startup"). If record dropped the * and made level positional, mypy would report the mismatch with [arg-type] at the install(record) call site.
Conformance is checked with the usual function-compatibility rules, and they are looser than exact equality. Because parameters are contravariant, a concrete function may accept wider parameter types than the Protocol declares and still match — a record(event: object, *, level: int = 0) conforms to a LoggingHook whose event is str. It may also declare extra parameters as long as they have defaults or absorb into *args/**kwargs, since every call the Protocol permits is still a valid call to the wider function. What it may not do is add a required parameter the Protocol never supplies, drop a parameter the Protocol promises to pass, or return a wider type than the Protocol’s return annotation (returns are covariant). When the parameter name matters — because callers pass it by keyword — the concrete function must use the same name; renaming event to msg breaks a hook(event=...) call site even though the positional shape is identical.
If you want to forbid keyword usage and pin a parameter as positional-only, put it before a / in the __call__ signature: def __call__(self, event: str, /, *, level: int = 0) -> None. That frees the implementer to name its first parameter anything at all, which is often what you want for a library callback whose first argument is conceptually anonymous. This positional-only control is another thing Callable[[str, int], None] cannot express — it has no / and no * — so reaching for a call Protocol is the only way to say “first argument positional, second keyword-only” in the type system.
Overloaded callbacks
A callback Protocol can hold @overload-ed __call__ definitions, which no Callable form can express. This is how you type a factory that returns different types depending on a flag.
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from typing import Protocol, overload, Literal
class Decoder(Protocol):
@overload
def __call__(self, raw: bytes, *, text: Literal[True]) -> str: ...
@overload
def __call__(self, raw: bytes, *, text: Literal[False] = False) -> bytes: ...
def call_decoder(decode: Decoder, blob: bytes) -> str:
return decode(blob, text=True) # picks the str overload
Overload resolution runs top to bottom: the checker matches the arguments against each @overload signature in source order and commits to the first that fits, so order the more specific Literal[True] case before the general one. A concrete function conforms to an overloaded callback Protocol only if its implementation is assignable to every overload — practically, that means the real decode is written as one function with a text: bool = False parameter and its own @overload stubs, or as a single body whose return type is the union str | bytes. If two overloads overlap in their inputs but disagree on return type, both mypy ([overload-overlap]) and pyright (reportOverlappingOverloads) warn, because a single call could satisfy both and the result type would be ambiguous.
The same Protocol can be made generic, which multiplies its usefulness. Parameterize __call__ with a TypeVar (or PEP 695 class Transform[T](Protocol)) to describe a callback whose input and output types are linked, e.g. def __call__(self, value: T) -> T. Where overloads do not fit — for instance forwarding an arbitrary parameter list to a wrapped function — a ParamSpec on the Protocol, or Concatenate, preserves the caller’s full argument signature instead of enumerating cases. Overloads, generics, and ParamSpec are complementary: reach for overloads when a discrete flag selects among a few known signatures, and for ParamSpec when you must relay an open-ended one.
Analyzer behaviour
mypy and pyright both match callables against a call Protocol by comparing __call__ signatures, and both honour keyword-only markers and defaults. pyright is marginally stricter about parameter names when a caller uses them as keywords, so a name mismatch that mypy tolerates positionally may surface as reportArgumentType in pyright. A signature-shape mismatch is [arg-type] in mypy. For a broader treatment of these divergences see pyright vs mypy comparison.
Under the hood both tools reduce the concrete callable to a signature object and run the same assignability test they use for any function-to-Callable check, so the diagnostics you see reuse the general codes rather than a Protocol-specific one. In mypy an incompatible argument type is [arg-type], an unexpected or missing keyword at a call site is [call-arg], and passing a function whose whole signature is wrong to install() is a [arg-type] on that argument. Pyright rolls these into reportArgumentType and reportCallIssue and, under typeCheckingMode = "strict", is more aggressive about a parameter that is positional-or-keyword in the Protocol but positional-only in the implementation. A practical consequence: keep the Protocol’s parameter names identical to the intended public keyword API, because that name is the part most likely to diverge between the two checkers. When you deliberately want maximum flexibility, declare the Protocol parameter positional-only with / so neither checker enforces the name.
Assignability also runs the other way, which is occasionally useful at boundaries. A value typed with a call Protocol is itself assignable to a compatible Callable[..., R], so a function that only cares about the return type can accept the looser annotation while your own code keeps the precise one. Both checkers treat the call Protocol as a structural subtype of the corresponding Callable, so you can narrow at the edges without a cast. What neither tool will do is upgrade a plain Callable back into the richer shape — once a signature has been flattened to Callable[[str, int], None], the keyword and default information is gone for good, and only re-annotating the source with a call Protocol recovers it.
hook(...) is dispatched.
Common mistakes
The recurring failure is a mismatch between what the annotation promises and what a real call needs — most often a keyword the compact Callable form silently dropped, which then reappears as a [call-arg] error the first time someone passes it.
- Reaching for
Callablewhen a keyword matters: the keyword call fails with[call-arg]. Switch to a__call__Protocol so the keyword-only parameter is part of the type. The temptation to silence it withCallable[..., None]“works”, but it disables argument checking at every call site of that value — a strictly worse trade. - Adding extra members to a callback Protocol: if you add methods beyond
__call__, a plain function will no longer match, because a bare function object has noretriesattribute orreset()method. Keep callback Protocols to__call__alone unless you truly intend to require a configured callback object rather than a function. - Naming parameters differently across the Protocol and implementation: callers who use keywords will hit
[call-arg]/reportArgumentType. Keep the parameter names identical to the intended keyword API, or make the parameter positional-only with/if the name should not be part of the contract. - Forgetting the default on the Protocol side: if
__call__listslevel: intwithout= 0, callers omitting it are rejected with[call-arg]even though the concrete function has a default. The Protocol, not the implementation, decides which calls the checker permits. - Expecting runtime enforcement: the Protocol is erased at runtime, so a mismatched callable that slips past your type gate will still be invoked — Python resolves the arguments by its own rules. The Protocol constrains the checker, not the interpreter, which is exactly why keeping the annotation honest matters.
FAQ
When is Callable[...] still the better choice?
When the callback is genuinely positional and simple — a comparator Callable[[T, T], int], a plain Callable[[], None] teardown. If there are no keyword-only parameters, defaults, or overloads to preserve, the compact form is clearer and needs no class definition.
Can a callback Protocol also carry attributes like __name__?
Yes. Because it is a normal Protocol, you can add attributes (for example retries: int) alongside __call__. Only callables that also expose those attributes will then match, which is useful for typing configured callback objects rather than bare functions.