Skip to content
← All articles

Protocol or ABC: choosing the right kind of interface

Two mechanisms, two different guarantees. A decision rule you can defend in review, the case for shipping both, and the honest limits of ABCMeta.register.

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.