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).

# 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
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.

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.

Common mistakes

  • 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 — # mypy: note the 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.

Back to Callable Signatures