Back to Blog
Python

Python Protocol: Structural Typing in Practice

python protocol: How typing.Protocol enables structural subtyping in Python: declaring protocols, runtime_checkable limits, and when to choose a Protocol over an ABC.

typing.Protocolstructural subtypingPEP 544type hintsduck typing
Editorial illustration showing a Python Protocol as an interface connector that accepts structurally compatible objects of different shapes.

The term python protocol most often refers to typing.Protocol, the structural subtyping mechanism introduced in PEP 544. It lets a type checker accept an object because it has the required attributes and methods, rather than because it inherits from a specific class. This matches the duck typing Python developers already rely on at runtime, but gives static analysis the information it needs to catch mismatches before execution.

What typing.Protocol Defines

A Protocol is declared by subclassing typing.Protocol and listing the members that matter:

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

Any class with a draw method that takes no arguments and returns None is structurally compatible with Drawable, even if it never imports or inherits from it. The type checker performs this compatibility check statically; the Protocol has no runtime effect by default.

Declaring Protocols With Methods and Attributes

Protocols can include both methods and attributes. Attributes are declared as class-level annotations without a value:

class Point(Protocol): x: float y: float

A class with instance attributes x and y of type float satisfies this Protocol. The type checker verifies the attribute names and types, but nothing at runtime enforces this unless you use @runtime_checkable.

Methods in a Protocol body are typically written with ... as the body. That ellipsis is not a no-op; it signals to the type checker that the method has no implementation. If you provide a real implementation inside the Protocol, it becomes a default implementation that subclasses can inherit, which is useful when several classes share the same method logic.

Structural vs Nominal Subtyping

The distinction between a Protocol and a normal base class is the difference between structural and nominal subtyping. With nominal subtyping, a class is compatible only if it explicitly inherits from the base. With structural subtyping, compatibility is determined by the shape of the object.

class Canvas: def draw(self) -> None: print("drawing") def render(shape: Drawable) -> None: shape.draw() render(Canvas()) # accepted by the type checker

Canvas never mentions Drawable, yet the type checker accepts it because its draw method matches. This is the same flexibility duck typing gives you at runtime, now visible to static analysis.

Using @runtime_checkable for isinstance Checks

By default, a Protocol has no runtime presence. Calling isinstance(obj, SomeProtocol) raises TypeError unless the Protocol is decorated with @runtime_checkable:

from typing import Protocol, runtime_checkable @runtime_checkable class Closeable(Protocol): def close(self) -> None: ...

With the decorator, isinstance performs a structural check against the Protocol's methods. There is an important limitation: isinstance only verifies that the method names exist on the target class or its ancestors. It does not verify signatures, and it does not check attribute-only members.

The Attribute-Only Protocol Pitfall

Consider this Protocol:

@runtime_checkable class Named(Protocol): name: str

isinstance(obj, Named) will return True for almost any object, because the runtime check only looks at methods defined in the Protocol body. The attribute name is not part of the runtime check. This produces false positives that static analysis would catch. If you need runtime validation of attributes, a Protocol is the wrong tool; write an explicit check or validate in a dataclass instead.

Performance and Overhead Considerations

Protocols themselves add no runtime cost when used only for type checking. The type checker evaluates structural compatibility at analysis time, and the Protocol class itself performs no runtime validation. The only runtime cost appears with @runtime_checkable, where isinstance walks the target's method resolution order and __dict__ to confirm each method name from the Protocol body exists. That scan is more work than a nominal isinstance check against a concrete class, so reserve runtime_checkable for code paths where the check is not executed frequently.

When to Prefer a Protocol Over an ABC

An abstract base class forces a class hierarchy. If the classes you want to accept already have a different base class, an ABC forces you to change inheritance or use multiple inheritance. A Protocol requires no inheritance relationship, which makes it suitable for libraries that want to accept third-party objects that happen to have the right shape.

Use an ABC when you control the class hierarchy and want to provide shared implementation or enforce abstractmethod behavior at instantiation time. Use a Protocol when you want to define an interface that external code can satisfy implicitly.

Generic Protocols

Protocols can be generic, which is useful for container-like interfaces:

from typing import Protocol, TypeVar T = TypeVar("T") class Repository(Protocol[T]): def get(self, key: str) -> T: ... def put(self, key: str, value: T) -> None: ...

A class implementing get and put with matching types satisfies Repository[str] or Repository[int] depending on the actual method signatures. The type checker infers the type argument from the implementation, so you get per-instance type safety without writing separate classes.

Where Protocols Break Down

Protocols work well for static analysis and lightweight runtime checks, but they do not change runtime behavior. A Protocol does not prevent you from passing an object that satisfies the shape at type-check time but fails at runtime due to a different signature. The type checker trusts your annotations; if you annotate a method as returning int and the implementation returns str, mypy will flag it, but the runtime will not.

Protocols also cannot enforce __init__ signatures, since structural compatibility is about the object's interface, not its construction. If you need to enforce constructor behavior, a Protocol is not the right tool; use a factory function or a concrete base class with validation instead.

python protocol: Practical Usage and Code Examples | RYUSLOG DEV