Python often relies on duck typing: if an object supports the operation a function needs, its concrete class does not matter. typing.Protocol gives static type checkers a way to describe that idea explicitly without requiring implementations to inherit from a shared base class.
Define the behavior you consume
from typing import Protocol
class ByteWriter(Protocol):
def write(self, data: bytes) -> int: ...
def emit_header(writer: ByteWriter) -> None:
writer.write(b"NLR1")Any statically compatible object can satisfy ByteWriter, even if its class never mentions the protocol.
This is structural typing: compatibility is based on available members rather than nominal inheritance.
Keep protocols small
A protocol is most useful when it captures the minimum behavior a consumer needs. If a function only calls write, do not require close, flush, configuration properties, and unrelated methods “for completeness.”
Small protocols reduce coupling and make tests easier because a lightweight fake can implement only the relevant behavior.
Use properties when callers need attributes
class Job(Protocol):
@property
def name(self) -> str: ...
def run(self) -> None: ...Choose the protocol shape based on how consumers use the object. Do not expose implementation details merely to satisfy a type checker.
Runtime checks are optional and limited
Protocols primarily serve static analysis. @runtime_checkable allows limited isinstance checks:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...Runtime protocol checks only verify the presence of members, not full type signatures. Do not use them as a substitute for validation or authorization.
Protocols versus abstract base classes
Use an abstract base class when you intentionally own an inheritance hierarchy, want shared implementation, or need nominal runtime identity. Use a protocol when consumers care about a capability and implementations may come from unrelated libraries or application layers.
The two approaches can coexist. A concrete class may inherit from an ABC and still structurally satisfy several protocols.
Common pitfalls
Designing protocols around providers
Start from what the consuming function needs, not every method a large service happens to expose. Consumer-driven interfaces stay smaller.
Creating a protocol for every class
Concrete types are often clearer inside a module. Protocols earn their cost at boundaries where multiple implementations, tests, or external types are expected.
Assuming type hints enforce runtime behavior
Python does not automatically reject incompatible objects at runtime because a parameter is annotated with a protocol. Static checking and runtime validation solve different problems.
Making mutable attributes too permissive
Writable protocol attributes impose both read and write expectations. Prefer read-only properties when mutation is not part of the contract.
Use structural interfaces deliberately
Protocols let Python code keep the flexibility of duck typing while documenting and statically checking important boundaries. The best protocols are narrow, consumer-oriented, and focused on behavior rather than class ancestry.