Protocols

Protocols let you describe an object's shape — the methods and attributes it must have — without forcing it into an inheritance hierarchy, giving duck typing a static contract.

A Protocol (from the typing module, standardized in PEP 544) defines an interface by structure rather than by explicit subclassing: any object that happens to have the right methods and attributes satisfies the protocol, whether or not it ever heard of the protocol class. This is Python's answer to "structural typing" — matching the language's long-standing duck-typing philosophy ("if it walks like a duck and quacks like a duck...") but giving static type checkers something concrete to verify.

What Is a Protocol

Ordinary type hints like list[int] or a custom class describe nominal types: an object matches only if it IS that class (or a subclass). A Protocol describes a structural type: an object matches if it HAS the required methods/attributes with compatible signatures, regardless of its actual class or inheritance.

from typing import Protocol

class SupportsClose(Protocol):
    def close(self) -> None: ...

class FileHandle:
    def close(self) -> None:
        print("closing file")

class DatabaseConnection:
    def close(self) -> None:
        print("closing connection")

def shut_down(resource: SupportsClose) -> None:
    resource.close()

shut_down(FileHandle())          # OK — has close()
shut_down(DatabaseConnection())  # OK — has close(), no shared base class

Why Protocols Matter

Without protocols, writing a function that accepts "anything closeable" forces a choice: either accept object (and lose all static checking), or define an abstract base class (ABC) that every caller must explicitly inherit from — which is intrusive for types you don't control, like third-party classes or builtins.

Protocols solve this: you declare the shape you need, and a static type checker (mypy, pyright) verifies that any argument passed in actually has that shape — with zero cooperation required from the argument's class. This matches how duck typing already works at runtime; protocols just make it checkable before runtime.

Many standard-library and third-party type stubs are protocols under the hood — Iterable, Iterator, Sized, Callable, Hashable in typing/collections.abc behave structurally for type-checking purposes.

Defining a Protocol

A protocol is a class that inherits from Protocol and declares method signatures (and/or attributes) with ... as the body — it is never meant to be instantiated directly.

Protocols can also declare attributes (not just methods), using ordinary annotations:

from typing import Protocol

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

def smallest(items: list[Comparable]) -> Comparable:
    result = items[0]
    for item in items[1:]:
        if item < result:
            result = item
    return result

print(smallest([5, 2, 8, 1]))       # 1 — int defines __lt__
print(smallest(["pear", "apple"]))  # "apple" — str defines __lt__
from typing import Protocol

class HasName(Protocol):
    name: str

def greet(entity: HasName) -> str:
    return f"Hello, {entity.name}!"

Structural Typing vs Nominal Typing

Nominal typing (the default for classes) checks the declared type hierarchy: isinstance(x, Base) succeeds only if x's class is Base or a subclass. Structural typing checks shape: does the object provide the required members? Protocols let you opt into structural checking exactly where you want it, while everything else in Python's type system stays nominal.

Nominal (class-based):        Structural (Protocol-based):
  class Animal: ...              class SupportsClose(Protocol):
  class Dog(Animal): ...             def close(self) -> None: ...
  isinstance(d, Animal)          any object with .close() matches
  -> True only via inheritance   -> no inheritance needed

Runtime-Checkable Protocols

By default, protocols are a purely static-typing construct — isinstance() against a plain Protocol raises TypeError. Decorating the protocol with @runtime_checkable enables isinstance()/issubclass() checks, but with an important limitation: the runtime check only verifies that the named methods/attributes EXIST, not that their signatures or types match.

@runtime_checkable checks presence of members only — it does not verify parameter types, return types, or argument counts. A class with def close(self, force: bool) -> str would still pass isinstance, even though it does not truly match the static signature.

from typing import Protocol, runtime_checkable

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

class FileHandle:
    def close(self) -> None: ...

print(isinstance(FileHandle(), SupportsClose))  # True
print(isinstance(42, SupportsClose))            # False — int has no close()

Explicit Subclassing (Optional)

A class MAY explicitly subclass a Protocol for documentation purposes, but it is never required — this is the key difference from ABCs.

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...

class Circle(Drawable):  # explicit subclass — optional, purely documentary
    def draw(self) -> None:
        print("drawing circle")

class Square:  # no subclassing at all — still satisfies Drawable structurally
    def draw(self) -> None:
        print("drawing square")

Protocols vs Abstract Base Classes

ABCs (via abc.ABC/abc.abstractmethod) enforce their contract at instantiation time through inheritance: you must subclass and implement every abstract method, or Python refuses to construct the object. Protocols enforce their contract at static-analysis time through structure: any matching shape is accepted, with no inheritance and (without @runtime_checkable) no runtime enforcement at all.

ABC                         Protocol
Matching rule    nominal (inherit + impl)    structural (has the shape)
Enforced by      runtime, at instantiation   static type checker (mostly)
Third-party fit  must modify the class       works with unmodified classes
Typical use      shared implementation base  interface-only contracts

Generic Protocols and Callable Protocols

Protocols can be generic (parameterized by type variables) and can describe callables via __call__, which is more expressive than Callable[[int], str] when the callable also needs specific parameter names or overloads.

from typing import Protocol

class Adder(Protocol):
    def __call__(self, x: int, y: int) -> int: ...

def apply(fn: Adder, a: int, b: int) -> int:
    return fn(a, b)

def add(x: int, y: int) -> int:
    return x + y

print(apply(add, 3, 4))  # 7 — a plain function satisfies the callable protocol

Practical Usage

Protocols shine when writing library code that should accept "anything with the right shape" — logging sinks, comparison keys, serializers, cache backends — without imposing an inheritance requirement on callers. They are also how you type-hint duck-typed code that already works fine at runtime but currently has no static contract.

