Protocols and Structural Typing

A Protocol (typing.Protocol, introduced by PEP 544 and accepted for Python 3.8) is a way to describe an interface by the shape of an object rather than by its ancestry. A class is a structural subtype of a protocol if it provides all the protocol’s members with compatible signatures — it never has to inherit from the protocol or register with it. This is Python’s longstanding “duck typing” — if it walks like a duck and quacks like a duck, treat it as a duck — finally made legible to static type checkers. Crucially, a protocol is primarily a static-analysis construct: by default it has no runtime teeth at all. isinstance() against a plain protocol raises TypeError; only when you opt in with @runtime_checkable do isinstance()/issubclass() work, and even then they check only method presence, never signatures.

This note is the deep half of a contrast pair. Its sibling, Abstract Base Classes, covers the nominal + registration, runtime-enforced approach (the abc module). The full nominal-versus-structural comparison lives here; the ABCs note gives a short recap and points back. Read them together — they are the two ways Python lets you talk about “does this object satisfy interface X?”, and they answer it at opposite ends of the compile/run divide.

Mental Model

The cleanest way to hold protocols in your head is to separate two orthogonal axes. The first axis is how membership is decided: nominally (by what a class declares it inherits from / is registered as — a name-based, “is-a” relationship) versus structurally (by what methods and attributes the class actually has — a shape-based relationship). The second axis is when the check happens: statically (by an external tool — mypy, pyright, the type checker built into your editor — before the program ever runs) versus at runtime (by the interpreter executing an isinstance() call). Protocols live in the structural + static quadrant. Abstract Base Classes live in the nominal + runtime quadrant. The remaining two quadrants are partly occupied too, which is exactly where the confusion lives — @runtime_checkable drags protocols a short, leaky distance into the runtime column, and the collections.abc “one-trick-pony” ABCs use __subclasshook__ to do a structural check at runtime.

flowchart TB
    subgraph STATIC["Checked STATICALLY (by a type checker, before run)"]
        direction LR
        subgraph SN["Nominal (by declaration)"]
            A["PEP 484 inheritance hints<br/>def f(x: SomeBaseClass)"]
        end
        subgraph SS["Structural (by shape)"]
            B["typing.Protocol<br/>(the top-right target)"]
        end
    end
    subgraph RUNTIME["Checked AT RUNTIME (by the interpreter, during isinstance)"]
        direction LR
        subgraph RN["Nominal (by declaration)"]
            C["isinstance(x, MyClass)<br/>abc + .register()"]
        end
        subgraph RS["Structural (by shape)"]
            D["@runtime_checkable Protocol<br/>collections.abc __subclasshook__<br/>(the leaky middle ground)"]
        end
    end

Diagram: the two orthogonal axes — nominal-vs-structural (left/right) and static-vs-runtime (top/bottom) — laid out as a 2×2. The insight: protocols are designed to occupy the top-right (static + structural) quadrant, where a type checker reasons about object shape before the program runs. @runtime_checkable and the collections.abc hooks are the partial leaks into the bottom-right (runtime + structural) quadrant — and because runtime structural checks cannot see signatures, they are the source of most “but isinstance said yes!” surprises.

Why Structural Typing Exists — The Duck-Typing Problem

Python code has always been duck-typed at runtime. A function that does for item in things: works with a list, a set, a generator, a file object, or a hand-rolled class with __iter__ — the interpreter never asks what things is, only whether it responds to the iterator protocol. This is enormously flexible, but it is invisible to a type checker. Before PEP 544, the only way to annotate “this parameter must be iterable” was to name a concrete type or a nominal abstract base class: def f(x: list) (too narrow — excludes tuples, generators) or def f(x: Iterable) (better, but only because collections.abc.Iterable happens to do runtime structural recognition; see below). For your own informal interfaces — “anything with a .render() method”, “anything that has both .read() and .close()” — there was no vocabulary at all. You either invented an ABC and forced every implementer to inherit from or register with it (intrusive, and impossible for third-party classes you do not own), or you gave up and annotated the parameter as Any, throwing away all checking.

