← Python Code Modern OOP
Browse Python Concepts

Protocol vs ABC — Structural vs Nominal Typing

Mental Model

Picture a regular Protocol as a purely theoretical blueprint for type checkers to compare against at design time, like an idea in your head. Adding @runtime_checkable is like physically building a transparent model of that blueprint, allowing you to actually inspect objects against it at runtime.

Rule: Always decorate your Protocols with @runtime_checkable if you need to perform dynamic isinstance() or issubclass() checks on them at runtime.

The Setup

You are writing a plugin system. You want to support dynamic third-party loggers that provide a .log() method, using a Protocol. At runtime, you need to assert that a configured plugin conforms to this interface before adding it to your system registry.

What Does This Print?

Broken code
Python
from typing import Protocol

class Logger(Protocol):
    def log(self, message: str) -> None: ...

class CustomPlugin:
    def log(self, message: str) -> None:
        print(f"[Plugin]: {message}")

plugin = CustomPlugin()
# Verify that our plugin matches the Protocol definition
print(isinstance(plugin, Logger))
Predict what happens when you run this script. Does it print True, False, or raise an exception?

The Output

What actually happens
TypeError: Instance and class checks can only be used with @runtime_checkable protocols

By default, typing.Protocol is purely a static analyzer tool used during linting/compilation. Unlike standard classes or Abstract Base Classes (ABCs), standard Protocols do not store runtime interfaces for isinstance() checks. Trying to evaluate isinstance() against a Protocol raises a runtime TypeError.

Why Python Does This

Under the hood, Protocol behaves as a structural type check system (duck typing). ABCs, on the other hand, enforce nominal typing—the class must explicitly subclass the ABC to pass isinstance(). Performing a structural check at runtime requires Python to parse class attributes and methods on the fly, which is computationally expensive. Python protects runtime performance by disallowing isinstance on Protocols unless they are decorated with @typing.runtime_checkable, which explicitly implements a dynamic attribute checking routine inside the protocol's metaclass __instancecheck__ method.

The Fix

Corrected pattern
Python
from typing import Protocol, runtime_checkable

# Adding runtime_checkable compiles helper methods allowing isinstance analysis
@runtime_checkable
class Logger(Protocol):
    def log(self, message: str) -> None: ...

class CustomPlugin:
    def log(self, message: str) -> None:
        print(f"[Plugin]: {message}")

plugin = CustomPlugin()
# This now correctly returns True because the structural match succeeds
print(isinstance(plugin, Logger))

Decorating a Protocol with @runtime_checkable explicitly tells the Python interpreter to generate the necessary runtime machinery. This allows isinstance() and issubclass() to perform structural checks by inspecting the object's methods and attributes against the protocol's definition.

How This Fails in Real Systems

An API Gateway microservice relied on third-party adapters for storage. A developer added a StorageProtocol to support multiple backends but used standard isinstance checks at the initialization boundary. On production deployment, the container cluster entered a crash loop immediately, resulting in 15 minutes of downtime due to unhandled TypeError exceptions during startup configuration parsing.

Key Takeaway

Always decorate your Protocols with @runtime_checkable if you need to perform dynamic isinstance() or issubclass() checks on them at runtime.
Common mistake: Developers assume Python Protocols behave like ABCs or regular classes at runtime, expecting isinstance() to work out of the box for structural type checking.