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.
Callable discards.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 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.
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
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.
hook(...) is dispatched.
Common mistakes
- 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. - Adding extra members to a callback Protocol: if you add methods beyond
__call__, a plain function will no longer match. Keep callback Protocols to__call__alone unless you truly require the extra attributes. - 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. - 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.
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.