Protocols enable structural subtyping—if an object has the right methods, it satisfies the protocol. No inheritance required. Duck typing with type safety.

Basic Protocol

from typing import Protocol
 
class Drawable(Protocol):
    def draw(self) -> None: ...
 
# No inheritance needed!
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 typing import Protocol
from abc import ABC, abstractmethod
 
# ABC requires inheritance
class DrawableABC(ABC):
    @abstractmethod
    def draw(self) -> None: ...
 
class Shape(DrawableABC):
    def draw(self) -> None:
        print("Shape")
 
# Protocol doesn't
class DrawableProtocol(Protocol):
    def draw(self) -> None: ...
 
# Any class with draw() works
class ExternalWidget:  # Third-party class
    def draw(self) -> None:
        print("Widget")
 
def render(d: DrawableProtocol) -> None:
    d.draw()
 
render(ExternalWidget())  # Works!

Protocol with Properties

from typing import Protocol
 
class Named(Protocol):
    @property
    def name(self) -> str: ...
 
class User:
    def __init__(self, name: str):
        self._name = name
    
    @property
    def name(self) -> str:
        return self._name
 
class Product:
    name: str  # Also satisfies the protocol
    
    def __init__(self, name: str):
        self.name = name
 
def greet(obj: Named) -> str:
    return f"Hello, {obj.name}!"
 
greet(User("Alice"))    # OK
greet(Product("Book"))  # OK

Protocol with Class Variables

from typing import Protocol, ClassVar
 
class Versioned(Protocol):
    version: ClassVar[str]
 
class APIClient:
    version: ClassVar[str] = "1.0.0"
 
class DatabaseClient:
    version = "2.0.0"  # Also works
 
def check_version(client: Versioned) -> str:
    return client.version

Callable Protocol

from typing import Protocol
 
class Handler(Protocol):
    def __call__(self, request: dict) -> dict: ...
 
def process(handler: Handler, request: dict) -> dict:
    return handler(request)
 
# Function satisfies it
def my_handler(request: dict) -> dict:
    return {"status": "ok", **request}
 
# Class with __call__ satisfies it
class MyHandler:
    def __call__(self, request: dict) -> dict:
        return {"status": "ok", **request}
 
process(my_handler, {})    # OK
process(MyHandler(), {})   # OK

Generic Protocols

from typing import Protocol, TypeVar
 
T = TypeVar('T')
 
class Container(Protocol[T]):
    def get(self) -> T: ...
    def set(self, value: T) -> None: ...
 
class Box:
    def __init__(self, value: int):
        self._value = value
    
    def get(self) -> int:
        return self._value
    
    def set(self, value: int) -> None:
        self._value = value
 
def double_value(container: Container[int]) -> None:
    container.set(container.get() * 2)
 
box = Box(5)
double_value(box)
print(box.get())  # 10

Runtime Checkable Protocol

from typing import Protocol, runtime_checkable
 
@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None: ...
 
class File:
    def close(self) -> None:
        print("Closing file")
 
class Connection:
    def close(self) -> None:
        print("Closing connection")
 
# Can use isinstance at runtime
file = File()
print(isinstance(file, Closeable))  # True
 
# Without @runtime_checkable, isinstance raises TypeError

Combining Protocols

from typing import Protocol
 
class Readable(Protocol):
    def read(self) -> bytes: ...
 
class Writable(Protocol):
    def write(self, data: bytes) -> None: ...
 
class ReadWritable(Readable, Writable, Protocol):
    """Combines both protocols."""
    pass
 
# Or use intersection in type hints
def copy_data(src: Readable, dst: Writable) -> None:
    data = src.read()
    dst.write(data)

Protocol for Iteration

from typing import Protocol, Iterator, TypeVar
 
T = TypeVar('T', covariant=True)
 
class Iterable(Protocol[T]):
    def __iter__(self) -> Iterator[T]: ...
 
class NumberRange:
    def __init__(self, start: int, end: int):
        self.start = start
        self.end = end
    
    def __iter__(self):
        return iter(range(self.start, self.end))
 
def sum_items(items: Iterable[int]) -> int:
    return sum(items)
 
print(sum_items(NumberRange(1, 5)))  # 10
print(sum_items([1, 2, 3, 4]))       # 10

Context Manager Protocol

from typing import Protocol, TypeVar
 
T = TypeVar('T')
 
class ContextManager(Protocol[T]):
    def __enter__(self) -> T: ...
    def __exit__(self, exc_type, exc_val, exc_tb) -> bool | None: ...
 
class Timer:
    def __enter__(self) -> 'Timer':
        import time
        self.start = time.time()
        return self
    
    def __exit__(self, *args) -> None:
        import time
        self.elapsed = time.time() - self.start
 
def timed_operation(cm: ContextManager) -> None:
    with cm:
        pass  # Do something

Supports Pattern

from typing import Protocol
 
class SupportsAdd(Protocol):
    def __add__(self, other: 'SupportsAdd') -> 'SupportsAdd': ...
 
class SupportsLessThan(Protocol):
    def __lt__(self, other: 'SupportsLessThan') -> bool: ...
 
def add_items(a: SupportsAdd, b: SupportsAdd) -> SupportsAdd:
    return a + b
 
def find_min(a: SupportsLessThan, b: SupportsLessThan) -> SupportsLessThan:
    return a if a < b else b
 
# Works with any type that supports the operation
add_items(1, 2)           # int
add_items("hello", " ")   # str
find_min(1, 2)            # int
find_min("a", "b")        # str

Repository Pattern with Protocol

from typing import Protocol, TypeVar, Generic, List, Optional
 
T = TypeVar('T')
ID = TypeVar('ID')
 
class Repository(Protocol[T, ID]):
    def get(self, id: ID) -> Optional[T]: ...
    def save(self, entity: T) -> T: ...
    def delete(self, id: ID) -> bool: ...
    def find_all(self) -> List[T]: ...
 
# Any class implementing these methods works
class InMemoryRepo:
    def __init__(self):
        self._data = {}
        self._id = 0
    
    def get(self, id: int):
        return self._data.get(id)
    
    def save(self, entity):
        self._id += 1
        self._data[self._id] = entity
        return entity
    
    def delete(self, id: int) -> bool:
        if id in self._data:
            del self._data[id]
            return True
        return False
    
    def find_all(self):
        return list(self._data.values())

Event Handler Protocol

from typing import Protocol, TypeVar
 
E = TypeVar('E', contravariant=True)
 
class EventHandler(Protocol[E]):
    def handle(self, event: E) -> None: ...
 
class ClickEvent:
    def __init__(self, x: int, y: int):
        self.x = x
        self.y = y
 
class ClickHandler:
    def handle(self, event: ClickEvent) -> None:
        print(f"Clicked at ({event.x}, {event.y})")
 
def dispatch(handler: EventHandler[ClickEvent], event: ClickEvent):
    handler.handle(event)
 
dispatch(ClickHandler(), ClickEvent(10, 20))

When to Use Protocol vs ABC

Use ProtocolUse ABC
Third-party typesYour own hierarchy
Structural typingNominal typing
No runtime checks neededNeed isinstance()
Flexible interfacesStrict contracts
Duck typingExplicit inheritance

Protocols bring type safety to duck typing. Use them when you want interface guarantees without forcing inheritance.

React to this post: