Python's most reusable APIs rarely ask "what family are you from?" They ask whether an object can be iterated, called, awaited, indexed, used as a context manager, converted to a path, or written to like a file. Those behavioral contracts are protocols, whether or not a Protocol class appears in the source.
Inheritance remains useful for shared invariants and implementation. It is a poor default admission ticket. Requiring every input to derive from your base class excludes objects that already have exactly the needed behavior and couples callers to construction and lifecycle choices they do not need.
Python guarantee. Special-method protocols drive syntax and built-ins. Static
typing.Protocolsupports structural subtyping for type checkers. These are related ideas but different enforcement systems: the interpreter does not generally validate annotations.
Version note. Experiments run on CPython 3.14.7 and use Python 3.10+ syntax. Protocol typing behavior also depends on the chosen type checker and its version. CPython special-method lookup caches and slot tables are implementation details; documented operation semantics are portable.
Experiment 1: iteration needs behavior, not ancestry
class Countdown:
def __init__(self, start):
self.start = start
def __iter__(self):
current = self.start
while current:
yield current
current -= 1
countdown = Countdown(3)
print("[result] countdown values:", list(countdown))
print("[check] countdown is a list:", isinstance(countdown, list))
The object works with list, loops, unpacking, and comprehensions without inheriting from a collection base. __iter__ is the behavioral boundary. This is runtime duck typing built into the language's data model.
Python contains many small protocols. len(x) asks for __len__; x[y] asks for subscription behavior; with x coordinates __enter__ and __exit__; os.fspath(x) asks for __fspath__. Learning these contracts usually yields more interoperability than building project-specific root classes.
Special methods are looked up on the type
Implicit syntax normally looks up special methods on the object's type, bypassing an instance dictionary and some ordinary attribute hooks. This preserves consistent behavior for the interpreter and avoids accidental metaclass confusion.
Experiment 2: attaching __len__ to one instance is insufficient
class Box:
pass
box = Box()
box.__len__ = lambda: 7
print("[result] explicit instance __len__:", box.__len__())
try:
print("[result] built-in len before class method:", len(box))
except TypeError as error:
print("[error] built-in len before class method:", type(error).__name__)
Box.__len__ = lambda self: 8
print("[result] built-in len after class method:", len(box))
An explicit dotted call finds the instance attribute; len does not. A runtime protocol is therefore not always equivalent to "this instance has an attribute with that spelling." For special syntax, implement methods on the class.
Static protocols describe a capability
typing.Protocol lets annotations state a structural contract. A type conforms when it provides compatible members, without explicit inheritance or registration. Keep protocols as small as the consumer's actual needs. A function that only calls write(str) should not require your entire ApplicationLogger hierarchy.
Experiment 3: one API accepts unrelated writers
from io import StringIO
from typing import Protocol
class TextWriter(Protocol):
def write(self, text: str) -> object:
...
def emit(destination: TextWriter, message: str) -> None:
destination.write(message + "\n")
class Collector:
def __init__(self):
self.parts = []
def write(self, text: str) -> None:
self.parts.append(text)
buffer = StringIO()
collector = Collector()
emit(buffer, "ready")
emit(collector, "ready")
print("[result] StringIO contents:", buffer.getvalue().strip())
print("[result] collector parts:", collector.parts)
Neither class declares TextWriter. A type checker can still verify calls. At runtime, annotations do not wrap emit; a bad object fails when write is attempted unless the application validates earlier.
The return type is deliberately object because this consumer ignores it and real writers vary. Over-specifying return values, mutability, or helper methods turns a useful protocol into disguised nominal coupling.
Runtime-checkable protocols are shallow
Decorating a protocol with @runtime_checkable enables isinstance and issubclass checks based on member presence. Runtime checks do not validate signatures or type annotations. They are suitable for coarse dispatch and diagnostics, not proof that calls are safe.
Experiment 4: presence is not signature compatibility
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closer(Protocol):
def close(self) -> None:
...
class Awkward:
def close(self, reason):
print("[event] close reason:", reason)
item = Awkward()
print("[check] runtime Closer membership:", isinstance(item, Closer))
try:
item.close()
except TypeError as error:
print("[error] incompatible close signature:", type(error).__name__)
The check succeeds while the call fails. Static analysis can compare signatures; runtime structural checks only answer a narrower question. Python 3.12 changed runtime protocol implementation details, including using static attribute lookup and freezing protocol members at class creation. Treat those mechanics as version-specific and rely only on documented shallow semantics.
ABCs occupy a different point
Abstract base classes can provide implementation, enforce abstract methods at instantiation, and support explicit or virtual subclass registration. Standard collection ABCs also offer mixins. They are useful when a nominal relationship or reusable laws matter.
Virtual subclass registration affects isinstance without adding methods or verifying behavior. __subclasshook__ can infer conformance, but it carries the same risk as other shallow checks.
Experiment 5: registration promises more than it supplies
from abc import ABC, abstractmethod
class Renderable(ABC):
@abstractmethod
def render(self):
raise NotImplementedError
class Empty:
pass
Renderable.register(Empty)
value = Empty()
print("[check] registered as Renderable:", isinstance(value, Renderable))
print("[check] render method supplied:", hasattr(value, "render"))
Registration says True; it does not inject render. Use virtual registration only when an existing type genuinely fulfills the contract and a nominal runtime check is required. Otherwise it creates confidence unsupported by behavior.
Adapters preserve boundaries
Sometimes an external object almost implements a protocol but uses different names or semantics. Do not force it into your hierarchy or scatter conditional calls. A tiny adapter translates once and can enforce units, error policy, and lifecycle.
Experiment 6: adapt shape without changing the source type
class LegacySink:
def append_line(self, value):
print(f"[event] legacy sink appended: {value}")
class WriterAdapter:
def __init__(self, sink):
self.sink = sink
def write(self, text):
for line in text.splitlines():
self.sink.append_line(line)
def announce(writer):
writer.write("one\ntwo")
announce(WriterAdapter(LegacySink()))
The adapter makes translation explicit and testable. It also avoids monkey-patching or subclassing a third-party class whose construction and future methods you do not control.
Protocols compose at the consumer
A large interface often reflects a producer's full feature set rather than one consumer's needs. Define capabilities near consumers and combine only where an operation truly needs both. Static protocols can inherit from other protocols without creating a runtime implementation hierarchy.
Experiment 7: separate read and close capabilities
from typing import Protocol
class Reader(Protocol):
def read(self, size: int = -1) -> str:
...
class Closer(Protocol):
def close(self) -> None:
...
class ReadAndClose(Reader, Closer, Protocol):
pass
def consume(source: Reader) -> str:
return source.read()
class Constant:
def read(self, size=-1):
return "payload" if size < 0 else "payload"[:size]
print("[result] consumed payload:", consume(Constant()))
print("[check] first composed protocol base:", ReadAndClose.__mro__[1].__name__)
consume does not demand cleanup behavior it never uses. Another operation can request ReadAndClose. This interface-segregation style improves tests: a small fake implements only the meaningful boundary, not twenty irrelevant abstract methods.
Protocol methods still need semantic laws
Matching names and signatures is necessary but often insufficient. Hashable objects must keep equality and hash consistent. Iterators must eventually raise StopIteration. Context managers must follow exception-suppression rules. A repository's commit method may promise atomicity that typing cannot express.
Document these laws in prose and contract tests. Structural typing catches shape errors; it cannot prove idempotence, ordering, ownership, thread safety, or transactional guarantees.
Experiment 8: shape can satisfy while semantics fail
class BrokenIterator:
def __iter__(self):
return self
def __next__(self):
return 1 # Never terminates.
iterator = BrokenIterator()
print("[result] first two iterator values:", next(iterator), next(iterator))
# Bound the experiment: list(iterator) would never finish.
print("[result] next three bounded values:", [next(iterator) for _ in range(3)])
The object has the iterator shape but violates an expected finiteness property for many uses. Infinite iterators are legitimate when intentional; the point is that member checks cannot infer the semantic contract a consumer assumes.
Engineering guidance
Start API design from operations. Write the function against the smallest behavior it consumes, then name that capability if static reuse warrants it. Prefer standard protocols such as iterable, mapping, path-like, context manager, and file-like conventions before inventing project vocabulary.
Use nominal inheritance when implementations share protected invariants, construction rules, or reusable algorithmic skeletons. Use an ABC when runtime instantiation enforcement or mixin methods provide real value. Use Protocol for static compatibility across independently authored types. Use @runtime_checkable sparingly and remember its shallow check. Use an adapter when semantics or names differ.
Avoid defensive hasattr ladders that probe several spellings. They obscure the contract and can swallow property errors. Usually call the documented operation and let the resulting exception identify a broken boundary, or validate once at an external configuration edge.
Do not create fake objects by inheriting a production base solely to pass isinstance. A narrow protocol makes test doubles honest. Conversely, do not publish enormous "god protocols" copied from concrete classes; they preserve all the coupling with fewer runtime safeguards.
Static checker behavior is tooling, not language execution. Run the configured checker in CI and pin or document its version when advanced variance, recursive protocols, or overload behavior matters. The examples here are runtime-valid, but full static verification depends on project settings.
Evolving a behavioral boundary
Adding a required protocol member is a breaking change even though no base class changes. Independently authored implementations may stop conforming at the next type-check run. Prefer creating a second capability and requesting it only in operations that need it. This keeps existing consumers and implementations honest rather than turning one protocol into a growing service locator.
Removing or loosening a requirement is usually compatible for callers but can expose assumptions hidden in implementations. Contract tests should exercise behavior through the consuming function, not merely check isinstance. Publish small reference implementations or test suites when third parties implement important semantic laws.
Properties require care in structural contracts. A read-only protocol property can often be supplied by an attribute or property with a compatible type, while a writable attribute imposes stronger variance constraints in static tools. Model what the consumer does: if it only reads name, do not require a setter merely because one implementation has one.
Callbacks are protocols too. Their positional and keyword behavior, exception policy, and sync or async nature belong in the type. Replacing a callback base class with Callable or a protocol often removes ceremony, but a named protocol is clearer when the callable also exposes state or several operations.
At external boundaries, structural compatibility does not validate untrusted input. Parsing JSON into an object with matching attribute names is data validation, not protocol conformance. Validate shape and values first, then adapt the result to an internal capability. Likewise, network services that happen to expose similarly named methods do not share a Python protocol unless transport failures and serialization semantics are incorporated.
A useful review question is: "Could a standard-library or third-party object satisfy this operation without knowing our package exists?" If yes, a nominal base requirement is probably accidental. If no because construction, invariants, or lifecycle are genuinely shared, inheritance may communicate something important. Protocol-oriented design is not an absolute ban; it is a demand that coupling pay for itself.
Exercises
- Replace a base-class parameter in your code with a protocol containing only members the function uses.
- Create an object that passes a runtime-checkable protocol but fails because of an incompatible property or signature.
- Compare an ABC, a protocol, and an adapter for a third-party message producer. State which guarantees each provides.
- Write contract tests for a semantic law that annotations cannot express, such as idempotent
close. - Find a standard-library protocol your project reimplemented and simplify the boundary around it.
Keep this model
Protocols make behavior the unit of compatibility. Python's runtime data model already works this way: syntax dispatches to small special-method contracts. typing.Protocol lets static tools describe similarly structural application boundaries without forcing implementation ancestry.
Inheritance is not obsolete. It is simply stronger coupling than most consumers need. Reach for it when shared invariants and implementation justify a family. For admission to an operation, prefer the narrow capability, document its semantic laws, and adapt near boundaries. That produces APIs which accept more legitimate objects while promising less fiction.