Yesterday's ABC said: "to be a Shape, you must inherit from Shape." Sometimes that's too strict. What if you want a function that accepts "anything with an area() method" — without forcing those things to inherit from anything?
from typing import Protocol, runtime_checkable
@runtime_checkable
class HasArea(Protocol):
def area(self) -> float:
...
def total_area(shapes) -> float:
return sum(s.area() for s in shapes)
class Square: # NOT inheriting from anything
def __init__(self, side):
self.side = side
def area(self) -> float:
return self.side ** 2
class Triangle: # also not inheriting
def __init__(self, base, height):
self.base = base
self.height = height
def area(self) -> float:
return 0.5 * self.base * self.height
print(total_area([Square(5), Triangle(3, 4)])) # 31.0
print(isinstance(Square(5), HasArea)) # TrueNeither Square nor Triangle mentions HasArea anywhere — but isinstance returns True?
That's the magic of structural typing. With @runtime_checkable, isinstance(x, HasArea) is True if x has the right methods, regardless of what it inherits from. The check is by shape, not by family tree.
Why not just use ABCs everywhere?
Two reasons. (1) You can't always edit the source. A library gives you a class; you want to use it where your function expects "thing with area" — but you can't make that library's class inherit from your Shape. With Protocol, no inheritance needed. (2) Lighter. No base class to set up; no template-method gymnastics. Just a description of what methods are needed.
When does ABC win then?
When the contract is meant to be implemented from scratch. Plugin systems where each implementation must provide a specific surface — there ABC's enforced inheritance is a feature, not a bug. Protocols are about describing what something must look like; ABCs are about enforcing a structural contract through inheritance.
typing.Protocol — structural typingA Protocol describes what methods/attributes an object must have, without requiring any inheritance.
from typing import Protocol
class HasArea(Protocol):
def area(self) -> float:
...Any class with an area(self) -> float method implicitly satisfies HasArea. No class X(HasArea): needed.
def total_area(shapes: list[HasArea]) -> float:
return sum(s.area() for s in shapes)mypy checks: does every class in shapes have an area() method? If yes, the call is valid. The runtime knows nothing — Protocol is purely a static description.
@runtime_checkableIf you want isinstance(x, HasArea) to actually work at runtime:
from typing import Protocol, runtime_checkable
@runtime_checkable
class HasArea(Protocol):
def area(self) -> float:
...
class Square:
def area(self) -> float:
return 25
print(isinstance(Square(), HasArea)) # True — Square has area()
print(isinstance("hello", HasArea)) # False — strings don'tThe check is structural — does the object have the methods? Not "does it inherit from this class."
@runtime_checkable is approximateIt only checks names, not signatures. A class with def area(self): return "not a number" would pass isinstance(x, HasArea) even though its area doesn't actually return float. For full safety, rely on mypy for static checking; use @runtime_checkable only when you genuinely need runtime dispatch.
class HasName(Protocol):
name: strAny class with a name: str attribute (or annotation) satisfies it.
Yes:
No:
| Goal | Use |
|---|---|
| Describe a shape; many implementations, possibly third-party | Protocol |
| Enforce a contract at instantiation | ABC + @abstractmethod |
| Document an extension point in your library | ABC (clearer to readers) |
| Type-hint "file-like", "iterable", "context-manager-like" | Protocol (or use the typing module's prebuilt ones) |
The stdlib has many built-in protocols: Iterable, Iterator, Container, Sized, Hashable, etc. Reach for those before writing your own.
Yesterday's ABC said: "to be a Shape, you must inherit from Shape." Sometimes that's too strict. What if you want a function that accepts "anything with an area() method" — without forcing those things to inherit from anything?
from typing import Protocol, runtime_checkable
@runtime_checkable
class HasArea(Protocol):
def area(self) -> float:
...
def total_area(shapes) -> float:
return sum(s.area() for s in shapes)
class Square: # NOT inheriting from anything
def __init__(self, side):
self.side = side
def area(self) -> float:
return self.side ** 2
class Triangle: # also not inheriting
def __init__(self, base, height):
self.base = base
self.height = height
def area(self) -> float:
return 0.5 * self.base * self.height
print(total_area([Square(5), Triangle(3, 4)])) # 31.0
print(isinstance(Square(5), HasArea)) # TrueNeither Square nor Triangle mentions HasArea anywhere — but isinstance returns True?
That's the magic of structural typing. With @runtime_checkable, isinstance(x, HasArea) is True if x has the right methods, regardless of what it inherits from. The check is by shape, not by family tree.
Why not just use ABCs everywhere?
Two reasons. (1) You can't always edit the source. A library gives you a class; you want to use it where your function expects "thing with area" — but you can't make that library's class inherit from your Shape. With Protocol, no inheritance needed. (2) Lighter. No base class to set up; no template-method gymnastics. Just a description of what methods are needed.
When does ABC win then?
When the contract is meant to be implemented from scratch. Plugin systems where each implementation must provide a specific surface — there ABC's enforced inheritance is a feature, not a bug. Protocols are about describing what something must look like; ABCs are about enforcing a structural contract through inheritance.
typing.Protocol — structural typingA Protocol describes what methods/attributes an object must have, without requiring any inheritance.
from typing import Protocol
class HasArea(Protocol):
def area(self) -> float:
...Any class with an area(self) -> float method implicitly satisfies HasArea. No class X(HasArea): needed.
def total_area(shapes: list[HasArea]) -> float:
return sum(s.area() for s in shapes)mypy checks: does every class in shapes have an area() method? If yes, the call is valid. The runtime knows nothing — Protocol is purely a static description.
@runtime_checkableIf you want isinstance(x, HasArea) to actually work at runtime:
from typing import Protocol, runtime_checkable
@runtime_checkable
class HasArea(Protocol):
def area(self) -> float:
...
class Square:
def area(self) -> float:
return 25
print(isinstance(Square(), HasArea)) # True — Square has area()
print(isinstance("hello", HasArea)) # False — strings don'tThe check is structural — does the object have the methods? Not "does it inherit from this class."
@runtime_checkable is approximateIt only checks names, not signatures. A class with def area(self): return "not a number" would pass isinstance(x, HasArea) even though its area doesn't actually return float. For full safety, rely on mypy for static checking; use @runtime_checkable only when you genuinely need runtime dispatch.
class HasName(Protocol):
name: strAny class with a name: str attribute (or annotation) satisfies it.
Yes:
No:
| Goal | Use |
|---|---|
| Describe a shape; many implementations, possibly third-party | Protocol |
| Enforce a contract at instantiation | ABC + @abstractmethod |
| Document an extension point in your library | ABC (clearer to readers) |
| Type-hint "file-like", "iterable", "context-manager-like" | Protocol (or use the typing module's prebuilt ones) |
The stdlib has many built-in protocols: Iterable, Iterator, Container, Sized, Hashable, etc. Reach for those before writing your own.
Create a free account to get started. Paid plans unlock all tracks.