PEP 544 calls the inheritance-based approach nominal subtyping and the shape-based approach structural subtyping, and explicitly chose not to replace one with the other: “protocol classes as specified in this PEP complement normal classes” (per PEP 544). A Protocol lets you write down the shape once, and any class — including int, a NumPy array, or a class from a library you cannot modify — that happens to have that shape is accepted by the type checker. The implementer never imports your protocol. This is the decisive advantage over ABCs: structural typing works retroactively and non-intrusively.

from typing import Protocol
 
class SupportsClose(Protocol):
    def close(self) -> None: ...
 
def close_all(items: list[SupportsClose]) -> None:
    for item in items:
        item.close()
 
class Resource:                  # does NOT inherit from SupportsClose
    def close(self) -> None:
        print("closed")
 
close_all([Resource()])          # type checker: OK — Resource has close()

Line by line: class SupportsClose(Protocol) declares a protocol by listing Protocol among the bases — this is the only syntactic marker that distinguishes a protocol from an ordinary class. The body def close(self) -> None: ... declares one required member; the ... (Ellipsis) is the conventional empty body — a protocol method is a specification, not an implementation. close_all annotates its parameter with the protocol. Resource defines close but inherits from nothing — yet close_all([Resource()]) type-checks, because Resource structurally matches SupportsClose. Run this and it works; delete Resource.close and the type checker (not the interpreter, not at this line) flags the call.

What Counts as a Protocol Member

A type checker decides structural compatibility by comparing the members of the candidate class against the protocol’s members. PEP 544 is precise about what a protocol member is: every method defined in the protocol body (regular, @staticmethod, @classmethod, @property, and even @abstractmethod), plus every variable declared via a PEP 526 variable annotation in the class body. Critically, “additional attributes only defined in the body of a method by assignment via self are not allowed” as protocol members (per PEP 544) — the interface must be explicit and introspectable from the class body alone, not buried inside __init__.

from typing import Protocol
 
class Drawable(Protocol):
    name: str                          # data member (PEP 526 annotation)
    line_width: int = 1                # data member with a default
    def draw(self) -> str: ...         # method member
    @property
    def area(self) -> float: ...       # property member

name: str and line_width: int are data members — a structural match requires the candidate to have attributes of compatible type. draw is a method member. area is a property member. A class satisfies Drawable only if it has all four. Note line_width: int = 1 carries a default value; this matters for the explicit-subclass case (below) where the default becomes a real class attribute, but for pure structural matching the type checker only cares that an implementer has a compatible line_width.

The Two Ways to Use a Protocol: Implicit and Explicit

There are two relationships a class can have with a protocol. The implicit one is the whole point of structural typing: a class matches simply by having the right shape, with no mention of the protocol anywhere. The explicit one is opt-in inheritance — you write class C(MyProtocol) — and it buys you two things: any default method implementations the protocol provides are inherited (a protocol may give its methods real bodies, not just ...), and the type checker will verify at definition time that your class actually satisfies the protocol it claims, catching a missing method right at the class rather than at every call site.

from typing import Protocol
 
class Greeter(Protocol):
    def greet(self) -> str:
        return "hello"                 # a real default implementation
 
class Formal(Greeter):                 # EXPLICIT subclass
    def greet(self) -> str:
        return "good day"
 
class Casual(Greeter):                 # EXPLICIT, relies on the default
    pass                               # inherits greet() returning "hello"

Formal(Greeter) explicitly subclasses and overrides greet. Casual(Greeter) explicitly subclasses but defines nothing, so it inherits the default greet returning "hello" — this only works because of explicit inheritance; an implicit match would have to supply its own greet. PEP 544 stresses that “explicit subclassing is not necessary for the sake of type-checking” (per PEP 544) — it is purely for inheriting defaults and getting definition-site verification.

A subtle rule: a protocol cannot extend a non-protocol class. If Proto(Base) where Base is an ordinary class, and C structurally implements Proto, then by transitivity C would have to be a subtype of Base — but Base is nominal, so this breaks the type lattice (per PEP 544). Protocols may only inherit from other protocols (and from Protocol itself). Combining protocols is therefore done by multiple inheritance of protocols:

class SupportsReadClose(SupportsRead, SupportsClose, Protocol): ...

The trailing Protocol is required — a class that inherits from protocols but omits Protocol from its own bases becomes an ordinary (non-protocol) class that merely implements those protocols.

