flowchart TB Q["Need to constrain a value's shape?"] Q --> Duck["Duck typing<br/>just call the method<br/>no check"] Q --> Goose["Goose typing<br/>isinstance(x, ABC)<br/>runtime"] Q --> Static["Static typing<br/>mypy / pyright<br/>at type-check time"] Q --> Runtime["Runtime Protocol<br/>isinstance(x, P)<br/>P decorated @runtime_checkable"]
25 Interfaces, Protocols, and ABCs
Python has four typing approaches (duck, goose, static, runtime). ABCs define formal interfaces. Protocols define structural interfaces. Both are tools — use the right one for the job.
In this chapter you will learn to:
- Place every type-checking style into the four-approach map.
- Use duck typing to satisfy a built-in protocol without inheritance.
- Register a virtual subclass of an ABC, and define your own ABC with abstract methods.
- Use
typing.Protocolfor structural typing that the static checker understands. - Choose between an ABC (runtime + partial implementations) and a
Protocol(static, structural).
25.1 The typing map
Python supports four typing styles, and they coexist in the same codebase:
| Approach | Checked when | Mechanism |
|---|---|---|
| Duck typing | runtime | EAFP — just call the method |
| Goose typing | runtime | isinstance(x, ABC) |
| Static typing | at type-check | mypy, pyright |
| Runtime protocol | runtime | isinstance(x, Protocol) (with @runtime_checkable) |
“EAFP” is the Pythonic acronym for Easier to Ask Forgiveness than Permission: try the operation, catch the exception if it fails. Its opposite is LBYL (Look Before You Leap) — if hasattr(x, 'method'): x.method(). Python’s data model favours EAFP because methods do their own type-checking and raise specific exceptions; the LBYL form is verbose and racy.
The right choice depends on who checks the contract and when. We’ll walk through the four in order.
25.2 Duck typing: Python digs sequences
Duck typing is the original Python style: don’t ask what something is; try to use it. The interesting twist is that the standard-library ABCs are written so that anything implementing the right methods passes isinstance automatically — no inheritance, no registration:
from collections import abc
class Struggle:
def __len__(self):
return 23
isinstance(Struggle(), abc.Sized)True
Walking through what happened:
Strugglenever inherited fromSized. There is noclass Struggle(Sized):anywhere — the two classes have no relationship in the source.Sizeddefines a class method called__subclasshook__thatisinstanceconsults as a fallback. It checks whether the candidate has a__len__method and answers “yes” if it does.- So
isinstance(Struggle(), abc.Sized)returnsTruepurely on the basis of method shape.
The general pattern: this is duck typing made formal — the standard library calls it “structural subtyping by special method.” You get the runtime check without forcing the candidate class to know anything about Sized.
25.3 Monkey patching to fit a protocol
Sometimes a class is almost compatible with a protocol but missing one method. You can patch it at runtime — gingerly. The classic example: random.shuffle requires __len__ and __setitem__, but a FrenchDeck that only supports reading doesn’t have __setitem__.
import collections, random
Card = collections.namedtuple("Card", "rank suit")
class FrenchDeck:
ranks = [str(n) for n in range(2, 11)] + list("JQKA")
suits = "spades diamonds clubs hearts".split()
def __init__(self):
self._cards = [Card(r, s) for s in self.suits for r in self.ranks]
def __len__(self):
return len(self._cards)
def __getitem__(self, pos):
return self._cards[pos]
deck = FrenchDeck()
def setitem(deck, position, card):
deck._cards[position] = card
FrenchDeck.__setitem__ = setitem
random.shuffle(deck)
deck[0]Card(rank='Q', suit='hearts')
Walking through the patch:
setitemis a plain function with the same signature as__setitem__would have on the class —(self, position, card), except we name the first argumentdeckfor clarity.FrenchDeck.__setitem__ = setitemwrites the function directly onto the class. From this assignment forward, everyFrenchDeckinstance and the class itself appear to have__setitem__. Python’s attribute lookup ondeck[0] = cardwalksdeck.__class__and finds the patched method.random.shuffle(deck)now succeeds because the protocol it requires (__len__,__getitem__,__setitem__) is fully present.
The general rule: monkey patching is a last resort. It modifies the class globally, which is invisible to anyone reading the original source. Use it for one-off scripts or test fixtures, not for library code.
25.4 Goose typing with ABCs
Goose typing (Alex Martelli’s name) is duck typing with a stamp of approval — the class explicitly says “I implement this protocol” by inheriting from an ABC. The ABC mechanism gives you two things: abstract methods (which subclasses must implement) and concrete mixin methods (which subclasses get for free).
import abc
class Tombola(abc.ABC):
@abc.abstractmethod
def load(self, iterable):
"""Add items from an iterable."""
@abc.abstractmethod
def pick(self):
"""Remove an item at random; raise LookupError if empty."""
def loaded(self):
return bool(self.inspect())
def inspect(self):
items = []
while True:
try:
items.append(self.pick())
except LookupError:
break
self.load(items)
return tuple(sorted(items))
class BingoCage(Tombola):
def __init__(self, items):
self._items = list(items)
random.shuffle(self._items)
def load(self, items):
self._items.extend(items)
def pick(self):
try:
return self._items.pop()
except IndexError:
raise LookupError("pick from empty BingoCage")
bingo = BingoCage(range(3))
bingo.loaded(), sorted(bingo.inspect())(True, [0, 1, 2])
Walking through the contract:
class Tombola(abc.ABC):declares an abstract base. Inheriting fromabc.ABCis what makes@abstractmethoddecorations actually enforced — without it the decorator is just metadata.@abc.abstractmethodonloadandpickmarks them as required. Subclasses must override both, or instantiation fails.loadedandinspecthave bodies. They’re concrete methods on the abstract class — what’s sometimes called a “mixin” — and subclasses inherit them for free.inspectis interesting: it implements its own work in terms of the abstractpickandload, so any concreteTombolaautomatically gets a workinginspect.BingoCage(Tombola)implementsloadandpick. Because both abstract methods are overridden,Tombola’s tracking marks the subclass concrete and instantiation succeeds.
The general pattern: an ABC is a contract. Abstract methods declare what the subclass owes; concrete methods declare what the subclass gets in return. If we’d forgotten one of the abstract methods:
class Half(Tombola):
def load(self, items): pass
Half()--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[4], line 3 1 class Half(Tombola): 2 def load(self, items): pass ----> 3 Half() TypeError: Can't instantiate abstract class Half without an implementation for abstract method 'pick'
Trying to instantiate raises TypeError. The ABC enforces the contract.
ABCs also support virtual subclasses — declaring an unrelated class as a subclass without inheritance. The standard library pre-registers tuple and list as Sequence this way. You can do the same for a class you can’t (or don’t want to) modify the bases of:
from collections.abc import Sequence
@Sequence.register
class FakeSeq:
def __len__(self): return 0
def __getitem__(self, i): raise IndexError
issubclass(FakeSeq, Sequence), issubclass(tuple, Sequence)(True, True)
Walking through the registration:
@Sequence.registeris equivalent toSequence.register(FakeSeq)— the class becomes a “virtual subclass” ofSequence. There’s no MRO change, no inheritance — onlyisinstanceandissubclassstart saying yes.FakeSeqdoesn’t get any ofSequence’s mixin methods (__contains__,index,count). Those are inherited via real subclassing, not via registration. We’re only buying theisinstanceanswer.issubclass(tuple, Sequence)isTruefor the same reason — the standard library registeredtupleas a virtual subclass at import time.
The general guidance: register is rarely needed — duck typing (__subclasshook__) or Protocol covers most cases. Reach for it only when retrofitting a class you don’t control to satisfy isinstance(x, SomeABC).
25.5 Static Protocol types
typing.Protocol is the static-checker version of duck typing. You define an interface; any class with matching methods satisfies it — no inheritance required, no registration. We’ll define a SupportsLessThan protocol so we can write a function that accepts anything orderable, and have mypy verify the constraint without forcing callers to subclass anything.
from typing import Protocol, Any, runtime_checkable, TypeVar
from collections.abc import Iterable
@runtime_checkable
class SupportsLessThan(Protocol):
def __lt__(self, other: Any) -> bool: ...
isinstance(1, SupportsLessThan), isinstance("hi", SupportsLessThan)(True, True)
Walking through the protocol:
class SupportsLessThan(Protocol):declares a structural type. The body lists the methods a value must have to qualify, with...as the body of each (it’s a stub — the body is never run).@runtime_checkableaddsisinstance(x, SupportsLessThan)support. Without the decorator,isinstancewould refuse to check it — protocols are static-only by default.isinstance(1, SupportsLessThan)isTruebecauseint.__lt__exists; same forstr. Neither type was modified — they just shape-match the protocol.
Use it in annotations to constrain a generic:
LT = TypeVar("LT", bound=SupportsLessThan)
def top(series: Iterable[LT], length: int) -> list[LT]:
return sorted(series, reverse=True)[:length]
top([3, 1, 4, 1, 5, 9, 2, 6], 3)[9, 6, 5]
Walking through the generic:
LT = TypeVar("LT", bound=SupportsLessThan)defines a type variable that ranges over any type satisfying the protocol.bound=...is the constraint.Iterable[LT]says “an iterable of someLT-comparable element type”;list[LT]ties the return type to the input — pass ints, get a list of ints.mypyverifies at type-check time that every element ofserieshas__lt__. At runtime there’s no check — Python just callssorted, which uses<internally.
The general pattern: a Protocol is the static type system’s name for “structural duck typing.” Define the shape, accept anything that fits, let the type checker enforce it without forcing inheritance.
25.6 ABC vs Protocol: when to use which
Both formalize duck typing. The differences are practical:
| ABC | Protocol |
|
|---|---|---|
| Subtyping | nominal (inherit or register) |
structural (just have the methods) |
| When checked | runtime via isinstance |
static via mypy, optionally runtime |
| Partial implementations | yes (mixin methods) | no |
| Forces explicit dependency | yes | no |
Use a Protocol when you want library code to be type-safe but you don’t want to force callers to inherit from anything (the Closeable, Iterable, SupportsLessThan style).
Use an ABC when you want runtime enforcement and you want to provide partial implementations the subclass can rely on (the Tombola style).
class Closeable(Protocol):
def close(self) -> None: ...
class ReadableCloseable(Closeable, Protocol):
def read(self, n: int = -1) -> str: ...Protocols compose, just like ABCs.
ABCs enforce contracts at runtime via isinstance checks. Protocols enforce contracts at static analysis time via mypy. Both formalize duck typing. Use Protocol for library code that should be type-safe; use ABC when you want runtime enforcement and want to provide partial implementations (mixin methods on the ABC).
25.7 Build: a runtime-checkable Plugin protocol with a registry
A plugin system is the canonical use-case for typing.Protocol: many third-party classes need to satisfy one shape, but you don’t want to force them all to import a common base class. We’ll build a Plugin Protocol, two unrelated concrete plugins, and a registry that uses isinstance against the Protocol to validate at the boundary.
Step 1: define the Protocol. A plugin must expose a string name and a run(config) method. Mark it @runtime_checkable so the registry can use isinstance:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Plugin(Protocol):
name: str
def run(self, config: dict) -> str: ...The Protocol declares one attribute (name: str) and one method (run(self, config: dict) -> str). The body of run is ... — a stub Python never executes; the protocol only describes shape. @runtime_checkable tells Python “build the introspection so isinstance(obj, Plugin) works.”
Step 2: two plugins that satisfy the protocol by shape. Neither imports Plugin. A HelloPlugin (a regular class) and an EchoPlugin (a @dataclass) — both happen to fit the contract:
from dataclasses import dataclass
class HelloPlugin:
name = "hello"
def run(self, config: dict) -> str:
return f"Hello, {config.get('who', 'world')}!"
@dataclass
class EchoPlugin:
text: str
name: str = "echo"
def run(self, config: dict) -> str:
return config.get("text", self.text)
[isinstance(HelloPlugin(), Plugin),
isinstance(EchoPlugin(text="hi"), Plugin),
isinstance("not a plugin", Plugin)][True, True, False]
HelloPlugin is a hand-written class; EchoPlugin is a dataclass — totally unrelated implementation strategies. Both have a name and a run of the right shape, so both pass isinstance(..., Plugin). A str doesn’t have name or run, so it fails. Crucially: neither plugin class inherits from Plugin, and Plugin doesn’t appear in either’s MRO. This is structural typing in action.
Step 3: a registry that validates at the boundary. The point of @runtime_checkable is to fail loudly on bad input at the entry point, while allowing the rest of the code to assume the type:
class Registry:
def __init__(self) -> None:
self._plugins: dict[str, Plugin] = {}
def register(self, plugin: Plugin) -> None:
if not isinstance(plugin, Plugin):
raise TypeError(f"{plugin!r} does not satisfy the Plugin protocol")
self._plugins[plugin.name] = plugin
def run(self, name: str, config: dict) -> str:
return self._plugins[name].run(config)
reg = Registry()
reg.register(HelloPlugin())
reg.register(EchoPlugin(text="default"))
[reg.run("hello", {"who": "Alice"}),
reg.run("echo", {"text": "ping"}),
reg.run("echo", {})]['Hello, Alice!', 'ping', 'default']
class BrokenPlugin:
pass # missing both name and run
reg.register(BrokenPlugin())--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[12], line 4 1 class BrokenPlugin: 2 pass # missing both name and run 3 ----> 4 reg.register(BrokenPlugin()) Cell In[11], line 7, in Registry.register(self, plugin) 5 def register(self, plugin: Plugin) -> None: 6 if not isinstance(plugin, Plugin): ----> 7 raise TypeError(f"{plugin!r} does not satisfy the Plugin protocol") 8 self._plugins[plugin.name] = plugin TypeError: <__main__.BrokenPlugin object at 0x7f822c1fc2f0> does not satisfy the Plugin protocol
Registry.register checks isinstance(plugin, Plugin) and raises TypeError with a clear message if the value is shape-wrong. After that, reg.run(...) can trust the dict’s values are real plugins — no further checking needed inside. BrokenPlugin is rejected immediately because it has neither name nor run.
The build threads the chapter’s lessons through one practical case: a Protocol declares the shape (Step 1), unrelated classes satisfy it without inheritance — including a @dataclass (Step 2), and @runtime_checkable lets the registry use isinstance to enforce the contract at the boundary while the rest of the code stays trusting (Step 3). No ABC, no register() calls, no MRO games.
25.8 Exercises
A duck for
Sized. Write a class with only__len__and confirmisinstance(x, collections.abc.Sized). What aboutIterable— what’s the minimum?A new ABC. Define
Stack(abc.ABC)with abstractpush/popmethods and a concretepeekthat returns the top without removing. Implement it with alist.Protocolfor context managers. Define aContextLikeProtocolrequiring__enter__and__exit__. Annotate a function that takes one. Try passing acontextlib.suppress— doesmypyaccept it?registerinstead of inherit. UseSequence.register(YourClass)to make a class look like aSequencetoisinstance. Why is this fragile?When duck typing wins. Name a case where duck typing (just call the method) is better than declaring a
Protocol. Hint: the cost of declaring the protocol must justify its use.
25.9 Summary
Python’s four typing approaches — duck, goose, static, runtime — are not competing; they’re a toolkit. Duck typing is the lightest. ABCs add runtime enforcement and shared implementation. Protocol adds static enforcement without inheritance. Pick the one that solves your problem at the lowest cost.
Next, Chapter 26 is the cautionary chapter. Inheritance, especially multiple inheritance, has real costs — and Python gives you the tools to use it carefully.