Python's Protocol enables structural subtyping—if it has the methods, it satisfies the interface. No inheritance required.
Basic Protocol
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None:
...
# These classes don't inherit from Drawable
class Circle:
def draw(self) -> None:
print("Drawing circle")
class Square:
def draw(self) -> None:
print("Drawing square")
def render(shape: Drawable) -> None:
shape.draw()
# Both work—they have draw()
render(Circle()) # OK
render(Square()) # OKProtocol vs ABC
from abc import ABC, abstractmethod
from typing import Protocol
# ABC: Nominal typing (must inherit)
class DrawableABC(ABC):
@abstractmethod
def draw(self) -> None:
pass
class CircleABC(DrawableABC): # Must inherit
def draw(self) -> None:
print("circle")
# Protocol: Structural typing (just needs methods)
class DrawableProtocol(Protocol):
def draw(self) -> None:
...
class CircleProtocol: # No inheritance needed
def draw(self) -> None:
print("circle")Multiple Methods
from typing import Protocol
class Serializable(Protocol):
def to_json(self) -> str:
...
def to_dict(self) -> dict:
...
class User:
def __init__(self, name: str):
self.name = name
def to_json(self) -> str:
import json
return json.dumps(self.to_dict())
def to_dict(self) -> dict:
return {"name": self.name}
def save(obj: Serializable) -> None:
print(obj.to_json())
save(User("Alice")) # WorksProtocol with Properties
from typing import Protocol
class Named(Protocol):
@property
def name(self) -> str:
...
class Person:
def __init__(self, name: str):
self._name = name
@property
def name(self) -> str:
return self._name
class Company:
name: str # Class attribute also works
def __init__(self, name: str):
self.name = name
def greet(entity: Named) -> None:
print(f"Hello, {entity.name}")
greet(Person("Alice"))
greet(Company("Acme"))Callable Protocol
from typing import Protocol
class Handler(Protocol):
def __call__(self, event: str) -> None:
...
def log_handler(event: str) -> None:
print(f"Log: {event}")
class AlertHandler:
def __call__(self, event: str) -> None:
print(f"Alert: {event}")
def dispatch(handler: Handler, event: str) -> None:
handler(event)
dispatch(log_handler, "click") # Function
dispatch(AlertHandler(), "error") # Callable classGeneric Protocols
from typing import Protocol, TypeVar
T = TypeVar('T')
class Comparable(Protocol[T]):
def __lt__(self, other: T) -> bool:
...
class Version:
def __init__(self, major: int, minor: int):
self.major = major
self.minor = minor
def __lt__(self, other: 'Version') -> bool:
return (self.major, self.minor) < (other.major, other.minor)
def minimum(a: Comparable[T], b: Comparable[T]) -> Comparable[T]:
return a if a < b else b
v1 = Version(1, 0)
v2 = Version(2, 0)
print(minimum(v1, v2)) # v1runtime_checkable
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None:
...
class Connection:
def close(self) -> None:
print("Closing")
# Now isinstance works
conn = Connection()
print(isinstance(conn, Closeable)) # True
# Without @runtime_checkable, isinstance failsNote: isinstance only checks method existence, not signatures.
Combining Protocols
from typing import Protocol
class Readable(Protocol):
def read(self) -> str:
...
class Writable(Protocol):
def write(self, data: str) -> None:
...
class ReadWritable(Readable, Writable, Protocol):
pass
class File:
def read(self) -> str:
return "content"
def write(self, data: str) -> None:
print(f"Writing: {data}")
def copy(src: Readable, dst: Writable) -> None:
dst.write(src.read())
f = File()
copy(f, f) # File satisfies bothProtocol with Class Variables
from typing import Protocol, ClassVar
class Versioned(Protocol):
version: ClassVar[str]
class MyAPI:
version: ClassVar[str] = "1.0.0"
def check_version(api: Versioned) -> None:
print(f"API version: {api.version}")Contravariant Protocol
from typing import Protocol, TypeVar
T_contra = TypeVar('T_contra', contravariant=True)
class Processor(Protocol[T_contra]):
def process(self, item: T_contra) -> None:
...
class NumberProcessor:
def process(self, item: int) -> None:
print(item * 2)
def run_processor(p: Processor[int], value: int) -> None:
p.process(value)
run_processor(NumberProcessor(), 5)Real-World Example: Repository Pattern
from typing import Protocol, TypeVar, Optional, List
T = TypeVar('T')
class Repository(Protocol[T]):
def get(self, id: int) -> Optional[T]:
...
def list(self) -> List[T]:
...
def save(self, item: T) -> None:
...
def delete(self, id: int) -> None:
...
# In-memory implementation
class InMemoryRepo:
def __init__(self):
self._data = {}
self._id = 0
def get(self, id: int):
return self._data.get(id)
def list(self):
return list(self._data.values())
def save(self, item):
self._id += 1
self._data[self._id] = item
def delete(self, id: int):
self._data.pop(id, None)
# Database implementation would satisfy same protocol
def process_items(repo: Repository[str]) -> None:
for item in repo.list():
print(item)Protocol vs TypedDict
from typing import Protocol, TypedDict
# Protocol: for objects with methods
class Configurable(Protocol):
def configure(self, **kwargs) -> None:
...
# TypedDict: for dict-like data
class Config(TypedDict):
host: str
port: intBest Practices
- Keep protocols small: Single responsibility
- Use
...for method bodies: Convention for abstract - Add
@runtime_checkableonly when needed: Has overhead - Prefer Protocol over ABC: More flexible
- Document expected behavior: Protocols don't enforce semantics
Summary
Protocols enable duck typing with type safety:
- Define interfaces without inheritance
- Structural subtyping ("if it walks like a duck")
- Generic protocols for type parameters
@runtime_checkablefor isinstance checks- Combine protocols for complex interfaces
Perfect for dependency injection, testing, and flexible APIs.
React to this post: