Callable vs Protocol for Callbacks: When to Use Each
When you type a callback, Callable[[int, str], bool] is the terse choice, but a
Protocol with a __call__
method is the precise one. Callable can only describe positional parameter types and a return
type; the moment you need keyword arguments, named parameters, overloads, default values, or
attributes on the callable itself, a calling Protocol is the right tool. This guide shows both
forms side by side and the exact analyzer errors each produces.
Use Callable[[int, str], bool] for simple positional callbacks. Switch to a Protocol with
__call__ when the callback needs keyword/named parameters, default values, @overload, or
attributes on the function object — Callable cannot express any of those.
Both forms describe the shape of something callable, and both are checked structurally — any
function whose signature matches is accepted, no inheritance required. The difference is purely
expressive power. Callable is a fixed two-slot shape (parameter list, return type) introduced
with the original typing module; a calling Protocol (PEP 544, Python 3.8+) lets you write a
full def __call__(...) signature with everything a real function signature supports.
Protocol can express everything Callable can, plus named params, defaults, overloads and attributes.Step 1: Start with Callable for simple positional callbacks
When a callback takes a couple of positional arguments and returns a value, Callable is the
clearest annotation. It lives in collections.abc (use typing.Callable only on Python 3.8).
# Python 3.11+, mypy 1.x
from collections.abc import Callable
def run_retry(should_retry: Callable[[int, str], bool], code: int, reason: str) -> None:
if should_retry(code, reason):
...
def by_status(code: int, reason: str) -> bool:
return code >= 500
run_retry(by_status, 503, "upstream timeout") # OK
The analyzer reads Callable[[int, str], bool] as “any callable taking exactly two positional
arguments of those types, returning bool.” Passing a callable with the wrong arity or types is a
clear error.
# Python 3.11+, mypy 1.x
def wrong_arity(code: int) -> bool: # missing the second parameter
return code >= 500
run_retry(wrong_arity, 503, "timeout")
# mypy error: [arg-type] — "Callable[[int], bool]" not assignable to "Callable[[int, str], bool]"
# pyright: reportArgumentType
Step 2: Reach for a Protocol when callers pass keyword arguments
Callable[[int, str], bool] describes positional parameters only — the names int and str are
types, not parameter names. If your call site uses keyword arguments, Callable cannot guarantee
the parameter names exist. A calling Protocol names them.
# Python 3.11+, mypy 1.x
from typing import Protocol
class RetryPolicy(Protocol):
def __call__(self, code: int, *, reason: str) -> bool: ...
def run_retry(should_retry: RetryPolicy, code: int, reason: str) -> None:
if should_retry(code, reason=reason): # keyword arg is part of the contract
...
def by_status(code: int, *, reason: str) -> bool:
return code >= 500
run_retry(by_status, 503, "timeout") # OK — names and kinds match
If a caller invokes the callback with a keyword the type does not declare, the analyzer catches it at the call site:
# Python 3.11+, mypy 1.x
def run_retry(should_retry: RetryPolicy, code: int) -> None:
should_retry(code, retries=3)
# mypy error: [call-arg] — unexpected keyword argument "retries" for "__call__"
# pyright: reportCallIssue
Step 3: Use a Protocol for defaults, overloads, and attributes
Three more things Callable simply cannot express, all of which a Protocol __call__ handles
naturally:
# Python 3.11+, mypy 1.x
from typing import Protocol, overload
class Serializer(Protocol):
name: str # attribute on the callable object
@overload
def __call__(self, payload: dict[str, int]) -> str: ...
@overload
def __call__(self, payload: list[int], *, pretty: bool = False) -> bytes: ...
def __call__(self, payload: object, *, pretty: bool = False) -> str | bytes: ...
def register(serializer: Serializer) -> None:
print(serializer.name) # attribute access type-checks
serializer({"id": 1}) # picks the first overload
A bare Callable annotation here would lose the overload resolution, the pretty default, and the
.name attribute. Accessing serializer.name through a Callable annotation is an error:
# Python 3.11+, mypy 1.x
from collections.abc import Callable
def register(serializer: Callable[[object], str | bytes]) -> None:
print(serializer.name)
# mypy error: [attr-defined] — "Callable[..., str | bytes]" has no attribute "name"
# pyright: reportAttributeAccessIssue
Edge cases
- Arbitrary signatures with
Callable[..., R]: The literal ellipsis means “any parameters.” It silences arity and keyword checks entirely, so prefer aProtocolwhen you actually know the shape —Callable[..., R]is an escape hatch, not a precise type. - Generic callbacks: A
Protocolcan be generic —class Transform[T](Protocol)withdef __call__(self, value: T) -> T— letting the return type track the argument.Callablecannot bind a TypeVar across its own parameter and return in the same flexible way withoutParamSpec. - Positional-only callbacks: If you genuinely need positional-only parameters in a
Protocol, add a/marker:def __call__(self, code: int, /) -> bool. This makes theProtocolmatch a plainCallable[[int], bool]exactly, with no name leakage.
Common mistakes
- Assuming
Callableparameter names matter. InCallable[[int, str], bool]the entries are types only. Calling withcode=...against aCallableannotation raises[call-arg]because no names are declared. Switch to aProtocolto allow keyword calls. - Over-widening with
Callable[..., Any]. This accepts every callable and suppresses[arg-type]and[call-arg]reports, hiding real mismatches. Reserve it for decorators or truly dynamic dispatch. - Forgetting
@runtime_checkablethen callingisinstance. A plain callingProtocolraisesTypeErrorat runtime if used withisinstance. Add the decorator, and remember it only checks for a__call__attribute —# mypy: notethe structural check is shallow.
FAQ
Is a Protocol slower than Callable?
No. Both vanish at runtime. The Protocol class object is constructed once at import; it adds no
per-call overhead because annotations are never evaluated on each call.
Can I convert a Callable annotation to a Protocol without breaking callers?
Yes, as long as the __call__ signature is structurally identical. Because both are checked
structurally, every function already accepted by the Callable form is accepted by an equivalent
Protocol.