Skip to content
Codeloom
Python

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.

·8 min read · By Codeloom
Intermediate 11 min read

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.