Python does not check that an object has the method you are about to call. That is duck typing, and it is why Python code composes so easily — and why a typo surfaces somewhere unhelpful.
Where duck typing hurts
class JsonExporter:
def export(self, rows): return f"json:{len(rows)}"
class CsvExporter:
def export(self, rows): return f"csv:{len(rows)}"
class Broken:
def exprot(self, rows): return "typo" # misspelled
def run(exporter, rows): return exporter.export(rows)
print(run(JsonExporter(), [1,2]), run(CsvExporter(), [1,2]))
try:
run(Broken(), [1,2])
except AttributeError as err:
print(type(err).__name__ + ':', err)
It prints:
json:2 csv:2
AttributeError: 'Broken' object has no attribute 'export'
The two good ones work with no shared base class, which is duck typing earning its keep. Broken fails — but inside run, at call time, possibly in production, and the traceback points at the caller rather than the class with the typo.
Two tools fix that, from opposite ends.
ABC: the class promises up front
from abc import ABC, abstractmethod
class Exporter(ABC):
@abstractmethod
def export(self, rows): ...
def describe(self): return f"{type(self).__name__} exporter"
class GoodExporter(Exporter):
def export(self, rows): return f"good:{len(rows)}"
print(GoodExporter().describe(), GoodExporter().export([1,2]))
class BadExporter(Exporter):
pass
try:
BadExporter()
except TypeError as err:
print(type(err).__name__ + ':', str(err)[:80])
It prints:
GoodExporter exporter good:2
TypeError: Can't instantiate abstract class BadExporter without an implementation for abstr
The failure moved to instantiation, which is much earlier and much clearer. An ABC can also ship shared behaviour — describe here — which a Protocol cannot.
The cost: the class must inherit from your ABC. You are back to an inheritance relationship, with everything Part 6 said about that.
Protocol: the caller states what it needs
from typing import Protocol, runtime_checkable
@runtime_checkable
class SupportsExport(Protocol):
def export(self, rows) -> str: ...
print('JsonExporter matches :', isinstance(JsonExporter(), SupportsExport))
print('Broken matches :', isinstance(Broken(), SupportsExport))
print('JsonExporter subclasses SupportsExport?', issubclass(JsonExporter, SupportsExport))
It prints:
JsonExporter matches : True
Broken matches : False
JsonExporter subclasses SupportsExport? True
JsonExporter never heard of SupportsExport and matches anyway, because it has the right method. This is structural typing — the shape is the contract. Type checkers enforce it before the code runs; @runtime_checkable additionally allows isinstance.
Note the runtime check only verifies the method exists, not its signature. The static checker does the real work.
The difference that decides it
class ThirdParty: # imagine this is in a library
def export(self, rows): return f"vendor:{len(rows)}"
print('Protocol accepts it :', isinstance(ThirdParty(), SupportsExport))
print('ABC accepts it :', isinstance(ThirdParty(), Exporter))
It prints:
Protocol accepts it : True
ABC accepts it : False
You cannot make a third-party class inherit from your ABC. You can absolutely write a Protocol it already satisfies.
That is the whole trade:
- ABC — you own all the implementations, and you want to share behaviour as well as require an interface.
- Protocol — implementations come from elsewhere, or you simply want to describe what a function needs without demanding anyone inherit anything.
What to remember
-
Duck typing fails at the call site; both tools move the failure earlier.
-
An ABC enforces at instantiation and can carry shared code, but demands inheritance.
-
A Protocol is structural — anything with the right shape matches, including code you do not control.
-
@runtime_checkableonly checks method names exist. Signatures are the type checker’s job.
For a function parameter, prefer a Protocol: it says what you need without constraining who can supply it. Reach for an ABC when you are building a family of classes you own and they share real behaviour.