|$ 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

PythonAdvanced🎯Free Tools
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
1from typing import Protocol
2
3# Define a protocol — describes the expected interface
4class Drawable(Protocol):
5 def draw(self) -> str: ...
6 def area(self) -> float: ...
7
8# This class matches Drawable — no inheritance needed
9class 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
19class 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
31def render(shape: Drawable) -> None:
32 print(f"Drawing: {shape.draw()}")
33 print(f"Area: {shape.area()}")
34
35# Both work — neither inherits from Drawable
36render(Circle(5)) # ✓
37render(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
1from typing import Protocol, ClassVar
2
3# Protocols can define methods, attributes, and class variables
4class Serializable(Protocol):
5 def to_dict(self) -> dict: ...
6
7 @classmethod
8 def from_dict(cls, data: dict) -> "Serializable": ...
9
10# Protocol with attributes
11class Named(Protocol):
12 name: str
13
14class Employee:
15 def __init__(self, name: str, role: str):
16 self.name = name
17 self.role = role
18
19def greet(entity: Named) -> str:
20 return f"Hello, {entity.name}!"
21
22greet(Employee("Alice", "Engineer")) # ✓ — has .name
23
24# Read-only attributes in protocols
25import typing
26
27class ReadableBuffer(Protocol):
28 @property
29 def nbytes(self) -> int: ...
30
31# Protocol with callable
32class CallableObj(Protocol):
33 def __call__(self, x: int, y: int) -> int: ...
34
35class Adder:
36 def __call__(self, x: int, y: int) -> int:
37 return x + y
38
39def apply(op: CallableObj, a: int, b: int) -> int:
40 return op(a, b)
41
42apply(Adder(), 3, 4) # ✓ — Adder is callable with (int, int) -> int
43
44# Protocol with generic
45from typing import Protocol, TypeVar
46
47T = TypeVar("T")
48
49class SupportsLessThan(Protocol):
50 def __lt__(self, other: "SupportsLessThan") -> bool: ...
51
52def smallest(items: list[SupportsLessThan]) -> SupportsLessThan:
53 return min(items)
54
55smallest([1, 2, 3]) # ✓ — int supports <
56smallest(["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
1from typing import Protocol, runtime_checkable
2
3@runtime_checkable
4class Closeable(Protocol):
5 def close(self) -> None: ...
6
7class FileHandle:
8 def __init__(self, path: str):
9 self.path = path
10
11 def close(self) -> None:
12 print(f"Closing {self.path}")
13
14class NotCloseable:
15 pass
16
17# isinstance works with @runtime_checkable
18fh = FileHandle("/tmp/test.txt")
19nc = NotCloseable()
20
21print(isinstance(fh, Closeable)) # True — has close()
22print(isinstance(nc, Closeable)) # False — no close()
23
24# Runtime check only verifies method EXISTS, not its signature
25class BadCloseable:
26 def close(self, extra: int, another: str) -> dict:
27 return {}
28
29print(isinstance(BadCloseable(), Closeable)) # True! (signature not checked)
30
31# Use in function to guard behavior
32def safe_close(obj: object) -> None:
33 if isinstance(obj, Closeable):
34 obj.close()
35 else:
36 print(f"Cannot close {type(obj).__name__}")
37
38safe_close(FileHandle("/tmp/test")) # Closing /tmp/test
39safe_close(NotCloseable()) # Cannot close NotCloseable
40
41# Combine with regular type checking
42def 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
1from typing import Protocol, Any
2
3# Before protocols — bare duck typing
4def 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
9class Readable(Protocol):
10 def read(self, n: int = -1) -> bytes: ...
11
12def 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.
17from io import BytesIO
18buf = BytesIO(b"hello world")
19process_stream(buf) # ✓ BytesIO has read()
20
21# Real-world example: logging
22class Loggable(Protocol):
23 def log(self, message: str, level: str = "INFO") -> None: ...
24
25class ConsoleLogger:
26 def log(self, message: str, level: str = "INFO") -> None:
27 print(f"[{level}] {message}")
28
29class 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
37def send_alert(logger: Loggable, message: str) -> None:
38 logger.log(f"ALERT: {message}", level="CRITICAL")
39
40send_alert(ConsoleLogger(), "Disk full") # ✓
41send_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
45class JsonReadable(Protocol):
46 def json(self) -> Any: ...
47
48def handle_response(resp: JsonReadable) -> Any:
49 return resp.json() # works with requests.Response, httpx.Response, etc.
Protocol vs ABC
vs_abc.py
Python
1from abc import ABC, abstractmethod
2from typing import Protocol
3
4# ABC — nominal typing (explicit inheritance required)
5class 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
13class 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)
24class Drawable(Protocol):
25 def draw(self) -> str: ...
26 def area(self) -> float: ...
27
28class 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
1from typing import Protocol, TypeVar, runtime_checkable
2
3# Extending protocols
4class Writable(Protocol):
5 def write(self, data: bytes) -> int: ...
6
7class Seekable(Protocol):
8 def seek(self, offset: int, whence: int = 0) -> int: ...
9
10class FileLike(Writable, Seekable, Protocol):
11 """Combination protocol — must satisfy both."""
12 def read(self, n: int = -1) -> bytes: ...
13
14# Generic protocols
15T = TypeVar("T")
16
17class Container(Protocol):
18 def __contains__(self, item: object) -> bool: ...
19 def __len__(self) -> int: ...
20
21def print_length(c: Container) -> None:
22 print(f"Length: {len(c)}")
23
24print_length([1, 2, 3]) # ✓ list matches
25print_length({"a": 1}) # ✓ dict matches
26print_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
34from typing import Annotated
35
36PositiveInt = Annotated[int, "must be > 0"]
37
38class Configurable(Protocol):
39 def configure(self, timeout: PositiveInt) -> None: ...
40
$Blueprint — Engineering Documentation·Section ID: PYTHON-PROTO·Revision: 1.0