from typing import Protocol

class SupportsWrite(Protocol):
    def write(self, data: str) -> int: ...

def log(sink: SupportsWrite, message: str) -> None:
    sink.write(message + "\n")

import sys
log(sys.stdout, "started")   # sys.stdout satisfies SupportsWrite structurally

Edge Cases and Rules

A class satisfies a protocol if it defines every required member with a compatible signature — extra members are fine and ignored. Protocols can extend other protocols (multiple inheritance among protocols). A protocol with only data attributes (no methods) is a valid but unusual protocol — most real protocols describe behavior.

Protocols are checked structurally only by static tools (mypy, pyright); CPython itself performs no protocol checking at function-call time unless you explicitly call isinstance() against a @runtime_checkable protocol.

Quick Reference

from typing import Protocol, runtime_checkable

class MyProtocol(Protocol):
    def method(self, arg: int) -> str: ...
    attr: bool

@runtime_checkable
class CheckableProtocol(Protocol):
    def method(self) -> None: ...

# isinstance(obj, CheckableProtocol)  -> checks member presence only
# static type checkers verify full signature compatibility

Key Takeaways

A Protocol defines a structural interface: any object with the matching shape satisfies it, no inheritance required.

This formalizes duck typing so static type checkers (mypy, pyright) can verify it before runtime.

isinstance()/issubclass() against a protocol only work when it is decorated @runtime_checkable, and even then only member presence is checked, not full signature compatibility.

Explicit subclassing of a Protocol is optional and purely documentary — structural matching works without it.

Prefer protocols over ABCs when you cannot or should not require callers to inherit from a specific base class, such as when accepting third-party or builtin types.

class SupportsClose(Protocol): def close(self) -> None: ... isinstance(obj, SupportsClose) # TypeError

@runtime_checkable class SupportsClose(Protocol): def close(self) -> None: ... isinstance(obj, SupportsClose) # OK

Plain protocols cannot be used with isinstance()/issubclass(); they must be decorated @runtime_checkable first.

@runtime_checkable class Adder(Protocol): def add(self, x: int, y: int) -> int: ... class Bad: def add(self, x: str) -> str: ... isinstance(Bad(), Adder) # True! signature mismatch not caught

@runtime_checkable isinstance checks verify member presence, not parameter or return types — a mismatched signature still passes at runtime.

class Drawable(Protocol): def draw(self) -> None:... d = Drawable() # TypeError: Protocols cannot be instantiated class Circle: def draw(self) -> None: print("circle") d: Drawable = Circle() # instantiate the concrete implementer, not the protocol

Protocols describe a shape; they are never meant to be instantiated directly. Instantiate a concrete class that satisfies the shape.

What is the core difference between structural typing (Protocol) and nominal typing (regular class inheritance)?

Nominal typing matches by declared class identity/hierarchy (isinstance checks the actual inheritance chain); structural typing matches by shape — an object satisfies a Protocol if it has the required methods/attributes, regardless of its class or inheritance.

Why can't you call isinstance() against a plain Protocol class?

Protocols are a static-typing-only construct by default; isinstance() needs runtime support for the check, which is only enabled by decorating the protocol with @runtime_checkable.

What exactly does @runtime_checkable verify, and what does it NOT verify?

It verifies that the named methods/attributes are present on the object. It does not verify parameter types, return types, or that the signature actually matches the protocol's declared signature.

Is it required to explicitly subclass a Protocol to satisfy it?

No. Explicit subclassing is optional and purely documentary — any class with the matching structure satisfies the protocol whether or not it inherits from it.

When would you prefer a Protocol over an ABC (abstract base class)?

When you need to accept objects whose class you don't control or shouldn't force to inherit from a specific base — e.g., third-party classes or builtins — since Protocols check shape rather than requiring explicit inheritance.

Can a Protocol describe a callable object, and how?

Yes, by declaring a __call__ method signature in the protocol; any function or callable object matching that signature satisfies it.

What happens if you try to instantiate a Protocol class directly?

It raises a TypeError — Protocols are interface descriptions, not concrete implementations, and are never meant to be instantiated.

Write a Protocol named Sized requiring a __len__(self) -> int method, then write a function describe(obj) that returns a string like 'length: 3' for anything satisfying it.

from typing import Protocol class Sized(Protocol): def __len__(self) -> int: ... def describe(obj: Sized) -> str: return f"length: {len(obj)}" print(describe([1, 2, 3])) # length: 3 print(describe("hello")) # length: 5

Both a list and a str satisfy Sized structurally because they both implement __len__, with no shared base class or explicit subclassing needed.

What does this print?

True then False — Bird defines fly() so it structurally matches the runtime-checkable Flyer protocol; Rock has no fly() method so it does not match.

Write a generic-callable Protocol named Transformer with __call__(self, value: int) -> int, and a function apply_twice(fn, x) that applies fn to x twice using that protocol as the type hint.

from typing import Protocol class Transformer(Protocol): def __call__(self, value: int) -> int:... def apply_twice(fn: Transformer, x: int) -> int: return fn(fn(x)) def double(value: int) -> int: return value * 2 print(apply_twice(double, 3)) # 12 apply_twice(double, 3) computes double(double(3)) = double(6) = 12; any plain function matching the (int) -> int shape satisfies the Transformer protocol.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Flyer(Protocol):
    def fly(self) -> str: ...

class Bird:
    def fly(self) -> str:
        return "flying"

class Rock:
    pass

print(isinstance(Bird(), Flyer))
print(isinstance(Rock(), Flyer))