Python Protocol Classes: Structural Subtyping Guide
Learn how to use Python Protocol classes for structural subtyping, duck typing with static checks, and writing flexible interfaces without inheritance.
What you'll learn
- ✓Define interfaces with Protocol classes
- ✓Use structural subtyping for duck typing with type safety
- ✓Combine Protocol with runtime_checkable for isinstance checks
- ✓Compare Protocol vs ABC for interface design
Prerequisites
- •Python classes and inheritance
- •Basic type hints
What Is Structural Subtyping?
Python has always embraced duck typing: if an object has the right methods, it works, regardless of its class hierarchy. But traditional type hints relied on nominal subtyping, meaning a class had to explicitly inherit from a base class to be considered compatible.
Protocol classes, introduced in Python 3.8 via PEP 544, bring duck typing into the type system. A class satisfies a Protocol if it has the right attributes and methods, without needing to inherit from anything.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str: ...
# Circle never mentions Drawable, but it satisfies the protocol
class Circle:
def draw(self) -> str:
return "Drawing a circle"
class Square:
def draw(self) -> str:
return "Drawing a square"
def render(shape: Drawable) -> None:
print(shape.draw())
render(Circle()) # Type-checks fine
render(Square()) # Type-checks fine
No inheritance needed. The type checker sees that Circle has a draw() -> str method, which matches the Drawable protocol, so it passes.
Defining Protocol Classes
Basic Protocol
A Protocol class defines the interface by listing method signatures with ... as the body:
from typing import Protocol
class Serializable(Protocol):
def to_json(self) -> str: ...
def to_dict(self) -> dict: ...
class User:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
def to_json(self) -> str:
import json
return json.dumps(self.to_dict())
def to_dict(self) -> dict:
return {'name': self.name, 'age': self.age}
def save(obj: Serializable) -> None:
"""Works with any object that has to_json and to_dict."""
print(f"Saving: {obj.to_json()}")
save(User("Alice", 30)) # Works
Protocol with Attributes
Protocols can require attributes, not just methods:
from typing import Protocol
class HasName(Protocol):
name: str
class HasPosition(Protocol):
x: float
y: float
class Player:
def __init__(self, name: str, x: float, y: float):
self.name = name
self.x = x
self.y = y
def greet(entity: HasName) -> str:
return f"Hello, {entity.name}!"
def distance_from_origin(obj: HasPosition) -> float:
return (obj.x ** 2 + obj.y ** 2) ** 0.5
p = Player("Alice", 3.0, 4.0)
print(greet(p)) # "Hello, Alice!"
print(distance_from_origin(p)) # 5.0
Protocol with Properties
You can define read-only properties in protocols:
from typing import Protocol
class Sized(Protocol):
@property
def size(self) -> int: ...
class MyCollection:
def __init__(self, items: list):
self._items = items
@property
def size(self) -> int:
return len(self._items)
def is_empty(obj: Sized) -> bool:
return obj.size == 0
print(is_empty(MyCollection([1, 2, 3]))) # False
print(is_empty(MyCollection([]))) # True
Composing Protocols
Protocols can inherit from other protocols to build up complex interfaces:
from typing import Protocol
class Readable(Protocol):
def read(self, n: int = -1) -> str: ...
class Writable(Protocol):
def write(self, data: str) -> int: ...
class Closeable(Protocol):
def close(self) -> None: ...
# Combine protocols through inheritance
class ReadWriteCloseable(Readable, Writable, Closeable, Protocol):
...
class InMemoryFile:
def __init__(self):
self._buffer = ""
self._position = 0
def read(self, n: int = -1) -> str:
if n == -1:
result = self._buffer[self._position:]
self._position = len(self._buffer)
else:
result = self._buffer[self._position:self._position + n]
self._position += n
return result
def write(self, data: str) -> int:
self._buffer += data
return len(data)
def close(self) -> None:
self._buffer = ""
self._position = 0
def process_stream(stream: ReadWriteCloseable) -> None:
stream.write("Hello, World!")
stream.close()
process_stream(InMemoryFile()) # Type-checks fine
Generic Protocols
Protocols can be parameterized with type variables:
from typing import Protocol, TypeVar
T = TypeVar('T')
T_co = TypeVar('T_co', covariant=True)
class Repository(Protocol[T]):
def get(self, id: int) -> T | None: ...
def save(self, entity: T) -> None: ...
def delete(self, id: int) -> bool: ...
def list_all(self) -> list[T]: ...
class User:
def __init__(self, id: int, name: str):
self.id = id
self.name = name
class InMemoryUserRepo:
def __init__(self):
self._store: dict[int, User] = {}
def get(self, id: int) -> User | None:
return self._store.get(id)
def save(self, entity: User) -> None:
self._store[entity.id] = entity
def delete(self, id: int) -> bool:
return self._store.pop(id, None) is not None
def list_all(self) -> list[User]:
return list(self._store.values())
def count_entities(repo: Repository[T]) -> int:
return len(repo.list_all())
repo = InMemoryUserRepo()
repo.save(User(1, "Alice"))
repo.save(User(2, "Bob"))
print(count_entities(repo)) # 2
runtime_checkable Protocols
By default, protocols only work with static type checkers. To use isinstance() at runtime, add the @runtime_checkable decorator:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Iterable(Protocol):
def __iter__(self): ...
@runtime_checkable
class SupportsLen(Protocol):
def __len__(self) -> int: ...
print(isinstance([1, 2, 3], Iterable)) # True
print(isinstance("hello", SupportsLen)) # True
print(isinstance(42, SupportsLen)) # False
# Practical use: validate input at runtime
def process(data):
if not isinstance(data, SupportsLen):
raise TypeError("data must support len()")
print(f"Processing {len(data)} items")
process([1, 2, 3]) # Processing 3 items
# process(42) # TypeError
Important caveat: runtime_checkable only checks that the methods exist, not their signatures. It cannot verify argument types or return types at runtime.
@runtime_checkable
class Addable(Protocol):
def __add__(self, other: int) -> int: ...
# This is True even though str.__add__ takes str, not int
print(isinstance("hello", Addable)) # True (only checks __add__ exists)
Protocol vs ABC: When to Use Which
Both Protocol and ABC (Abstract Base Class) define interfaces, but they serve different purposes:
from abc import ABC, abstractmethod
from typing import Protocol
# ABC approach: requires inheritance
class SerializerABC(ABC):
@abstractmethod
def serialize(self, data: dict) -> bytes: ...
@abstractmethod
def deserialize(self, raw: bytes) -> dict: ...
class JSONSerializer(SerializerABC): # Must inherit
def serialize(self, data: dict) -> bytes:
import json
return json.dumps(data).encode()
def deserialize(self, raw: bytes) -> dict:
import json
return json.loads(raw)
# Protocol approach: no inheritance needed
class SerializerProtocol(Protocol):
def serialize(self, data: dict) -> bytes: ...
def deserialize(self, raw: bytes) -> dict: ...
class MsgpackSerializer: # No inheritance
def serialize(self, data: dict) -> bytes:
import json # Pretend this is msgpack
return json.dumps(data).encode()
def deserialize(self, raw: bytes) -> dict:
import json
return json.loads(raw)
def save_data(serializer: SerializerProtocol, data: dict) -> bytes:
return serializer.serialize(data)
# Both work with the Protocol-typed function
save_data(JSONSerializer(), {"a": 1})
save_data(MsgpackSerializer(), {"a": 1})
Use ABC when:
- You control all implementations
- You want to enforce that classes explicitly opt in
- You need shared implementation (mixin methods)
- You want clear error messages when a method is missing
Use Protocol when:
- You want duck typing with type safety
- You are typing third-party objects you cannot modify
- You want loose coupling between components
- You are defining callback or handler interfaces
Practical Patterns
Callback Protocols
Define typed callbacks that are more expressive than Callable:
from typing import Protocol
class EventHandler(Protocol):
def __call__(self, event_type: str, data: dict) -> bool: ...
class LoggingHandler:
def __init__(self, log_file: str):
self.log_file = log_file
def __call__(self, event_type: str, data: dict) -> bool:
print(f"[{event_type}] {data}")
return True
class EventBus:
def __init__(self):
self._handlers: list[EventHandler] = []
def subscribe(self, handler: EventHandler) -> None:
self._handlers.append(handler)
def emit(self, event_type: str, data: dict) -> None:
for handler in self._handlers:
if not handler(event_type, data):
break # Handler signaled to stop
bus = EventBus()
bus.subscribe(LoggingHandler("/var/log/app.log"))
bus.emit("user.login", {"user_id": 42})
Strategy Pattern with Protocols
from typing import Protocol
class SortStrategy(Protocol):
def sort(self, data: list[int]) -> list[int]: ...
class BubbleSort:
def sort(self, data: list[int]) -> list[int]:
arr = data.copy()
n = len(arr)
for i in range(n):
for j in range(0, n - i - 1):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
return arr
class QuickSort:
def sort(self, data: list[int]) -> list[int]:
if len(data) <= 1:
return data
pivot = data[len(data) // 2]
left = [x for x in data if x < pivot]
middle = [x for x in data if x == pivot]
right = [x for x in data if x > pivot]
return self.sort(left) + middle + self.sort(right)
class Sorter:
def __init__(self, strategy: SortStrategy):
self.strategy = strategy
def sort(self, data: list[int]) -> list[int]:
return self.strategy.sort(data)
sorter = Sorter(QuickSort())
print(sorter.sort([3, 1, 4, 1, 5, 9])) # [1, 1, 3, 4, 5, 9]
Static Duck Typing for Third-Party Code
from typing import Protocol
class DataFrameLike(Protocol):
"""Works with pandas, polars, or any compatible frame."""
def select(self, columns: list[str]) -> "DataFrameLike": ...
def filter(self, mask) -> "DataFrameLike": ...
def to_dict(self) -> dict: ...
def summarize(df: DataFrameLike, columns: list[str]) -> dict:
"""Process any DataFrame-like object."""
subset = df.select(columns)
return subset.to_dict()
Wrapping Up
Protocol classes bridge the gap between Python’s dynamic duck typing and static type checking. They let you define interfaces that any class can satisfy without inheritance, which leads to more flexible and loosely coupled code. Use them for callback types, strategy patterns, repository interfaces, and any situation where you want to type-check against a shape rather than a class hierarchy. For most new interface definitions, Protocol is the better default over ABC unless you specifically need shared implementation or explicit opt-in.
Related articles
- Python Python Type Narrowing and @overload Patterns
Master Python type narrowing with isinstance, TypeGuard, TypeIs, and use @overload to write precise function signatures that type checkers understand.
- Python Python ABCs vs Protocols: Choosing the Right Abstraction
Understand the difference between Abstract Base Classes and Protocols in Python. Learn when nominal typing beats structural typing and vice versa.
- Python Python Concurrency: asyncio vs threading vs multiprocessing
Compare Python's concurrency models side by side. Learn when to use asyncio, threading, or multiprocessing with practical benchmarks and real-world examples.
- Python Python Environments: venv, pyenv, and Poetry Guide
Master Python environment management with venv for virtual environments, pyenv for version switching, and Poetry for dependency management.