Backend / Python OOP / 09_protocols_and_interfaces.md

Interfaces in Python: duck typing, ABCs and Protocols

Updated 4 interview angles 5 min read source
On this page6
  1. Three ways to say “anything with a .read()”
  2. Choosing
  3. The interfaces Python already defines
  4. Where it breaks in production
  5. Related
  6. Interview angle

Interfaces in Python: duck typing, ABCs and Protocols

Python has no interface keyword, and the interview question underneath “how do you define an interface?” is whether you know the three mechanisms it has instead, and which one to reach for. Type-checker mechanics for Protocol are in Protocol and structural typing; this note is the OOP decision.

Three ways to say “anything with a .read()”

Mechanism Checked Contract lives
Duck typing at call time, by failing nowhere
ABC at instantiation, by inheritance in the base class
Protocol at type-check time, by shape in the type

Duck typing is the default and the oldest: call .read() and let it raise if the object cannot. It costs nothing and documents nothing. The failure surfaces wherever the method is first used, which may be deep inside a library and long after the wrong object was passed in.

An ABC

An ABC makes the contract explicit and enforced. Subclass it, and forgetting a method fails at instantiation rather than at first use:

python
from abc import ABC, abstractmethod

class Storage(ABC):
    @abstractmethod
    def read(self, key: str) -> bytes: ...
    @abstractmethod
    def write(self, key: str, data: bytes) -> None: ...

class Broken(Storage):
    def read(self, key): return b""

Broken()   # TypeError: Can't instantiate abstract class Broken
           # with abstract method write

The cost is nominal typing: a class is a Storage only if it inherits from Storage. A third-party class with the right methods is not one, and you cannot change its bases.

A Protocol

A Protocol is the contract without the inheritance. Any object with the right methods satisfies it, checked statically:

python
from typing import Protocol

class Readable(Protocol):
    def read(self, key: str) -> bytes: ...

def load(store: Readable, key: str) -> bytes:
    return store.read(key)

load(boto3.client("s3"), "k")   # type error: no read(key) - caught by mypy
load(MyStore(), "k")            # fine, and MyStore never heard of Readable

That second call is the whole argument. The consumer declares what it needs; the provider does not have to know. This is dependency inversion without an import in either direction, which is why Protocols are the right default for boundaries between packages and for anything you will want to fake in a test.

Choosing

Reach for an ABC when you own the hierarchy and want shared implementation — a base class with a real close() that three subclasses inherit, plus two abstract methods each must fill. abc also gives you register() for declaring a virtual subclass you cannot edit.

Reach for a Protocol when the thing is defined by what it can do rather than what it is: a callback, a repository interface, a plugin, a file-like object. Most “interfaces” in application code are this kind.

Reach for plain duck typing in a script or a function that will never be called with anything else. Adding a Protocol to a ten-line helper is ceremony.

python
# The plugin case: a Protocol at the boundary, registration by name.
class Exporter(Protocol):
    name: str
    def export(self, rows: list[dict]) -> bytes: ...

EXPORTERS: dict[str, Exporter] = {}

def register(exporter: Exporter) -> Exporter:
    EXPORTERS[exporter.name] = exporter     # type-checked against the shape
    return exporter

The interfaces Python already defines

Most of the language’s own contracts are informal protocols on dunder methods, and collections.abc gives them names you can check against:

You implement You are a
__iter__, __next__ Iterator
__enter__, __exit__ context manager
__call__ Callable
__len__, __getitem__ Sequence-like
__hash__, __eq__ Hashable

isinstance(x, collections.abc.Iterable) works on any class with __iter__ even though it never subclassed anything — the ABC implements __subclasshook__ to check the shape. That is structural typing predating Protocol by a decade, and it is the precedent to cite when someone says Protocols are un-Pythonic.

Where it breaks in production

  • isinstance against a Protocol without @runtime_checkable raises. With it, the check only confirms method names exist — not signatures — so a class with read(self) passes for read(self, key) and fails later.
  • ABC across a package boundary. The other package must import your base class to inherit from it, coupling their release to yours. A Protocol removes the import.
  • Protocol with default implementations. You can put a method body in a Protocol, but implementers only get it if they explicitly subclass the Protocol, which defeats the point. Shared behaviour wants an ABC or a mixin.
  • Optional methods. Neither mechanism expresses “has flush() if it can”. Split into two Protocols and check for the narrower one where it matters.
  • Duck typing failures deep in a call stack. The AttributeError fires three libraries down from the mistake. A Protocol at the boundary moves that to type-check time.

Interview angle 4

  • “How do you define an interface in Python?” - three ways, chosen by who owns what. An ABC when you own the hierarchy and want shared code; a Protocol when the contract is a shape and the implementer should not have to know about you; plain duck typing when nothing else will ever be passed.
  • “Protocol or ABC?” - Protocol at boundaries and for anything you will fake in tests, because the consumer declares the need and the provider stays ignorant of it. ABC when subclasses share real implementation. Follow-up: can a Protocol carry a default method? Only for explicit subclasses, which defeats it - that is the ABC case.
  • “Isn’t structural typing un-Pythonic?” - collections.abc has done it since 2.6: isinstance(x, Iterable) passes for any class with __iter__. Protocol gave the type checker what the runtime already did.
  • “What does @runtime_checkable actually check?” - that the method names exist. Not signatures, not return types. A class with the wrong arity passes isinstance and fails on the call.