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())  # OK

Protocol 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"))  # Works

Protocol 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 class

Generic 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))  # v1

runtime_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 fails

Note: 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 both

Protocol 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: int

Best Practices

  1. Keep protocols small: Single responsibility
  2. Use ... for method bodies: Convention for abstract
  3. Add @runtime_checkable only when needed: Has overhead
  4. Prefer Protocol over ABC: More flexible
  5. 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_checkable for isinstance checks
  • Combine protocols for complex interfaces

Perfect for dependency injection, testing, and flexible APIs.

React to this post: