Defining Callback Protocols with call

TL;DR

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 versus a callback Protocol Callable keeps only positional types and return type, while a Protocol with __call__ preserves parameter names, keyword-only flags, and defaults. Callable[[str, int], None] positional types only no parameter names no keyword-only args no defaults lossy contract LoggingHook(Protocol) named: event keyword-only: level default: level = 0 overloads allowed precise contract
A call Protocol keeps the parameter detail that 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.

Signature detail lost through the Callable funnel Parameter names, the keyword-only marker, and default values enter a funnel and only two positional types survive. def record(event: str, *, level: int = 0) -> None name: event marker: * default: = 0 name: level Callable funnel Callable[[str, int], None] names ✗ keyword-only ✗ defaults ✗
Everything except the ordered positional types is discarded.

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.

Anatomy of a callback Protocol signature The __call__ method is split into labelled segments showing the named parameter, keyword-only marker, defaulted keyword-only parameter, and return type, each preserved by the Protocol. def __call__(self, event: str, *, level: int = 0) -> None self event: str * level: int = 0 -> None named param kw-only marker kw-only + default return type every segment survives — nothing collapses to a positional list
Each part of the real signature has a place to live in the Protocol's __call__.
# 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.

Overload selection by the text flag The Literal value of the text keyword decides which overload of the Decoder callback the checker picks, yielding str for True and bytes for False. decode(raw: bytes, *, text: Literal[...]) text = ? overload dispatch True False text: Literal[True] returns str text: Literal[False] returns bytes
The Literal flag picks the overload, so the checker knows the return type at each call site.
# 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.

mypy versus pyright on callback Protocols Both checkers honour keyword-only markers, defaults, and overloads, but they emit different diagnostics for a parameter-name mismatch on a keyword call. Feature mypy pyright keyword-only markers honoured defaults honoured name mismatch on keyword call [call-arg] reportArgumentType overloaded __call__
The two checkers agree on structure but name their diagnostics differently.

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.

Runtime vs static analysis A callback Protocol is erased at runtime — passing an ordinary function that "looks right" runs fine even if it violates the declared keyword-only rule, because Python resolves arguments by its own rules, not the annotation. The Protocol only constrains what the static checker accepts at the call site; it never rewrites how 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.

From a lossy annotation to a working keyword call A Callable annotation drops the keyword, the keyword call raises a call-arg error, switching to a call Protocol restores the keyword, and the call is accepted. Callable[[str,int],None] keyword lost hook(e, level=2) [call-arg] __call__ Protocol keyword restored call accepted the fix is the annotation, not the call site
The keyword the Callable dropped comes back once the type is a call Protocol.
  • Reaching for Callable when 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 with Callable[..., 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 no retries attribute or reset() 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__ lists level: int without = 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.

Back to Protocol and Structural Subtyping