Generic, Recursive, and Callback Protocols

Protocols compose with the rest of the typing system. A generic protocol carries type parameters; PEP 544 infers variance structurally rather than letting you declare it (declared invariance would break transitivity of subtyping). Using the modern PEP 695 syntax available in 3.12+:

class Container[T](Protocol):
    def __contains__(self, item: T) -> bool: ...

A recursive protocol refers to itself through a forward reference (a string), letting you type tree- and graph-shaped interfaces:

class Traversable(Protocol):
    def leaves(self) -> "list[Traversable]": ...

A callback protocol uses __call__ to describe a function-like object with more precision than Callable[...] can — in particular, named and keyword-only parameters:

class Combiner(Protocol):
    def __call__(self, *vals: bytes, maxlen: int | None = None) -> list[bytes]: ...

This is the escape hatch when Callable[[bytes], list[bytes]] is too blunt — Callable cannot express “accepts a keyword argument maxlen”, but a callback protocol can.

@runtime_checkable — Limited Runtime Teeth

By design, a plain protocol raises TypeError if you pass it to isinstance() or issubclass(): “protocols basically would be used to model duck typing statically, not explicitly at runtime” (per PEP 544). The @typing.runtime_checkable decorator opts a protocol into runtime checks. But the decorator’s power is sharply bounded, and the typing documentation is emphatic about exactly two limitations, stated nearly verbatim:

  1. Presence only, never signatures. runtime_checkable() “will check only the presence of the required methods or attributes, not their type signatures or types.” The docs give the canonical gotcha: ssl.SSLObject passes an issubclass() check against Callable because it has a __call__/__init__ member, even though that __init__ exists only to raise TypeError — so the “callable” can never actually be instantiated (per the typing docs). A runtime protocol check confirms the method name is there; it cannot confirm the method does the right thing or even takes the right arguments.

  2. It can be surprisingly slow. An isinstance() check against a runtime-checkable protocol “can be surprisingly slow compared to an isinstance() check against a non-protocol class,” because it must introspect every required member; the docs recommend plain hasattr() for performance-sensitive structural checks (per the typing docs).

from typing import Protocol, runtime_checkable
 
@runtime_checkable
class Closable(Protocol):
    def close(self) -> None: ...
 
assert isinstance(open("/etc/hostname"), Closable)   # True: file has close()

@runtime_checkable rewrites the protocol so the interpreter will accept it in isinstance. The assert succeeds because a file object has a close method — but note nothing checked that close takes zero arguments or returns None.

There is a further, important asymmetry the docs spell out: issubclass() against a runtime-checkable protocol works only for “non-data” protocols — protocols whose members are all methods. A protocol that declares data attributes (like name: str) supports isinstance() but raises if you try issubclass(), because a class object (as opposed to an instance) cannot be inspected for instance attributes that are only set in __init__ (per PEP 544). This is the same reason data-attribute protocols are inherently leaky at runtime — PEP 544’s own example shows an object that is not an instance of a data protocol before initialize() runs and becomes one after, because the attribute springs into existence mid-program:

class P(Protocol):
    x: int
 
class C:
    def initialize(self) -> None:
        self.x = 0
 
c = C()
isinstance(c, P)        # False — c has no x yet
c.initialize()
isinstance(c, P)        # True — now c.x exists

Both behaviors are confirmed by reading _ProtocolMeta in Lib/typing.py at v3.14.5. First, member detection uses inspect.getattr_static, not hasattr: _ProtocolMeta.__instancecheck__ loads it via _lazy_load_getattr_static() and, for each attribute, does val = getattr_static(instance, attr). Using getattr_static is deliberate — it reads attributes without triggering __getattr__, __getattribute__, or descriptor __get__, so a property that would raise, or a __getattr__ that fabricates everything, cannot fool the check. Second, the protocol’s member set is frozen at class creation: _ProtocolMeta.__init__ computes cls.__protocol_attrs__ = _get_protocol_attrs(cls) once when the protocol class is defined, and __instancecheck__ iterates that cached cls.__protocol_attrs__ — so adding or removing methods on the protocol after definition does not change which attributes isinstance looks for. (Note this is about the protocol’s attribute set, not the instance’s: getattr_static still inspects the candidate object’s own __dict__ and MRO live, so an attribute assigned in __init__ is detected, which is exactly the “we need this method for situations where attributes are assigned in __init__” comment in the source.)

