Python typing.Protocol: Structural Typing vs ABCs

A Python Protocol lists methods, and any object that has them conforms with no inheritance. Type-checked duck typing, runtime checks, and Protocol vs ABC.

  • Course: Python study plan
  • Module: Inheritance, protocols and duck typing
  • Kind: Lesson
  • Reading time: 13 min
  • Runtime: CPython 3.11

What is a Protocol in Python?

A Protocol, from the typing module (Python 3.8+), is a class that lists methods and attributes; any object that has them satisfies it, with no inheritance or registration. That is structural typing: a type checker such as mypy verifies every call site, while nothing changes at run time. It lets type hints describe duck-typed code, such as a parameter accepting anything with a speak() method.

Lesson

Duck typing has always been how Python code works; typing.Protocol (3.8) is how a type checker can verify it. A protocol is a class that lists methods and attributes; any object that has them satisfies the protocol — no inheritance, no registration — and mypy will report a caller that passes something that does not. That is structural typing (shape decides) as opposed to nominal typing (declared lineage decides), and it is what type hints for duck-typed code should say. This lesson covers declaring a protocol, using it as a hint, @runtime_checkable for isinstance, protocols with attributes and generic protocols, the Supports… protocols in the standard library, and when to choose a protocol over an ABC.

Declaring and using a protocol

from typing import Protocol

class Speaker(Protocol):
    def speak(self) -> str: ...

class Dog:                          # no inheritance from Speaker
    def speak(self) -> str:
        return "woof"

class Robot:
    def speak(self) -> str:
        return "beep"

def chorus(things: list[Speaker]) -> str:
    return " ".join(t.speak() for t in things)

chorus([Dog(), Robot()])            # checker: fine — both have speak() -> str
chorus([Dog(), 42])                 # checker: error — int has no speak

The protocol body lists the members with ... bodies. Nothing changes at run time: Dog and Robot are ordinary classes, chorus calls speak() as before. The hint list[Speaker] tells the checker the shape, and the checker verifies every call site. Method signatures matter — a speak(self, loud: bool) would not match — and return types must be compatible.

runtime_checkable

By default isinstance(x, Speaker) raises TypeError, because a protocol is a static notion. @runtime_checkable allows it, checking that the method names exist (not their signatures):

from typing import Protocol, runtime_checkable

@runtime_checkable
class Closable(Protocol):
    def close(self) -> None: ...

if isinstance(resource, Closable):
    resource.close()

This is hasattr with a name and a hint, and it is the correct run-time counterpart of the static check. It only inspects presence: an object with a close attribute that is not callable would pass.

Attributes and properties in a protocol

class Named(Protocol):
    name: str                       # a data attribute

class HasArea(Protocol):
    @property
    def area(self) -> float: ...    # a read-only property (a plain attribute satisfies it too)

An attribute in a protocol matches an instance attribute, a class attribute or a property of the same name and compatible type. This is how a hint says "anything with a .name" — the shape of a dataclass, a named tuple and a hand-written class all match.

The standard protocols

typing and collections.abc already define the ones most code needs, so write your own only for your own operations:

ProtocolRequires
Iterable[T], Iterator[T]__iter__ / __next__
Sized, Container[T], Collection[T]__len__, __contains__, both plus __iter__
Sequence[T], Mapping[K, V]the sequence / mapping methods
Callable[[A, B], R]__call__ with that signature
Hashable__hash__
SupportsInt, SupportsFloat, SupportsIndex, SupportsAbs__int__, __float__, __index__, __abs__
ContextManager[T]__enter__ / __exit__

The collections.abc names are both ABCs (for inheritance and isinstance) and, to the checker, structural: a parameter hinted Iterable[int] accepts any class with __iter__, whether or not it inherits.

Generic protocols

from typing import Protocol, TypeVar

T = TypeVar("T")

class Comparable(Protocol):
    def __lt__(self, other: "Comparable") -> bool: ...

class Stack(Protocol[T]):
    def push(self, item: T) -> None: ...
    def pop(self) -> T: ...

def smallest(xs: list[Comparable]) -> Comparable:
    return min(xs)

