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.

TL;DR

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.

Callable versus a calling Protocol Callable covers positional parameter types and the return type; a Protocol with __call__ additionally covers named parameters, defaults, overloads and attributes on the callable. Callable[[int, str], bool] positional param types return type no keyword names no defaults / overloads no attributes class Handler(Protocol) def __call__(self, …) -> bool named params: code, retries=0 keyword-only args @overload signatures attributes: handler.name a strict superset
A calling 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).

How a Callable annotation is checked A passed function is verified in sequence for arity, parameter types and return type before it is accepted. by_status candidate arity = 2? types match? returns bool? accept
A Callable annotation checks arity, parameter types and the return type — and nothing else.
# 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.

Keyword arguments need named parameters A Callable annotation has no parameter names so a keyword call is rejected; a Protocol declares the names so the same keyword call is accepted. Callable Callable[[int, str], bool] parameters have no names f(code=503, reason="x") rejected: [call-arg] Protocol __call__(self, code, *, reason) names are part of the type f(code=503, reason="x") accepted
Only the Protocol carries parameter names, so only it can promise a keyword call is valid.
# 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:

Features that require a Protocol Default parameter values, overloaded call signatures and attributes on the callable each require a Protocol rather than a Callable. default values: pretty=False @overload call signatures attributes: handler.name class Serializer(Protocol) one __call__ carries them all
Defaults, overloads and attributes each fall outside Callable's two slots; a Protocol gathers them into one type.
# 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 Protocoldef __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 a Protocol when you actually know the shape — Callable[..., R] is an escape hatch, not a precise type.
  • Generic callbacks: A Protocol can be generic — class Transform[T](Protocol) with def __call__(self, value: T) -> T — letting the return type track the argument. Callable cannot bind a TypeVar across its own parameter and return in the same flexible way without ParamSpec.
  • Positional-only callbacks: If you genuinely need positional-only parameters in a Protocol, add a / marker: def __call__(self, code: int, /) -> bool. This makes the Protocol match a plain Callable[[int], bool] exactly, with no name leakage.
  • Forwarding a signature verbatim: When a decorator must preserve the wrapped function’s exact parameters, neither Callable nor a hand-written Protocol fits — reach for ParamSpec and Concatenate (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.

Choosing Callable, Protocol or ParamSpec Pick Callable for simple positional callbacks, a Protocol when names, defaults, overloads or attributes are needed, and ParamSpec when forwarding a signature unchanged. what must it express? names / defaults / overloads? no, positional only yes forward signature Callable[[...], R] Protocol.__call__ ParamSpec / Concatenate a Protocol is a strict superset of Callable
Callable for the simple case, a Protocol when the contract is richer, ParamSpec when a signature must pass through unchanged.

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 Callable parameter names matter. In Callable[[int, str], bool] the entries are types only. Calling with code=... against a Callable annotation raises [call-arg] because no names are declared. Switch to a Protocol to 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_checkable then calling isinstance. A plain calling Protocol raises TypeError at runtime if used with isinstance. Add the decorator, and remember it only checks for a __call__ attribute — the structural check never inspects parameter types.
  • Using a Protocol where a plain function type would do. A one-argument positional callback with no attributes gains nothing from a Protocol; the extra class is noise. Keep Callable until a concrete need — a keyword, a default, an overload, an attribute — forces the upgrade.
  • Confusing the two import homes. Import Callable from collections.abc, not from typing, in new code; typing.Callable still 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.
Callback typing pitfalls to avoid Four recurring mistakes: relying on Callable parameter names, over-widening with Callable of Any, calling isinstance without runtime_checkable, and over-using Protocol. x expecting Callable to carry parameter names x over-widening with Callable[..., Any] x isinstance on a Protocol without @runtime_checkable x a Protocol where plain Callable would do
Four avoidable callback-typing mistakes — mostly about reaching for the wrong precision level.
Runtime vs static analysis Both annotations are erased at runtime — Python does not check that a passed callable actually matches the declared signature, keyword names, or overloads. A `Protocol` is not enforced unless you decorate it with `@runtime_checkable` and call `isinstance`, and even then only the *existence* of `__call__` is verified, never its parameter types. The richer contract exists entirely for the type checker.

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.

Back to Callable Signatures