Python Protocols and Structural Subtyping for Flexible APIs
Python code often depends on behavior rather than a specific class hierarchy. A function may only need an object with a send() method, a read() method, or a pair of repository operations.
typing.Protocol lets type checkers describe those behavioral requirements directly. A class satisfies a protocol by having compatible members; it does not need to inherit from the protocol.
This is structural subtyping: “if it has the required shape, it can be used here.”
A small protocol is an explicit contract
The Protocol type is available in Python’s standard typing module in supported modern Python releases.
Consider a service that only needs to send a notification:
from typing import Protocol
class Notifier(Protocol):
def send(self, recipient: str, message: str) -> None:
...
def publish_alert(
notifier: Notifier,
recipient: str,
message: str,
) -> None:
notifier.send(recipient, message)A concrete implementation does not inherit from Notifier:
class ConsoleNotifier:
def send(self, recipient: str, message: str) -> None:
print(f"{recipient}: {message}")
publish_alert(ConsoleNotifier(), "user@example.com", "Build finished")A static type checker can accept ConsoleNotifier because its send method matches the protocol.
Why not use a base class?
Abstract base classes are useful when implementations should share runtime identity, common behavior, registration, or enforced inheritance.
Protocols are useful when the consumer only needs a capability.
This distinction matters for reusable code. Requiring every compatible object to inherit from your base class can make integrations harder, especially when the object comes from another package that you do not control.
A protocol lets existing classes participate without modifying their inheritance tree.
Define protocols from the consumer side
Do not start by copying every public method from a large implementation class.
Instead, ask what a particular function actually uses.
If a report generator only needs:
class UserLookup(Protocol):
def find_name(self, user_id: int) -> str | None:
...then do not add create_user, delete_user, transaction methods, cache controls, or unrelated properties.
Small consumer-driven protocols have several benefits:
- callers have fewer assumptions;
- test doubles are easy to implement;
- implementations can evolve independently;
- type errors point to the capability that is actually missing.
Protocols make tests lightweight
Because no inheritance is required, a test can provide a tiny fake:
class FakeNotifier:
def __init__(self) -> None:
self.messages: list[tuple[str, str]] = []
def send(self, recipient: str, message: str) -> None:
self.messages.append((recipient, message))The fake models only the behavior the test cares about.
This is often clearer than mocking a large production class with dozens of irrelevant methods.
Protocols do not eliminate the need for integration tests. They make unit boundaries explicit; they do not prove that a real SMTP, HTTP, or database implementation behaves correctly.
Use attributes when they are part of the contract
Protocols can describe data attributes as well as methods:
class Task(Protocol):
id: str
priority: intBe cautious with mutable attributes because variance and setter expectations can become more restrictive than expected.
When consumers only need to read a value, a read-only property can express intent more clearly:
class Task(Protocol):
@property
def id(self) -> str:
...Generic protocols preserve useful types
A protocol can be generic when the capability works across value types.
For example, a source can expose a typed get operation. The exact generic syntax depends on the minimum Python version and type-checking environment you support, so libraries should choose syntax that matches their declared compatibility range.
The design principle remains the same: preserve the relationship between input and output types without forcing implementations into a shared inheritance hierarchy.
runtime_checkable has a narrow purpose
Protocols are primarily for static typing. If you decorate one with @runtime_checkable, Python allows checks such as:
isinstance(value, SomeProtocol)That runtime check only verifies the presence of protocol attributes according to Python’s runtime protocol rules. It does not perform full static signature validation.
Do not use runtime protocol checks as a replacement for normal input validation or a static type checker.
Protocols and dependency injection
Protocols fit naturally with dependency injection, but no framework is required.
Constructor injection can be simple:
class AlertService:
def __init__(self, notifier: Notifier) -> None:
self.notifier = notifierProduction code supplies a real notifier. Tests supply a fake. The service depends on the small protocol rather than on a concrete vendor-specific client.
This reduces coupling without introducing a service locator or reflection-heavy container.
Common pitfalls
Creating one enormous protocol
A “god interface” defeats structural typing’s main advantage. Split capabilities according to consumer needs.
Adding methods because an implementation has them
Protocols should describe required behavior, not mirror implementation inventory.
Expecting runtime enforcement
Type hints do not automatically reject incompatible values at runtime.
Using protocols where a plain callable is clearer
If a dependency is just one function, Callable may express the requirement more directly than a named protocol. A protocol becomes more useful for multiple related members or when a semantic interface name improves readability.
Prefer capability-oriented boundaries
Protocols work best when they make dependencies smaller and more explicit. Define the behavior a consumer needs, type against that behavior, and allow production implementations and test doubles to satisfy it structurally.
The result is ordinary Python code with fewer inheritance constraints, better static documentation, and test seams that reflect real capabilities rather than the shape of a large concrete class.