Both abc.ABC and typing.Protocol are called “interfaces” in casual
conversation, and the conversation goes badly from there, because they make
entirely different promises. This is a trade-off, not a verdict — the goal
here is a rule you can state out loud in a design review and then live with.
The two guarantees
abc.ABC gives you enforcement at construction time. ABCMeta collects
every member marked __isabstractmethod__ into cls.__abstractmethods__,
and object.__new__ refuses to build an instance while that set is
non-empty:
TypeError: Can't instantiate abstract class PartialCodec
without an implementation for abstract method 'name'
It also gives you a place to put shared implementation — a concrete template method written once in terms of the abstract ones.
The price is nominal typing. To satisfy the interface you must be a subclass, which means you must be able to change the class. That is fine for types you own and impossible for types you do not.
typing.Protocol gives you structural conformance, checked statically at
the point of use. A class satisfies it by having the members. It need not
import the protocol, appear near it in the file tree, or know it exists.
Nothing is enforced at construction time — a protocol is a claim the type
checker verifies, and if you skip the type checker, nothing happened.
💡A colleague says "let us just make it an ABC — Protocols are checked by mypy, and mypy is optional, so ABCs are strictly safer." Where does that argument go wrong? click to reveal
It conflates when the check happens with what is checked.
An ABC checks one thing: are the abstract member names bound on the
class. It does not check signatures, return types, or that the
implementation does anything sensible. def name(self, x, y, z): return 7
satisfies an abstract name property in every way ABCMeta cares about. So
“runtime enforcement” buys you a narrower guarantee than it sounds like.
A Protocol checks signatures, parameter names, defaults, variance and return
types — a far stronger claim — but only when a checker runs. In a codebase
with mypy --strict in CI, that is every commit, which is more often than
most production code paths execute.
The other half of the answer is that the ABC’s guarantee only reaches
classes that subclass it. The plugin that a third party ships, the stdlib
object you want to accept, the Mock in your test suite — an ABC has
nothing to say about any of them, and the “safety” evaporates precisely at
the boundary you were worried about.
The honest framing: ABC = weak check, always runs, only for types you own. Protocol = strong check, runs in CI, works on anything.
The decision rule
Ask one question: do I own the hierarchy?
| Situation | Choose |
|---|---|
You own the types, want shared implementation, and want TypeError when a subclass is incomplete |
ABC |
| You are describing something you do not own — stdlib types, a third-party client, a test double, a plugin from another team | Protocol |
| You are defining a boundary and providing a convenience base for your own implementations | Both |
That third row is not a cop-out. It is a genuinely good pattern:
class Codec(Protocol): # published; what callers depend on
def encode(self, text: str) -> str: ...
def decode(self, blob: str) -> str: ...
class BaseCodec(ABC): # internal; convenience for our impls
@abstractmethod
def encode(self, text: str) -> str: ...
@abstractmethod
def decode(self, blob: str) -> str: ...
def roundtrip(self, text: str) -> str:
return self.decode(self.encode(text))
Callers annotate against Codec and can pass anything shaped right. Your own
codecs inherit BaseCodec, get roundtrip for free, and get a TypeError
if someone lands a half-finished class. The protocol is the contract; the
ABC is an implementation detail you are free to delete later.
💡You are writing a library. A function needs "something with a read(size) -> bytes method". You control nothing about what callers will pass. Which do you choose, and what does the wrong choice cost your users concretely? click to reveal
A Protocol — and this is the case where the wrong choice is most expensive.
Choose an ABC and every caller must either subclass your base or write an
adapter. That means a user with an io.BytesIO, an http.client.HTTPResponse,
an open() file object, a socket.makefile() result or a
unittest.mock.Mock cannot pass it directly. None of those are classes they
can change. You have made every one of them write a five-line wrapper class,
and those wrappers now exist forever in their codebase, with their own bugs.
With a Protocol, all five work as-is, and so does the sixth one you have not thought of.
The stdlib already made this decision for you and it is worth copying:
typing ships SupportsRead, SupportsWrite, SupportsIndex, SupportsInt
and friends precisely because “has this one method” is the real requirement,
and nobody can be asked to subclass to prove it.
What ABCMeta.register does not do
SomeABC.register(SomeClass) creates a virtual subclass. After it,
issubclass(SomeClass, SomeABC) is True and isinstance agrees.
It does not check that the class has the methods at all. None of them. You can register an empty class against a five-method ABC and the runtime will cheerfully report it as a subclass.
class Reader(ABC):
@abstractmethod
def read(self, size: int) -> bytes: ...
class NotAReader:
pass
Reader.register(NotAReader)
isinstance(NotAReader(), Reader) # True. It has no read method.
And type checkers largely ignore register — mypy will not treat the
registered class as a subtype. So the mechanism gives you a runtime lie and
no static benefit. It exists for the stdlib’s own numeric-tower and
collections registration, where the maintainers verify conformance by other
means. In application code it is almost always the wrong tool; what you
wanted was a Protocol.
💡If register gives no static benefit and no runtime verification, why does collections.abc rely on it so heavily?
click to reveal
Because collections.abc is registering C-implemented builtins that
cannot be retrofitted into a Python class hierarchy. list cannot be made to
inherit from a Python MutableSequence after the fact; the memory layout is
fixed and the type was created before the ABC existed. register is the
escape hatch that lets isinstance([], Sequence) be true.
The correctness of those registrations is guaranteed by CPython’s own test suite, not by the mechanism. That is the part that does not transfer to your code: the stdlib has a reason to trust its registrations that you do not have for yours.
There is also a subtlety worth carrying: collections.abc mixes both
approaches. Several of its ABCs — Hashable, Iterable, Sized,
Container — implement __subclasshook__, so isinstance(x, Iterable)
is a genuine structural check for __iter__ rather than a registration
lookup. That is Protocol-like behaviour, hand-rolled before Protocol
existed. It is also why isinstance(x, Iterable) returns True for objects
that only implement __getitem__ and will still fail when you iterate them:
the hook checks __iter__, and the old-style iteration protocol is invisible
to it.
Cost, briefly
A Protocol member lookup at type-check time is free at runtime; a Protocol
used with isinstance is not free — @runtime_checkable checks are
noticeably slower than hasattr, and the docs say so. An ABC’s cost is one
__instancecheck__ through ABCMeta, cached after the first call per type.
Neither cost should drive the decision. Ownership should.
The rule, one line
Own the type and want a shared base? ABC. Describing someone else’s type? Protocol. Defining a public boundary? Publish the Protocol, keep the ABC private.