Protocol: Structural Subtyping
Protocol (Python 3.8+) defines types by structure (methods/attributes) alone, without explicit inheritance. It applies static type hints to duck typing, allowing type checkers to verify whether a class implements a specific interface.
Protocol vs ABC
from abc import ABC, abstractmethod
from typing import Protocol
# ABC approach: explicit inheritance required
class Drawable(ABC):
@abstractmethod
def draw(self) -> str: ...
class Circle(Drawable): # Must inherit Drawable
def draw(self) -> str:
return "Drawing circle"
# Protocol approach: no inheritance needed, just matching structure
class Renderable(Protocol):
def render(self) -> str: ...
class Square: # No need to inherit Renderable!
def render(self) -> str:
return "Rendering square"
class Triangle:
def render(self) -> str:
return "Rendering triangle"
def display(item: Renderable) -> None:
"""Works with anything satisfying the Renderable Protocol"""
print(item.render())
display(Square()) # Rendering square
display(Triangle()) # Rendering triangle
# display("hello") # mypy reports error: str has no render()
Using typing.Protocol
from typing import Protocol, runtime_checkable
class Serializable(Protocol):
"""Protocol for serializable objects"""
def to_dict(self) -> dict:
"""Convert to dictionary"""
...
def to_json(self) -> str:
"""Convert to JSON string"""
...
@classmethod
def from_dict(cls, data: dict) -> "Serializable":
"""Create object from dictionary"""
...
class User:
def __init__(self, id: int, name: str, email: str):
self.id = id
self.name = name
self.email = email
def to_dict(self) -> dict:
return {"id": self.id, "name": self.name, "email": self.email}
def to_json(self) -> str:
import json
return json.dumps(self.to_dict(), ensure_ascii=False)
@classmethod
def from_dict(cls, data: dict) -> "User":
return cls(data["id"], data["name"], data["email"])
class Product:
def __init__(self, sku: str, name: str, price: float):
self.sku = sku
self.name = name
self.price = price
def to_dict(self) -> dict:
return {"sku": self.sku, "name": self.name, "price": self.price}
def to_json(self) -> str:
import json
return json.dumps(self.to_dict(), ensure_ascii=False)
@classmethod
def from_dict(cls, data: dict) -> "Product":
return cls(data["sku"], data["name"], data["price"])
def save_to_cache(item: Serializable, key: str) -> None:
"""Anything satisfying the Serializable Protocol can be saved"""
data = item.to_json()
print(f"cache[{key}] = {data}")
# Both User and Product satisfy the Serializable Protocol
save_to_cache(User(1, "Alice", "alice@example.com"), "user:1")
save_to_cache(Product("P001", "Python Book", 35000), "product:P001")
runtime_checkable: Support for isinstance()
By default, Protocol is only used for static type checking. Adding the @runtime_checkable decorator enables runtime isinstance() checks.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Comparable(Protocol):
def __lt__(self, other) -> bool: ...
def __eq__(self, other) -> bool: ...
class Point:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __lt__(self, other: "Point") -> bool:
return (self.x ** 2 + self.y ** 2) < (other.x ** 2 + other.y ** 2)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
class Score:
def __init__(self, value: int):
self.value = value
def __lt__(self, other: "Score") -> bool:
return self.value < other.value
def __eq__(self, other: object) -> bool:
return isinstance(other, Score) and self.value == other.value
p = Point(3, 4)
s = Score(95)
# runtime_checkable enables isinstance()
print(isinstance(p, Comparable)) # True
print(isinstance(s, Comparable)) # True
print(isinstance("hello", Comparable)) # False
print(isinstance(42, Comparable)) # True (int supports comparison)
# Note: runtime_checkable only checks method existence, not signatures
Practical Protocol Patterns
Drawable Protocol
from typing import Protocol
class Drawable(Protocol):
"""Objects that can be drawn"""
x: float
y: float
def draw(self, canvas: "Canvas") -> None: ...
def bounding_box(self) -> tuple[float, float, float, float]: ...
class Canvas:
def __init__(self, width: int, height: int):
self.width = width
self.height = height
self._objects: list = []
def add(self, obj: Drawable) -> None:
self._objects.append(obj)
def render_all(self) -> None:
for obj in self._objects:
obj.draw(self)
class CircleShape:
def __init__(self, x: float, y: float, radius: float):
self.x = x
self.y = y
self.radius = radius
def draw(self, canvas: Canvas) -> None:
print(f"Drawing circle on canvas ({canvas.width}x{canvas.height}): "
f"center ({self.x}, {self.y}), radius={self.radius}")
def bounding_box(self) -> tuple[float, float, float, float]:
return (self.x - self.radius, self.y - self.radius,
self.x + self.radius, self.y + self.radius)
class RectShape:
def __init__(self, x: float, y: float, width: float, height: float):
self.x = x
self.y = y
self.width = width
self.height = height
def draw(self, canvas: Canvas) -> None:
print(f"Drawing rect on canvas: ({self.x}, {self.y}), {self.width}x{self.height}")
def bounding_box(self) -> tuple[float, float, float, float]:
return (self.x, self.y, self.x + self.width, self.y + self.height)
canvas = Canvas(800, 600)
canvas.add(CircleShape(100, 100, 50))
canvas.add(RectShape(200, 200, 100, 80))
canvas.render_all()
Serializable Protocol
from typing import Protocol, TypeVar
import json
T = TypeVar("T", bound="JsonSerializable")
class JsonSerializable(Protocol):
def to_json(self) -> str: ...
@classmethod
def from_json(cls: type[T], json_str: str) -> T: ...
class Config:
def __init__(self, host: str, port: int, debug: bool):
self.host = host
self.port = port
self.debug = debug
def to_json(self) -> str:
return json.dumps({"host": self.host, "port": self.port, "debug": self.debug})
@classmethod
def from_json(cls, json_str: str) -> "Config":
data = json.loads(json_str)
return cls(**data)
class UserSettings:
def __init__(self, theme: str, language: str, notifications: bool):
self.theme = theme
self.language = language
self.notifications = notifications
def to_json(self) -> str:
return json.dumps(vars(self))
@classmethod
def from_json(cls, json_str: str) -> "UserSettings":
return cls(**json.loads(json_str))
def save_settings(settings: JsonSerializable, filename: str) -> None:
json_str = settings.to_json()
print(f"Saving to {filename}: {json_str}")
def load_and_print(cls: type, json_str: str) -> None:
obj = cls.from_json(json_str)
print(f"Loaded: {vars(obj)}")
cfg = Config("localhost", 8080, True)
user_settings = UserSettings("dark", "en", True)
save_settings(cfg, "config.json")
save_settings(user_settings, "user.json")
Comparable Protocol
from typing import Protocol, TypeVar
T = TypeVar("T")
class Comparable(Protocol):
def __lt__(self, other: "Comparable") -> bool: ...
def __le__(self, other: "Comparable") -> bool: ...
def __gt__(self, other: "Comparable") -> bool: ...
def __ge__(self, other: "Comparable") -> bool: ...
def __eq__(self, other: object) -> bool: ...
def find_min(items: list[T]) -> T:
"""Works with any list of types satisfying the Comparable Protocol"""
if not items:
raise ValueError("Empty list")
result = items[0]
for item in items[1:]:
if item < result: # type: ignore
result = item
return result
def find_max(items: list[T]) -> T:
if not items:
raise ValueError("Empty list")
result = items[0]
for item in items[1:]:
if item > result: # type: ignore
result = item
return result
# Use the same function for various types
print(find_min([3, 1, 4, 1, 5])) # 1
print(find_max(["banana", "apple"])) # banana
print(find_min([3.14, 2.71, 1.41])) # 1.41
When to Use Protocol vs ABC
When to use Protocol:
- When you want to include third-party classes or built-in types in an interface
- When you want to express duck typing with type hints without explicit inheritance
- When there is no class hierarchy or it is unnecessary
- When typing.Protocol is more flexible and loosely coupled
When to use ABC:
- When you want to put concrete implementations (shared logic) in the base class
- When you frequently need isinstance() type checks at runtime
- When the team/codebase prefers explicit class hierarchies
- When you want to force implementations with @abstractmethod
# ABC is suitable: sharing common implementations
from abc import ABC, abstractmethod
class BaseRepository(ABC):
def __init__(self, db_url: str):
self._db_url = db_url
self._connection = None
def connect(self) -> None: # Common implementation
print(f"Connecting to {self._db_url}")
self._connection = f"conn:{self._db_url}"
@abstractmethod
def find_by_id(self, id: int) -> dict | None: ...
@abstractmethod
def save(self, data: dict) -> bool: ...
# Protocol is suitable: integrating with external classes
class SupportsRead(Protocol):
def read(self, n: int = -1) -> str: ...
def count_words(stream: SupportsRead) -> int:
"""Works with files, StringIO, sockets — anything with read()"""
return len(stream.read().split())
Expert Tips
1. Providing Default Implementations in Protocol Methods
from typing import Protocol, runtime_checkable
@runtime_checkable
class Loggable(Protocol):
def get_log_level(self) -> str: ...
def log(self, message: str) -> None:
"""Can also have default implementation (but not a required method of the Protocol)"""
print(f"[{self.get_log_level()}] {message}")
2. Generic Protocol
from typing import Protocol, TypeVar
T_co = TypeVar("T_co", covariant=True)
class Iterable(Protocol[T_co]):
def __iter__(self) -> "Iterator[T_co]": ...
class Iterator(Protocol[T_co]):
def __next__(self) -> T_co: ...
def __iter__(self) -> "Iterator[T_co]": ...
3. Composing More Specific Protocols via Protocol Inheritance
from typing import Protocol
class Readable(Protocol):
def read(self) -> str: ...
class Writable(Protocol):
def write(self, data: str) -> int: ...
class ReadWritable(Readable, Writable, Protocol):
"""Both readable and writable"""
...
def copy(src: Readable, dst: Writable) -> None:
dst.write(src.read())
Summary
| Feature | Protocol | ABC |
|---|---|---|
| Inheritance required | No (structural subtyping) | Yes (nominal subtyping) |
| Runtime isinstance() | Requires @runtime_checkable | Supported by default |
| Sharing common implementation | Limited | Fully supported |
| Coupling | Low | High |
| Third-party class integration | Easy | Difficult |
| Primary use case | Type hints, expressing duck typing | Enforcing interfaces + shared logic |
Protocol elegantly combines Python's duck typing philosophy with the static type system.