Structural Subtyping with Protocol

· 3 min read · Updated March 13, 2026 · intermediate
protocol typing structural-typing duck-typing interfaces mypy

If you have used ABCs to define interfaces, Protocol offers a different approach. Rather than requiring explicit inheritance, Protocol lets you define interfaces that types can satisfy just by having the right methods. This is structural subtyping — Python checks the structure, not the inheritance hierarchy.

The Problem with Duck Typing

Python has always embraced duck typing: if an object has the right methods, you can use it. The problem is that static type checkers like mypy cannot verify what methods an argument needs:

def draw_shape(shape):
    shape.draw()

class Circle:
    def draw(self):
        print("Drawing circle")

class Square:
    def draw(self):
        print("Drawing square")

draw_shape(Circle())
draw_shape(Square())

You cannot tell from the function signature what methods the argument needs. This is fine at runtime but frustrating for static analysis.

Enter Protocol

typing.Protocol lets you define interfaces explicitly without requiring inheritance:

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...

Now you can annotate your function:

def render(drawable: Drawable) -> None:
    drawable.draw()

Any object with a draw() method that returns None satisfies this Protocol — no inheritance required:

class Circle:
    def draw(self) -> None:
        print("Drawing circle")

class Square:
    def draw(self) -> None:
        print("Drawing square")

render(Circle())
render(Square())

This is structural subtyping: the type checker verifies the object has the right structure, not whether it inherits from a particular class.

When to Use Protocol

Protocol shines in these scenarios:

1. You cannot modify the class you are adapting A third-party library class already has the methods you need, but does not inherit from your interface:

from typing import Protocol

class Serializable(Protocol):
    def to_json(self) -> str: ...

class Response:
    def __init__(self, text):
        self.text = text
    
    def to_json(self) -> str:
        return self.text

def serialize(data: Serializable) -> str:
    return data.to_json()

2. Multiple unrelated classes share behavior Several classes implement the same methods but share no common ancestor:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def distance_from_origin(self) -> float:
        return (self.x ** 2 + self.y ** 2) ** 0.5

class Vector:
    def __init__(self, components):
        self.components = components
    
    def distance_from_origin(self) -> float:
        return sum(x**2 for x in self.components) ** 0.5

Both can satisfy a HasMagnitude Protocol without sharing inheritance.

Protocol vs ABC

AspectABCProtocol
InheritanceRequiredNot required
Runtime checksisinstance() worksNo runtime effect
ImplementationOverride abstract methodsJust implement the methods
Use caseEnforce contract strictlyAdapting existing types

ABC forces subclasses to inherit and implement methods. Protocol merely describes what methods an object should have — the type checker verifies it, not the class itself.

from abc import ABC, abstractmethod
from typing import Protocol

class Animal(ABC):
    @abstractmethod
    def speak(self) -> str: ...

class Dog(Animal):
    def speak(self) -> str:
        return "Woof"

class Cat:
    def speak(self) -> str:
        return "Meow"

class Speaker(Protocol):
    def speak(self) -> str: ...

def make_speak(thing: Speaker) -> str:
    return thing.speak()

make_speak(Dog())
make_speak(Cat())

Defining Rich Protocols

Protocol can include properties, class methods, and operators:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Comparable(Protocol):
    @property
    def value(self) -> int: ...
    
    def __lt__(self, other: "Comparable") -> bool: ...

class Score:
    def __init__(self, points):
        self.points = points
    
    @property
    def value(self) -> int:
        return self.points
    
    def __lt__(self, other):
        return self.points < other.points

a = Score(10)
b = Score(20)
print(a < b)
print(isinstance(a, Comparable))

The @runtime_checkable decorator lets you use isinstance() with the Protocol at runtime.

See Also