Python has always encouraged behavior-oriented code: if an object can produce lines, a parser may not care whether it is a file, socket wrapper, fixture, or in-memory adapter. Static structural typing gives that style a vocabulary. A Protocol says which operations a consumer needs without requiring providers to inherit from a shared base.
Runtime Python and static analysis answer different questions, however. A type checker reasons about declared shapes before execution. isinstance, abstract base classes, and simply attempting an operation inspect or exercise objects at runtime. Confusing those layers creates checks that look reassuring while proving very little.
Version note.
typing.Protocolwas standardized by PEP 544 and is available in Python 3.8+. Examples target 3.10 through 3.14 and were verified on CPython 3.14. Runtime-check behavior has changed across versions, notably in Python 3.12; those changes are documented below.
Experiment 1: compatibility without inheritance
from typing import Protocol
class Closer(Protocol):
def close(self) -> None: ...
class MemoryBuffer:
def __init__(self):
self.closed = False
def close(self) -> None:
self.closed = True
def finish(resource: Closer) -> None:
resource.close()
buffer = MemoryBuffer()
finish(buffer)
print("[check] buffer closed through protocol:", buffer.closed)
MemoryBuffer does not inherit Closer or register itself. A static type checker accepts it because its visible close method has a compatible signature. This is structural subtyping: compatibility follows the required structure. Ordinary execution does not consult the annotation; finish calls close exactly as it would without types.
Protocols are most useful at consumer boundaries. finish needs one capability, so its protocol says one thing. A broad Resource protocol containing open, close, read, write, seek, context management, naming, and metrics would reject useful providers and couple the consumer to operations it never uses.
Annotations are not runtime validators by default. Python stores many annotations for tools and introspection, subject to version-specific annotation evaluation rules, but it does not enforce an argument's protocol when entering a function. Static checking happens in tools such as mypy or pyright, outside this runnable example.
Experiment 2: protocol attributes describe readable and writable shape
from typing import Protocol
class Job(Protocol):
id: str
attempts: int
class QueueJob:
def __init__(self, job_id: str):
self.id = job_id
self.attempts = 0
def retry(job: Job) -> str:
job.attempts += 1
return f"{job.id}:{job.attempts}"
item = QueueJob("job-7")
print("[result] retried job state:", retry(item))
A plain protocol attribute generally permits reading and writing and is invariant in static checking. That protects consumers such as retry, which assign an integer. If a provider exposed a covariantly narrower value while allowing mutation, the consumer could violate its invariant.
Use a read-only @property in the protocol when consumers only read. Read-only values can support more flexible covariance. Match mutability to actual use instead of adding setters for superficial compatibility.
Methods need accurate parameter names when keyword calls are possible. A provider accepting def write(self, data) is not fully compatible with a protocol whose consumers call write(payload=...). Positional-only and keyword-only distinctions are contracts. Structural typing checks more than the number of arguments.
Data protocols also reveal a design smell when they list many fields. Consumers tightly coupled to representation may need a domain value object rather than an interface-shaped bag of attributes.
Experiment 3: callback protocols express rich call shapes
from typing import Protocol
class Decoder(Protocol):
def __call__(self, payload: bytes, *, strict: bool = ...) -> str: ...
class Utf8Decoder:
def __call__(self, payload: bytes, *, strict: bool = True) -> str:
errors = "strict" if strict else "replace"
return payload.decode("utf-8", errors=errors)
def receive(data: bytes, decoder: Decoder) -> str:
return decoder(data, strict=False)
print("[result] decoded payload:", receive(b"valid", Utf8Decoder()))
Callable[[bytes], str] is concise for simple positional signatures. A callback protocol can represent keyword-only parameters, overloads, generic methods, and additional attributes. It also admits functions, methods, partials, and callable instances if their signatures fit.
The ellipsis as the protocol's default says a default exists without prescribing its runtime value. The concrete provider chooses True. Consumers can omit the argument because both shapes promise it is optional.
Static compatibility remains directional. A provider must safely accept every call the consumer may make and return a value the consumer can use. Parameter types are contravariant in principle; return types are covariant. Type checkers implement the detailed rules. Do not weaken everything to Any merely to silence a mismatch: that opts out of the evidence the protocol was meant to provide.
Experiment 4: runtime-checkable checks presence, not signatures
from typing import Protocol, runtime_checkable
@runtime_checkable
class SupportsClose(Protocol):
def close(self) -> None: ...
class WrongClose:
def close(self, reason):
print("[event] close reason:", reason)
candidate = WrongClose()
print("[check] shallow protocol match:", isinstance(candidate, SupportsClose))
try:
candidate.close()
except TypeError as error:
print("[error] incompatible close signature:", type(error).__name__)
@runtime_checkable permits isinstance and issubclass checks for a protocol. The check verifies required attribute presence, not type annotations or call signatures. WrongClose passes and then fails the intended zero-argument call. Runtime-checkable protocols are shallow capability hints, not validation.
Python 3.12 changed runtime protocol lookup to use inspect.getattr_static, and protocol member sets are frozen when the class is created. Monkey-patching members onto the protocol later no longer changes runtime instance checks. This is version-specific behavior; code should define complete protocols up front regardless.
Runtime checks can also be slower than nominal isinstance checks. Never place them in a hot loop without measurement. More importantly, ask whether checking first improves anything. If the next action is resource.close(), calling it directly often gives the clearest error and avoids a check-then-use race in dynamic systems.
Use runtime checks at genuine branching boundaries, such as accepting either a path or a readable stream, while being aware of ambiguous objects. Explicit overloads or separate functions can be clearer than capability guessing.
Experiment 5: ABCs can provide runtime identity and implementation
from abc import ABC, abstractmethod
class Store(ABC):
@abstractmethod
def load(self, key):
raise NotImplementedError
def require(self, key):
value = self.load(key)
if value is None:
raise KeyError(key)
return value
class DictStore(Store):
def __init__(self, values):
self.values = values
def load(self, key):
return self.values.get(key)
store = DictStore({"theme": "dark"})
print("[check] nominal Store instance:", isinstance(store, Store))
print("[result] required stored value:", store.require("theme"))
An ABC uses nominal inheritance by default, prevents instantiation while abstract methods remain, and can provide concrete mixin behavior. That is valuable when the abstraction owns reusable implementation, construction rules, or a runtime family identity.
A protocol is preferable when existing and third-party types should conform without modification and consumers need only shape. An ABC is preferable when providers deliberately join a framework and inherit useful machinery or invariants. Some APIs expose both: an ABC for implementations and a smaller protocol for consumers.
ABC virtual subclass registration allows runtime recognition without inheritance, and __subclasshook__ can customize structural recognition. Neither injects methods or verifies semantics. Overusing registration creates global claims that are difficult to revoke. Keep adaptation explicit when behavior must be transformed.
Experiment 6: generic protocols preserve relationships
from typing import Protocol, TypeVar
T = TypeVar("T")
class Source(Protocol[T]):
def read(self) -> T: ...
class IntSource:
def __init__(self, value: int):
self.value = value
def read(self) -> int:
return self.value
def duplicate(source: Source[T]) -> tuple[T, T]:
value = source.read()
return value, value
print("[result] duplicated source value:", duplicate(IntSource(42)))
The type variable connects the provider's return type to duplicate's result. Without it, a protocol returning object would discard useful information, while a protocol fixed to int would reject string or domain-object sources.
Python 3.12 introduced type-parameter syntax such as class Source[T](Protocol):; the older TypeVar spelling remains appropriate for a 3.10+ article. Syntax availability and typing semantics must be separated. Publishing code with new syntax raises SyntaxError before a checker can help on older interpreters.
Variance determines whether, for example, a source of a subtype can stand in for a source of a base type. Read-only producers are commonly covariant; consumers are commonly contravariant; mutable combinations tend to be invariant. Let actual reads and writes guide variance rather than adding flags until a checker becomes quiet.
Static evidence is not runtime evidence
Type checking can establish that analyzed call sites and declared providers fit under a tool's model. It cannot prove that untyped input, plugin loading, monkey-patching, reflection, network data, or ignored errors are safe. Runtime validation belongs where untrusted values cross boundaries.
Conversely, runtime attribute checks cannot establish signature compatibility, return types, side effects, exception behavior, complexity, thread safety, or semantic meaning. An object with commit() might commit a database transaction or a source-control operation. Shared spelling is not shared contract.
Tests cover concrete behavior under examples. Types cover families of possible compositions. Runtime validation checks actual values at boundaries. These tools complement one another; none is a stronger universal replacement for the others.
Evolving protocols without trapping providers
Adding a required member to a public protocol is a breaking change for every provider, even though no base class changed. Consumer-owned, narrow protocols reduce that blast radius. If one new operation is needed by one function, define a focused extension protocol rather than expanding a central interface.
Optional protocol members are not directly modeled as "maybe present" in the same interface. Split capabilities and use narrowing where optional behavior is truly useful. But avoid capability probing as a substitute for a coherent API. Two explicit strategies can be easier to test than an object whose behavior changes according to whichever attributes happen to exist.
Keep protocols near the code that consumes them when they are local architectural seams. Shared package-level protocols make sense only when multiple consumers truly agree on the same contract.
Adapters turn near-matches into contracts
Structural compatibility should not pressure unrelated providers into misleading names. Suppose a third-party client exposes shutdown(wait=True) while a consumer needs close() -> None. They are not compatible merely because both end a lifecycle. A small adapter can decide the wait policy and expose the exact consumer protocol.
Adapters are also the right place for unit conversion, exception translation, asynchronous bridging, and ownership rules. A runtime check cannot perform those changes. ABC registration cannot perform them either. Explicit adaptation makes the semantic decision testable and keeps type declarations honest.
Mocks deserve the same discipline. A dynamically permissive mock can appear to satisfy any protocol and allow misspelled methods until assertions run. Use a spec, autospec, or a small fake that implements the protocol's real behavior. Static checking of test doubles catches drift only when those doubles are annotated and included in analysis.
Protocol modules must avoid creating import cycles. Since protocols usually need only annotations, place truly shared contracts in a dependency-neutral module, or use guarded type-only imports where appropriate. Do not centralize every protocol in one global interfaces file; that recreates broad coupling under a structural name.
At package boundaries, publish protocols only when third parties are expected to implement them. Otherwise an ordinary concrete annotation may communicate supported behavior more accurately. Structural openness is a compatibility promise: external implementations will depend on member names, signatures, variance, and documented semantics.
Exercises: test each layer
- Define a read-only protocol property and compare checker behavior with a writable attribute using a subtype value.
- Create a runtime-checkable callable protocol, then demonstrate an object that passes presence checking with an incompatible signature.
- Model a serializer using an ABC with a concrete file-writing helper and a narrow protocol consumed by an HTTP handler.
- Write a generic
Parser[T]protocol connecting input parsing to a result type. - Identify a broad interface in a project and split it into consumer-specific capabilities. List which implementations become easier.
- Compare direct operation,
hasattr, runtime protocol checking, and an ABC check at one real boundary. Explain what each proves.
Keep this model
Structural typing lets consumers specify behavior without requiring inheritance. Protocols are primarily static contracts; runtime-checkable protocols perform shallow presence checks only. ABCs provide nominal runtime identity, abstract construction constraints, and optional implementation reuse.
Choose according to the evidence needed. Use narrow protocols for static composition, ABCs for deliberate framework membership, runtime validation for untrusted values, and direct operations when failure already communicates the problem. Keep version-specific typing syntax separate from the stable architectural idea.