Protocols and abstract base classes
Structural (Protocol) vs nominal (ABC) typing and when each one buys you real safety.
Why this matters
Python's "duck typing" โ "if it walks like a duck and quacks like a duck, it is a duck" โ is its biggest superpower and biggest footgun. Protocols (PEP 544) give you the duck typing flexibility and static type checking. Knowing when to reach for Protocol, ABC, or just type-hinted concrete classes is what distinguishes idiomatic modern Python.
Learning objectives
- Use duck typing without losing type safety.
- Define
Protocols for structural typing. - Use
@runtime_checkableforisinstanceon protocols. - Pick between
Protocol,ABC, and concrete classes. - Apply the standard "iterable / iterator / sequence" protocols.
1. Duck typing โ the cultural default
def total_lengths(items):
return sum(len(x) for x in items)
total_lengths(["a", "bb", "ccc"]) # 6
total_lengths([{"a": 1}, {"a": 1, "b": 2}]) # 3
total_lengths([(1,), (1, 2), (1, 2, 3)]) # 6The function works with anything that has a __len__ and is iterable. No isinstance checks. No declared interface. Just hopes.
This is structural typing: you depend on shape, not lineage.
The problem: nothing catches a wrong caller until runtime โ and Python tools couldn't type-check this until PEP 544.
2. Nominal vs structural typing
Nominal (Java, C#): a type checker says "yes" only if you declared inheritance/implementation.
Structural (TypeScript, Go interfaces, Python Protocol): the type checker says "yes" if the object has the right shape.
Python uses both:
- Nominal via ABCs (
isinstance(x, MutableMapping)). - Structural via Protocols (
xhaskeys()and__getitem__).
3. Protocol โ the right way to type duck-typed code
from typing import Protocol
class SupportsLen(Protocol):
def __len__(self) -> int: ...
def total_lengths(items: list[SupportsLen]) -> int:
return sum(len(x) for x in items)What this means:
mypy/pyrightaccept anything with a__len__(self) -> intmethod.- No
isinstance(x, SupportsLen)at runtime by default. - No need for callers to subclass
SupportsLen.
The ... body means "no implementation; this is just a shape."
Real-world example
from typing import Protocol
class Repository(Protocol):
def get(self, id: int) -> dict | None: ...
def save(self, item: dict) -> None: ...
def sync(source: Repository, dest: Repository, ids: list[int]) -> None:
for i in ids:
item = source.get(i)
if item is not None:
dest.save(item)Any class with get + save matching those signatures works โ InMemoryRepo, SQLRepo, S3Repo โ without subclassing.
4. runtime_checkable โ isinstance on protocols
By default, Protocols are static only. To use them with isinstance, mark @runtime_checkable:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None: ...
isinstance(open("x.txt"), Closable) # TrueCaveats:
- Only checks method existence, not signatures.
- Slower than nominal
isinstance. - Use sparingly; static checks are usually enough.
5. ABCs vs Protocols โ when to use which
| Use ABC when... | Use Protocol when... |
|---|---|
| You own all implementations | You want third parties to "fit in" without subclassing |
| You want to share base behaviour, not just signatures | You only care about shape |
| Plugin systems where registration is explicit | Standard-library style adapters (Iterable, Sized) |
You need mixin style code reuse | Modern type-hint-only contracts |
In 2026, prefer Protocol for new public APIs. ABCs remain for inheritance hierarchies where you genuinely share code.
# Concrete impl + Protocol
class S3Repository:
def get(self, id): ...
def save(self, item): ...
def sync(src: Repository, dst: Repository): ... # Repository is a Protocol
sync(S3Repository(), MemoryRepo()) # works; no inheritance6. Generic Protocols
from typing import Protocol, TypeVar
T = TypeVar("T")
class Container(Protocol[T]):
def add(self, item: T) -> None: ...
def pop(self) -> T: ...
def drain(c: Container[int]) -> list[int]:
out = []
try:
while True:
out.append(c.pop())
except IndexError:
return outGeneric protocols are powerful. Phase 3.5 covers TypeVar, Generic, ParamSpec in depth.
7. collections.abc protocols you'll often type against
| Use | Type |
|---|---|
| Any for-loopable | Iterable[T] |
Has __next__ | Iterator[T] |
Has len() | Sized |
in works | Container[T] |
| Indexable + sized | Sequence[T] |
| Mutable list-like | MutableSequence[T] |
| dict-like | Mapping[K, V], MutableMapping[K, V] |
| Callable | Callable[[A, B], R] |
These come from collections.abc and double as both ABCs and structural protocols:
from collections.abc import Iterable, Mapping
def sum_values(m: Mapping[str, int]) -> int:
return sum(m.values())
sum_values({"a": 1, "b": 2}) # 3
sum_values(MappingProxyType({...})) # also fine8. The classic Python protocols
Iterator protocol
class CountDown:
def __init__(self, n: int): self.n = n
def __iter__(self):
return self # iterator is its own iterable
def __next__(self):
if self.n <= 0:
raise StopIteration
self.n -= 1
return self.n + 1
list(CountDown(3)) # [3, 2, 1]In practice, you almost always use generators (Phase 3.2) instead of __iter__/__next__.
Context manager protocol
class Tx:
def __enter__(self):
self.connection = open_db()
return self.connection
def __exit__(self, exc_type, exc, tb):
if exc:
self.connection.rollback()
else:
self.connection.commit()
self.connection.close()
return False # don't suppress exception
with Tx() as conn:
conn.execute(...)(Phase 3.3 deep dive.)
Callable protocol
Any object with __call__ is callable:
class Adder:
def __init__(self, by: int): self.by = by
def __call__(self, x: int) -> int: return x + self.by
add5 = Adder(5)
add5(3) # 8
callable(add5) # TrueUseful for stateful "function-like" objects (counters, accumulators, throttlers).
Hash + equality protocol
(Recap Lesson 2.1.) Implement together. Equal objects must hash equal.
Comparison protocol
__lt__, __le__, __gt__, __ge__. functools.total_ordering fills the gaps.
9. Worked example: storage backends
from typing import Protocol, runtime_checkable
@runtime_checkable
class KeyValueStore(Protocol):
def get(self, key: str) -> bytes | None: ...
def put(self, key: str, value: bytes) -> None: ...
def delete(self, key: str) -> None: ...
# Three implementations โ no shared base class
class MemoryStore:
def __init__(self) -> None: self._d: dict[str, bytes] = {}
def get(self, k): return self._d.get(k)
def put(self, k, v): self._d[k] = v
def delete(self, k): self._d.pop(k, None)
class FileStore:
def __init__(self, path): self.path = Path(path); self.path.mkdir(exist_ok=True)
def get(self, k): p = self.path / k; return p.read_bytes() if p.exists() else None
def put(self, k, v): (self.path / k).write_bytes(v)
def delete(self, k): (self.path / k).unlink(missing_ok=True)
class S3Store:
... # boto3 wrapper
def cache_get_or_compute(store: KeyValueStore, key: str, compute) -> bytes:
v = store.get(key)
if v is None:
v = compute()
store.put(key, v)
return vcache_get_or_compute works with any of the three โ and mypy verifies the call site, even though no class inherits from KeyValueStore.
Hands-on lab (1.5 hours)
- Define a
ComparableProtocol with__lt__; write a genericsorted2(xs: list[Comparable]) -> list. - Build three classes implementing a
NotifierProtocol; write anotify_all(notifiers, msg). - Add
@runtime_checkableto one of your protocols; verifyisinstanceworks on the implementations. - Find a place in your code where you
isinstance(x, MyBase); refactor to Protocol-typed parameters. - Read the
collections.abcsource forSequence; list the abstract methods and the ones it provides for free. - Bonus: define a generic
Cache[K, V]Protocol; implement an in-memory and a disk version.
Common pitfalls
- Adding methods to a Protocol after callers exist โ silently breaks them.
isinstance(x, Protocol)without@runtime_checkableโTypeError.- Overusing Protocols for one-off internal types; just use a concrete class.
- Treating
Protocolas a base class for code reuse โ it's for typing only; use ABCs for shared implementation. - Forgetting that
runtime_checkabledoesn't check argument types, only attribute presence.
Self-check
- Define structural vs nominal typing.
- What does
Protocolgive you thatABCdoesn't? - When use
@runtime_checkable? - State three protocols from
collections.abc. - Why is
__call__useful?
References
- PEP 544 โ Protocols: Structural subtyping.
- PEP 3119 โ Abstract Base Classes.
- Fluent Python, Ramalho โ Chapter 13.
- mypy docs on Protocols: https://mypy.readthedocs.io/en/stable/protocols.html.
- ลukasz Langa, "Python's Type System" PyCon talk.
Sign in to save your progress and earn badges.