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.
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
Callable moved to collections.abc when generics landed there in Python 3.9; typing.Callable
is a deprecated alias kept for 3.8 and for from __future__ import annotations compatibility. The
two-slot form fixes only arity and parameter types — there is no syntax to mark a parameter
optional, give it a name, or type *args/**kwargs precisely. Parameter subtyping follows normal
contravariance: a callable declared Callable[[int], bool] is accepted where Callable[[bool], bool]
is expected (it accepts a wider input), but not the reverse. When you cannot enumerate the parameters
at all, Callable[..., bool] (a literal ellipsis) opts out of parameter checking entirely — handy
for decorators, dangerous everywhere else.
Two shapes come up constantly. A zero-argument callback is Callable[[], R] — an empty parameter
list, not Callable[R], which is a type error. And an async callback is typed by its return, not
by the async keyword: an async def is a function that returns a coroutine, so annotate it
Callable[[int], Awaitable[bool]] (or the more general Coroutine[Any, Any, bool]).
# Python 3.11+, mypy 1.x
from collections.abc import Callable, Awaitable
async def check(code: int) -> bool:
return code >= 500
def schedule(cb: Callable[[int], Awaitable[bool]]) -> None:
...
schedule(check) # OK — the async def matches Awaitable[bool]
Annotating that same parameter Callable[[int], bool] would reject check, because the callable
really returns a coroutine, not a bool. At runtime, isinstance(fn, collections.abc.Callable) is
valid and equivalent to callable(fn) — but it tests only that fn is callable, never its
signature. The parameter and return types in a Callable[...] annotation, like all annotations, are
erased; nothing verifies them when the callback is actually invoked.
One convenience follows from the fixed shape: when you pass a lambda directly, both checkers infer
its parameter types from the expected Callable. In run_retry(lambda code, reason: code >= 500, ...) the code and reason parameters are typed int and str with no annotation, because the
Callable[[int, str], bool] context flows inward. Assigning that lambda to an intermediate variable
first discards the context, and mypy then infers its parameters as Any — a frequent reason a lambda
type-checks inline yet loses its guarantees once it is refactored into a named variable.
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
The parameter kind is part of the contract too. Declaring def __call__(self, code: int, *, reason: str) makes reason keyword-only, so a positional should_retry(503, "x") is now the error, not the
keyword form — the Protocol pins down not just the names but whether each argument may be passed
positionally, by keyword, or either way. That precision is exactly what Callable, which knows only
“two positional slots,” cannot offer.
Optional parameters are the mirror image. A Protocol can declare
def __call__(self, code: int, retries: int = 0) -> bool, so both cb(500) and cb(500, retries=3)
type-check; Callable[[int], bool] forces exactly one argument and Callable[[int, int], bool]
forces two, with no way to say “the second is optional.” Whenever a callback has an argument some
callers omit, that alone is reason enough to prefer a Protocol.
This matters most for framework hooks. A web framework that invokes your handler as
handler(request=req, response=resp) has made the parameter names part of its public contract; a
Callable[[Request, Response], None] annotation cannot capture that, so a handler written
def h(req, resp) type-checks against the Callable yet fails at runtime with an unexpected-keyword
error. A calling Protocol that spells the names out turns that latent runtime failure into a
type error you see before shipping.
There is also no faithful Callable spelling for *args/**kwargs. A callback that genuinely
accepts (*args: int) or arbitrary keyword arguments can only be written Callable[..., R], which
discards all argument checking, or as a Protocol whose __call__ declares *args: int and
**kwargs: str explicitly. When the variadic shape carries meaning, the Protocol is once more the
only precise option.
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
The overload case is the one people underestimate. A function object’s own
@overload signatures are visible to the
checker only through a Protocol __call__; there is no Callable spelling that says “returns
str for a dict argument but bytes for a list.” The default value works the same way — the
pretty: bool = False slot lets serializer(data) and serializer(data, pretty=True) both check,
whereas a Callable would force every argument to be supplied. The Protocol is genuinely a strict
superset of what Callable can say.
Two mechanics of that overloaded __call__ deserve a note. First, like any overloaded function, the
Protocol still needs a final non-@overload __call__ signature — even though a Protocol method
has no body, that trailing signature is where the union return str | bytes is read from, and
omitting it is the same [misc] “single overload” error covered in
Function Overloading. Second, name: str
is an ordinary attribute declaration, so a matching object must expose a real name — an instance
attribute, class attribute, or property. A plain lambda, which carries no custom attributes, does
not satisfy Serializer, and mypy reports [arg-type] at the call site: structural matching checks
the attributes too, not just __call__.
The attribute case models a genuinely common object: a callable instance that also carries state. A
rate limiter written as a class with __call__ and a .remaining counter is exactly a
Serializer-style Protocol — def __call__(...) plus remaining: int. Typing the parameter as a
bare Callable would check the invocation but lose .remaining; the Protocol keeps both the call
and the state visible to callers, which is why stateful callbacks are the textbook reason to move off
Callable.
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. - Forwarding a signature verbatim: When a decorator must preserve the wrapped function’s exact
parameters, neither
Callablenor a hand-writtenProtocolfits — reach forParamSpecandConcatenate(Callable[Concatenate[int, P], R]), which propagate the original signature.
The forwarding case in that last bullet looks like this — the wrapper keeps the wrapped function’s
parameters exactly, so callers of timed(f) still see f’s own signature:
# Python 3.11+, mypy 1.x
from collections.abc import Callable
from typing import ParamSpec, TypeVar
import functools
P = ParamSpec("P")
R = TypeVar("R")
def timed(fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(*args, **kwargs)
return inner
Callable[P, R] carries the entire signature through the decorator; a Protocol would have to
restate every parameter and a plain Callable[..., R] would erase them. A generic calling Protocol,
by contrast, is the tool when the return type must track a single argument — something Callable
cannot do without ParamSpec gymnastics:
# Python 3.12+, mypy 1.11+
from typing import Protocol
class Transform[T](Protocol):
def __call__(self, value: T) -> T: ...
def apply_twice[T](f: Transform[T], x: T) -> T:
return f(f(x))
apply_twice(lambda n: n + 1, 3) # T = int, returns int
Marking a calling Protocol @runtime_checkable lets isinstance(obj, RetryPolicy) run, but the
check is deliberately shallow: it verifies only that obj has a __call__ attribute, never that the
signature matches, so isinstance(lambda: 0, RetryPolicy) is True even though that lambda takes no
arguments. Treat a runtime protocol check as a coarse “is this callable at all” gate and keep the real
signature enforcement in the type checker.
Common mistakes
These mistakes share a single theme — reaching for the wrong level of precision — and each has a clear tell in the analyzer output:
- 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 — the structural check never inspects parameter types. - Using a
Protocolwhere a plain function type would do. A one-argument positional callback with no attributes gains nothing from aProtocol; the extra class is noise. KeepCallableuntil a concrete need — a keyword, a default, an overload, an attribute — forces the upgrade. - Confusing the two import homes. Import
Callablefromcollections.abc, not fromtyping, in new code;typing.Callablestill works but is a deprecated alias, and mixing the two in one module is a common source of confusing “incompatible type” messages when versions of typeshed disagree on their identity.
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.