Why isinstance works on data protocols without @runtime_checkable

Summary

A developer encountered an inconsistent behavior when using typing.Protocol for structural subtyping. Initially, attempting to use isinstance() on a protocol containing only methods failed with a TypeError unless the @runtime_checkable decorator was applied. However, when an attribute (a data member) was added to the protocol, the isinstance() check worked without the decorator, albeit with a logical flaw in how the check actually behaved. This postmortem explores the distinction between behavioral protocols and data protocols in the Python type system.

Root Cause

The discrepancy arises from how the Python interpreter and the typing module implement structural checks at runtime:

  • Method-only Protocols: When a Protocol contains only methods, it is strictly a structural interface. By default, isinstance() calls are disabled for these because checking if an object “has a method” via reflection is computationally expensive and can lead to unpredictable results if methods are dynamically generated. The @runtime_checkable decorator explicitly enables this attribute-presence check.
  • Data Protocols: Once a member like x: int is added, the protocol is classified as a Data Protocol.
  • The Implementation Gap: When isinstance(obj, A) is called on a Data Protocol without @runtime_checkable, the runtime check does not actually validate the type or even the existence of the attribute in a robust way. Instead, it falls back to a different internal logic path where the presence of a type hint for an attribute triggers a different branch in the CPython implementation of isinstance.

Why This Happens in Real Systems

In large-scale production systems, this happens due to the divergence between static analysis and runtime enforcement:

  • Static vs. Dynamic: Tools like mypy or pyright see the x: int and assume structural compatibility. However, the Python runtime (CPython) handles isinstance through a specific dispatch mechanism.
  • Attribute Resolution: Checking for the existence of a method requires a hasattr call, whereas checking for a data member often relies on the object’s __dict__ or __slots__.
  • Optimization Shortcuts: The Python core developers optimized isinstance for classes and standard protocols. The “magic” that allows data protocols to pass without the decorator is effectively an unintended side effect of how the interpreter handles protocols containing non-method members during the isinstance dispatch.

Real-World Impact

  • False Positives: The most dangerous impact is that isinstance(B(), A) might return True even if B does not actually possess the attribute x. The check becomes shallow and unreliable.
  • Silent Failures: A developer might assume their code is type-safe because the assert passed, only to have a AttributeError occur minutes later in production when the code actually tries to access obj.x.
  • Brittle Refactoring: Changing a method to a property (or vice-versa) can silently change the runtime behavior of the entire validation logic, leading to non-deterministic production crashes.

Example or Code

from typing import Protocol, runtime_checkable

class MethodProtocol(Protocol):
    def f(self) -> None: ...

class DataProtocol(Protocol):
    x: int
    def f(self) -> None: ...

class B:
    def f(self) -> None:
        pass

# 1. This fails with TypeError (Correct behavior)
try:
    isinstance(B(), MethodProtocol)
except TypeError as e:
    print(f"Caught expected error: {e}")

# 2. This passes WITHOUT @runtime_checkable (Dangerous/Unexpected behavior)
# Even though B() does NOT have attribute 'x'
if isinstance(B(), DataProtocol):
    print("DataProtocol check passed unexpectedly!")

# 3. The correct, safe way
@runtime_checkable
class SafeProtocol(Protocol):
    def f(self) -> None: ...

assert isinstance(B(), SafeProtocol)

How Senior Engineers Fix It

Senior engineers do not rely on “magic” behavior or accidental side effects. To fix this, we follow these principles:

  • Explicit over Implicit: Always use the @runtime_checkable decorator if isinstance or issubclass is required. Never rely on the presence of a data member to “unlock” runtime checking.
  • Strict Interface Definition: If the goal is to check for behavior (methods), keep the protocol as a behavioral protocol and decorate it explicitly.
  • Defensive Programming: If runtime type checking is critical for a specific high-risk component, implement an explicit validate() method or use a library like pydantic for rigorous runtime validation rather than relying on typing.Protocol‘s isinstance.
  • Documentation of Intent: Use comments to explain why a protocol is being used for runtime checks, warning future maintainers not to remove the decorator.

Why Juniors Miss It

  • Trusting the “Green” Test: A junior engineer sees the assert pass and assumes the logic is sound. They mistake a shallow runtime check for a deep structural validation.
  • Lack of Runtime Awareness: Juniors often view typing as a purely static tool for IDE autocompletion. They may not realize that isinstance triggers actual execution logic in the CPython interpreter.
  • Surface-Level Debugging: When the error occurs, a junior might try to “fix” it by adding an attribute to the protocol to make the error go away, rather than investigating why the underlying mechanism changed.

Leave a Comment