|$ curl https://forge-ai.dev/api/markdown?path=docs/python/protocols
$cat docs/python-—-protocols.md
updated Last week·20 min read·published
Python — Protocols
Introduction
Protocols (Python 3.8+) enable structural subtyping — you define the expected interface as a type, and any class that implements those methods satisfies the type automatically, without inheritance. This is "duck typing made explicit."
Unlike ABCs, protocols don't require classes to explicitly inherit from them. The type checker verifies structural compatibility: if it has the right methods and attributes, it matches the protocol.
Protocol Basics
protocol_basics.py
Python
| 1 | from typing import Protocol |
| 2 | |
| 3 | # Define a protocol — describes the expected interface |
| 4 | class Drawable(Protocol): |
| 5 | def draw(self) -> str: ... |
| 6 | def area(self) -> float: ... |
| 7 | |
| 8 | # This class matches Drawable — no inheritance needed |
| 9 | class Circle: |
| 10 | def __init__(self, radius: float): |
| 11 | self.radius = radius |
| 12 | |
| 13 | def draw(self) -> str: |
| 14 | return f"Circle(r={self.radius})" |
| 15 | |
| 16 | def area(self) -> float: |
| 17 | return 3.14159 * self.radius ** 2 |
| 18 | |
| 19 | class Rectangle: |
| 20 | def __init__(self, width: float, height: float): |
| 21 | self.width = width |
| 22 | self.height = height |
| 23 | |
| 24 | def draw(self) -> str: |
| 25 | return f"Rectangle({self.width}x{self.height})" |
| 26 | |
| 27 | def area(self) -> float: |
| 28 | return self.width * self.height |
| 29 | |
| 30 | # Function accepts any object that matches the Drawable protocol |
| 31 | def render(shape: Drawable) -> None: |
| 32 | print(f"Drawing: {shape.draw()}") |
| 33 | print(f"Area: {shape.area()}") |
| 34 | |
| 35 | # Both work — neither inherits from Drawable |
| 36 | render(Circle(5)) # ✓ |
| 37 | render(Rectangle(3, 4)) # ✓ |
| 38 | |
| 39 | # The type checker verifies structural compatibility |
| 40 | # If a class is missing draw() or area(), mypy will flag it |
Protocol Members
protocol_members.py
Python
| 1 | from typing import Protocol, ClassVar |
| 2 | |
| 3 | # Protocols can define methods, attributes, and class variables |
| 4 | class Serializable(Protocol): |
| 5 | def to_dict(self) -> dict: ... |
| 6 | |
| 7 | @classmethod |
| 8 | def from_dict(cls, data: dict) -> "Serializable": ... |
| 9 | |
| 10 | # Protocol with attributes |
| 11 | class Named(Protocol): |
| 12 | name: str |
| 13 | |
| 14 | class Employee: |
| 15 | def __init__(self, name: str, role: str): |
| 16 | self.name = name |
| 17 | self.role = role |
| 18 | |
| 19 | def greet(entity: Named) -> str: |
| 20 | return f"Hello, {entity.name}!" |
| 21 | |
| 22 | greet(Employee("Alice", "Engineer")) # ✓ — has .name |
| 23 | |
| 24 | # Read-only attributes in protocols |
| 25 | import typing |
| 26 | |
| 27 | class ReadableBuffer(Protocol): |
| 28 | @property |
| 29 | def nbytes(self) -> int: ... |
| 30 | |
| 31 | # Protocol with callable |
| 32 | class CallableObj(Protocol): |
| 33 | def __call__(self, x: int, y: int) -> int: ... |
| 34 | |
| 35 | class Adder: |
| 36 | def __call__(self, x: int, y: int) -> int: |
| 37 | return x + y |
| 38 | |
| 39 | def apply(op: CallableObj, a: int, b: int) -> int: |
| 40 | return op(a, b) |
| 41 | |
| 42 | apply(Adder(), 3, 4) # ✓ — Adder is callable with (int, int) -> int |
| 43 | |
| 44 | # Protocol with generic |
| 45 | from typing import Protocol, TypeVar |
| 46 | |
| 47 | T = TypeVar("T") |
| 48 | |
| 49 | class SupportsLessThan(Protocol): |
| 50 | def __lt__(self, other: "SupportsLessThan") -> bool: ... |
| 51 | |
| 52 | def smallest(items: list[SupportsLessThan]) -> SupportsLessThan: |
| 53 | return min(items) |
| 54 | |
| 55 | smallest([1, 2, 3]) # ✓ — int supports < |
| 56 | smallest(["a", "b"]) # ✓ — str supports < |
runtime_checkable
By default, protocols are only checked statically by type checkers. Adding @runtime_checkable enables isinstance() checks at runtime, but only for method existence — not signatures.
runtime_check.py
Python
| 1 | from typing import Protocol, runtime_checkable |
| 2 | |
| 3 | @runtime_checkable |
| 4 | class Closeable(Protocol): |
| 5 | def close(self) -> None: ... |
| 6 | |
| 7 | class FileHandle: |
| 8 | def __init__(self, path: str): |
| 9 | self.path = path |
| 10 | |
| 11 | def close(self) -> None: |
| 12 | print(f"Closing {self.path}") |
| 13 | |
| 14 | class NotCloseable: |
| 15 | pass |
| 16 | |
| 17 | # isinstance works with @runtime_checkable |
| 18 | fh = FileHandle("/tmp/test.txt") |
| 19 | nc = NotCloseable() |
| 20 | |
| 21 | print(isinstance(fh, Closeable)) # True — has close() |
| 22 | print(isinstance(nc, Closeable)) # False — no close() |
| 23 | |
| 24 | # Runtime check only verifies method EXISTS, not its signature |
| 25 | class BadCloseable: |
| 26 | def close(self, extra: int, another: str) -> dict: |
| 27 | return {} |
| 28 | |
| 29 | print(isinstance(BadCloseable(), Closeable)) # True! (signature not checked) |
| 30 | |
| 31 | # Use in function to guard behavior |
| 32 | def safe_close(obj: object) -> None: |
| 33 | if isinstance(obj, Closeable): |
| 34 | obj.close() |
| 35 | else: |
| 36 | print(f"Cannot close {type(obj).__name__}") |
| 37 | |
| 38 | safe_close(FileHandle("/tmp/test")) # Closing /tmp/test |
| 39 | safe_close(NotCloseable()) # Cannot close NotCloseable |
| 40 | |
| 41 | # Combine with regular type checking |
| 42 | def process(resource: Closeable) -> None: |
| 43 | # Static type checker knows resource has .close() |
| 44 | # Runtime isinstance check can guard against bad inputs |
| 45 | resource.close() |
ℹ
info
@runtime_checkable only checks if the methods exist, not their parameter types or return types. For full type safety, rely on static type checkers like mypy or pyright.
Duck Typing with Protocols
duck_typing.py
Python
| 1 | from typing import Protocol, Any |
| 2 | |
| 3 | # Before protocols — bare duck typing |
| 4 | def process_stream_old(stream: Any) -> str: |
| 5 | # No type safety — if stream lacks read(), we get a runtime error |
| 6 | return stream.read(1024) |
| 7 | |
| 8 | # After protocols — explicit duck typing |
| 9 | class Readable(Protocol): |
| 10 | def read(self, n: int = -1) -> bytes: ... |
| 11 | |
| 12 | def process_stream(stream: Readable) -> bytes: |
| 13 | # Type checker verifies stream has read() |
| 14 | return stream.read(1024) |
| 15 | |
| 16 | # Works with file objects, BytesIO, sockets, etc. |
| 17 | from io import BytesIO |
| 18 | buf = BytesIO(b"hello world") |
| 19 | process_stream(buf) # ✓ BytesIO has read() |
| 20 | |
| 21 | # Real-world example: logging |
| 22 | class Loggable(Protocol): |
| 23 | def log(self, message: str, level: str = "INFO") -> None: ... |
| 24 | |
| 25 | class ConsoleLogger: |
| 26 | def log(self, message: str, level: str = "INFO") -> None: |
| 27 | print(f"[{level}] {message}") |
| 28 | |
| 29 | class FileLogger: |
| 30 | def __init__(self, path: str): |
| 31 | self.path = path |
| 32 | |
| 33 | def log(self, message: str, level: str = "INFO") -> None: |
| 34 | with open(self.path, "a") as f: |
| 35 | f.write(f"[{level}] {message}\n") |
| 36 | |
| 37 | def send_alert(logger: Loggable, message: str) -> None: |
| 38 | logger.log(f"ALERT: {message}", level="CRITICAL") |
| 39 | |
| 40 | send_alert(ConsoleLogger(), "Disk full") # ✓ |
| 41 | send_alert(FileLogger("/var/log/app.log"), "Disk full") # ✓ |
| 42 | |
| 43 | # Third-party objects match too — no modification needed |
| 44 | # If httpx.Response has json(), it satisfies a JsonReadable protocol |
| 45 | class JsonReadable(Protocol): |
| 46 | def json(self) -> Any: ... |
| 47 | |
| 48 | def handle_response(resp: JsonReadable) -> Any: |
| 49 | return resp.json() # works with requests.Response, httpx.Response, etc. |
Protocol vs ABC
vs_abc.py
Python
| 1 | from abc import ABC, abstractmethod |
| 2 | from typing import Protocol |
| 3 | |
| 4 | # ABC — nominal typing (explicit inheritance required) |
| 5 | class AbstractDrawable(ABC): |
| 6 | @abstractmethod |
| 7 | def draw(self) -> str: ... |
| 8 | |
| 9 | @abstractmethod |
| 10 | def area(self) -> float: ... |
| 11 | |
| 12 | # Must explicitly inherit — otherwise isinstance fails |
| 13 | class CircleABC(AbstractDrawable): # ← inheritance required |
| 14 | def __init__(self, r: float): |
| 15 | self.r = r |
| 16 | |
| 17 | def draw(self) -> str: |
| 18 | return f"Circle({self.r})" |
| 19 | |
| 20 | def area(self) -> float: |
| 21 | return 3.14159 * self.r ** 2 |
| 22 | |
| 23 | # Protocol — structural typing (no inheritance needed) |
| 24 | class Drawable(Protocol): |
| 25 | def draw(self) -> str: ... |
| 26 | def area(self) -> float: ... |
| 27 | |
| 28 | class CircleProto: # ← NO inheritance |
| 29 | def __init__(self, r: float): |
| 30 | self.r = r |
| 31 | |
| 32 | def draw(self) -> str: |
| 33 | return f"Circle({self.r})" |
| 34 | |
| 35 | def area(self) -> float: |
| 36 | return 3.14159 * self.r ** 2 |
| 37 | |
| 38 | # Comparison: |
| 39 | # ABC: Protocol: |
| 40 | # - Explicit inheritance - No inheritance needed |
| 41 | # - isinstance() works - isinstance needs @runtime_checkable |
| 42 | # - Can have implementation - Can have default methods (3.12+) |
| 43 | # - Tight coupling - Loose coupling |
| 44 | # - Good for your own classes - Good for third-party types |
| 45 | # - Shared base behavior - Interface description only |
| 46 | |
| 47 | # When to use ABC: |
| 48 | # - You need shared implementation |
| 49 | # - You control all subclasses |
| 50 | # - You want强制 inheritance contract |
| 51 | |
| 52 | # When to use Protocol: |
| 53 | # - Type-checking third-party objects |
| 54 | # - You want loose coupling |
| 55 | # - Duck typing with static safety |
| 56 | # - You can't modify the implementing class |
✓
best practice
Use Protocols when you want to type-check objects you don't control (third-party libs, stdlib types). Use ABCs when you need shared implementation or want to enforce an inheritance hierarchy in your own codebase.
Advanced Patterns
advanced.py
Python
| 1 | from typing import Protocol, TypeVar, runtime_checkable |
| 2 | |
| 3 | # Extending protocols |
| 4 | class Writable(Protocol): |
| 5 | def write(self, data: bytes) -> int: ... |
| 6 | |
| 7 | class Seekable(Protocol): |
| 8 | def seek(self, offset: int, whence: int = 0) -> int: ... |
| 9 | |
| 10 | class FileLike(Writable, Seekable, Protocol): |
| 11 | """Combination protocol — must satisfy both.""" |
| 12 | def read(self, n: int = -1) -> bytes: ... |
| 13 | |
| 14 | # Generic protocols |
| 15 | T = TypeVar("T") |
| 16 | |
| 17 | class Container(Protocol): |
| 18 | def __contains__(self, item: object) -> bool: ... |
| 19 | def __len__(self) -> int: ... |
| 20 | |
| 21 | def print_length(c: Container) -> None: |
| 22 | print(f"Length: {len(c)}") |
| 23 | |
| 24 | print_length([1, 2, 3]) # ✓ list matches |
| 25 | print_length({"a": 1}) # ✓ dict matches |
| 26 | print_length({1, 2, 3}) # ✓ set matches |
| 27 | |
| 28 | # Protocol with default implementation (Python 3.12+) |
| 29 | # class DefaultProtocol(Protocol): |
| 30 | # def method(self) -> str: |
| 31 | # return "default" # concrete default |
| 32 | |
| 33 | # Annotated protocols |
| 34 | from typing import Annotated |
| 35 | |
| 36 | PositiveInt = Annotated[int, "must be > 0"] |
| 37 | |
| 38 | class Configurable(Protocol): |
| 39 | def configure(self, timeout: PositiveInt) -> None: ... |
| 40 |
$Blueprint — Engineering Documentation·Section ID: PYTHON-PROTO·Revision: 1.0