A protocol may take type parameters like any generic (Module 15 covers TypeVar in full). Comparable is the classic one — "anything with __lt__" — and is what sorted's elements must be. (3.12's class Stack[T](Protocol) syntax is reading only on this track's runtime.)

Protocol versus ABC

ProtocolABC
Conformancestructural: having the membersnominal: inheriting (or register)
Enforcedby the type checker; runtime_checkable for isinstanceat instantiation (TypeError for missing methods)
Shared implementationnone — a protocol should be pure interfaceyes: concrete and mixin methods
Third-party classesconform automaticallymust inherit or be registered
Use forhints on parameters that accept "anything with these methods"base classes you control that share code or must fail fast

A common combination: an ABC for your own hierarchy's shared behaviour, and a Protocol in the signature of the function that only needs one method — so the function also accepts objects from outside the hierarchy.

Pitfalls

  • isinstance(x, SomeProtocol) without @runtime_checkable — TypeError.
  • Expecting a runtime check to verify signatures; it checks names only.
  • Putting implementation in a protocol; keep it an interface.
  • Hinting a parameter with a concrete class when a protocol or ABC would accept more.
  • Mismatched method signatures silently failing the structural match in the checker.
  • Writing a protocol for something collections.abc already names.

Key takeaways

  • A Protocol lists members; any object with them conforms — structural typing, verified by the type checker.
  • @runtime_checkable allows isinstance, checking member names only.
  • Protocols may declare attributes and properties, and may be generic.
  • Prefer the standard Iterable, Sequence, Callable, Supports… protocols to writing your own.
  • Protocol for hints and third-party conformance; ABC for shared implementation and construction-time enforcement.

Common questions

What is the difference between Protocol and ABC in Python?

A Protocol is structural: any class with the right members conforms, the type checker verifies it, and it should hold no implementation. An ABC is nominal: classes must inherit from it or be registered, it can share concrete methods, and it refuses to instantiate an incomplete subclass.

What does @runtime_checkable do?

It lets a Protocol be used with isinstance, which otherwise raises TypeError. The check only looks for the member names, not their signatures, so an object with a non-callable attribute of the right name still passes.

What is structural typing in Python?

Structural typing decides compatibility by an object's shape — the methods and attributes it has — rather than by the classes it inherits from, which is nominal typing. typing.Protocol brings structural typing to Python's type hints, matching how duck-typed code already behaves.

Can a Python Protocol declare attributes?

Yes. name: str in a protocol matches any object with a name of a compatible type, whether an instance attribute, a class attribute or a property. A read-only @property in a protocol is satisfied by a plain attribute too.

Should I write my own Protocol or use a standard one?

Use the standard ones first: Iterable, Sequence, Mapping, Callable, Hashable and the Supports… protocols such as SupportsInt already describe the common shapes. Write your own only for operations of your own, like a Speaker with speak().

Exercises

Closable

Define a @runtime_checkable protocol Closable with close(self) -> None. Four unrelated classes are given: File and Socket have close(), Buffer has flush() only, Const has a non-callable attribute named close. Read class names; for each instance print whether isinstance(obj, Closable) holds and, if it does, try to call close() — printing closed <Class> on success or not callable if the call raises TypeError.

Input: one line of class names. Output: one line per name: <Class> closable=<bool> followed, when closable, by a space and closed <Class> or not callable.

File Buffer Const

prints

File closable=True closed File
Buffer closable=False
Const closable=True not callable

Anything with a name

Define a @runtime_checkable protocol Named with a data member name: str. Objects of four unrelated shapes are built from the input — a dataclass User, a namedtuple Pet, a plain class Thing and a dict — and a function greet(obj) prints hello <name> when isinstance(obj, Named) and nameless otherwise. (A dict does not conform: a key is not an attribute.)

Input: lines user <name>, pet <name>, thing <name>, dict <name>. Output: one line per input line.

user ada
dict bob
pet rex

prints

hello ada
nameless
hello rex

In this module: Inheritance, protocols and duck typing

← Multiple inheritance and the MRO — mixins and cooperative super() · Composition over inheritance — Liskov, delegation and wrapping built-ins →