The Supports* Family — Protocols in the Standard Library

The typing module ships a family of small, @runtime_checkable protocols that formalize the numeric-coercion duck-typing the interpreter has always done: SupportsInt, SupportsFloat, SupportsComplex, SupportsBytes, SupportsIndex, SupportsAbs, and SupportsRound (per the typing docs). Each declares a single dunder — SupportsInt requires __int__, SupportsIndex requires __index__, and so on — and because they are runtime-checkable you can write isinstance(x, SupportsInt). They are the canonical, library-blessed examples of “structural interface, one method, opt-in runtime check,” and they mirror the one-trick-pony ABCs in collections.abc almost exactly — which is the deepest point of contact between the two systems.

Nominal vs Structural — The Full Comparison

This is the crux of the contrast pair. Both protocols and ABCs answer “does object X satisfy interface Y?”, but they differ on every axis that matters.

AxisProtocols (structural)ABCs (nominal + registration)
How membership is decidedBy shape — the class has the right membersBy declaration — inherit from the ABC, or .register() it
When checkedStatically, by an external type checker, before runAt runtime, by the interpreter, during isinstance
IntrusivenessNon-intrusive — implementer never imports the protocolIntrusive — implementer must inherit or be explicitly registered
Works on third-party / built-in classesYes, retroactively, automaticallyOnly if you .register() them yourself
Runtime isinstanceOnly via @runtime_checkable, presence-only, leakyFirst-class; the primary use
Signature checkingThe static checker checks signatures fullyNever — neither inheritance nor .register() checks signatures
Default method bodiesOnly via explicit subclassingVia inheritance (mixin methods)

The mental rule of thumb: reach for a Protocol when you are describing an informal interface for static checking, especially across code you do not control; reach for an ABC when you need a runtime guarantee, a mix-in with shared method implementations, or you are extending the collections.abc ecosystem. They are not rivals so much as the static and dynamic faces of the same idea. In fact PEP 544 notes that collections.abc classes “already provide structural runtime behavior via __subclasshook__, which protocols formalize for static type checking” (per PEP 544) — protocols are, in a real sense, the static-analysis-era continuation of what the one-trick-pony ABCs were already doing at runtime.

Failure Modes and Common Misunderstandings

isinstance against a protocol must be safe.” No. It only ever checks member presence. The ssl.SSLObject-is-Callable example above is the canonical trap: an object can pass the runtime check and then blow up the moment you actually use it, because the structural check never saw signatures or semantics. Treat @runtime_checkable as a slightly-better hasattr, not as a contract.

“I forgot the trailing Protocol base.” Writing class Combined(ProtoA, ProtoB): (without Protocol) silently produces an ordinary class that implements both protocols, not a new protocol. Type checkers will then refuse to use Combined structurally. Always include Protocol in the bases of a protocol that inherits other protocols.

“Data-attribute protocols behave like method protocols at runtime.” They do not. issubclass() raises for data protocols, and isinstance() results flip as attributes are assigned during __init__ or later — the P.x/C.initialize example demonstrates an object changing protocol membership mid-execution. Only non-data (method-only) protocols are reasonably well-behaved at runtime.

“Protocols enforce anything at runtime by default.” They do not. Without @runtime_checkable, an annotation x: MyProtocol has zero runtime effect; PEP 544 states “no runtime semantics will be imposed for variables or parameters annotated with a protocol class. Any checks will be performed only by third-party type checkers and other tools” (per PEP 544).

Production Notes

The most consequential real-world use of protocols is making libraries interoperable without coupling. Numeric and array libraries (NumPy, the array-API standard) describe “anything that behaves like an array” as a protocol so unrelated implementations interoperate without a shared base class. Web and ASGI frameworks describe the application callable as a callback protocol. The Supports* family lets generic numeric code accept any coercible object. The recurring lesson from these codebases is the discipline the limitations demand: keep @runtime_checkable protocols small and method-only, prefer them for static checking, and when you genuinely need a runtime guarantee that an object behaves correctly (not merely that a method name exists), fall back to a try/except around the actual operation rather than trusting an isinstance against a protocol.

See Also