@runtime_checkable Protocol Pitfalls

TL;DR

@runtime_checkable lets you call isinstance(obj, MyProtocol), but the check is shallow: it verifies only that the named attributes exist, never their types or method signatures. Data-member protocols can be isinstance-checked for attribute presence too, but the result is even weaker, and the check is slower than a normal isinstance. Treat a True result as “has these names”, not “is structurally valid”.

A Protocol defines structural compatibility for the static checker. Decorating it with @runtime_checkable additionally permits isinstance() and issubclass() calls at runtime — but the runtime check is dramatically weaker than what mypy or pyright verify statically. This mismatch is the source of most bugs, so this guide focuses on exactly where the runtime check stops short. It is part of Advanced Typing Patterns & Generics.

Runtime checkable protocol gap Static analysis checks names and signatures; runtime isinstance on a runtime_checkable protocol checks only that the names exist. Static checker attribute names exist method signatures match attribute types match full structural check isinstance() at runtime attribute names exist signatures NOT checked types NOT checked presence only
The runtime check verifies only that the attribute names are present, nothing more.

Context: PEP 544 and the runtime decorator

Protocols come from PEP 544. By default a Protocol is static onlyisinstance against it raises TypeError. Adding @runtime_checkable (from typing) enables isinstance/issubclass, but the PEP deliberately defines the runtime semantics as a presence check via hasattr, not a structural validation. The static and runtime meanings of “is a Drawable” therefore differ.

# Python 3.8+, mypy 1.x / pyright 1.1.x
from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> str: ...

class Button:
    def draw(self) -> str:
        return "[button]"

Pitfall 1: signatures are not checked

isinstance(obj, Drawable) returns True if obj has a draw attribute — even if that draw takes the wrong arguments, returns the wrong type, or is not callable at all.

# Python 3.11+, runtime behaviour
class Broken:
    draw = "not a method"        # an attribute named draw, but a str

print(isinstance(Broken(), Drawable))   # True (!) — only presence is checked
Broken().draw()                         # TypeError: 'str' object is not callable

The static checker would reject Broken as a Drawable because draw is not a method with the right signature. The runtime check sees only the name.

Runtime vs static analysis mypy and pyright verify the *full* structure of a Protocol — every method's parameter types, return type, and every attribute's type. `isinstance(obj, RuntimeCheckableProtocol)` verifies only that each member *name* exists on the object (via `hasattr`). A class can pass the runtime check and still be rejected statically, or pass statically yet break at runtime if you trusted `isinstance` alone.

Pitfall 2: data-member protocols and runtime checks

A Protocol with non-method members (a name: str attribute) can be marked @runtime_checkable, and isinstance will check that the attribute exists on the instance. But class-level data attributes that only appear in __init__ may not be visible until an instance is constructed, and the check still ignores the declared type entirely.

# Python 3.11+, mypy 1.x / pyright 1.1.x
from typing import Protocol, runtime_checkable

@runtime_checkable
class Named(Protocol):
    name: str

class Widget:
    def __init__(self) -> None:
        self.name = 123          # wrong type — int, not str

print(isinstance(Widget(), Named))   # True — `name` exists; its type is ignored

Pitfall 3: issubclass rejects non-method members

issubclass against a @runtime_checkable Protocol that declares data members raises TypeError at runtime — only protocols whose members are all methods support issubclass.

# Python 3.11+, runtime behaviour
issubclass(Widget, Named)
# TypeError: Protocols with non-method members don't support issubclass()

Use isinstance on an instance for data-member protocols, never issubclass on the class.

Pitfall 4: performance

A @runtime_checkable isinstance is markedly slower than a nominal isinstance(x, SomeClass), because it walks every protocol member calling hasattr. In a hot loop this cost is real; cache the result or restructure to avoid per-iteration checks.

Edge cases

  • Callable but wrong arity: isinstance passes if draw is callable at all; a draw(self, x) that needs an extra argument still returns True, then raises TypeError when called.
  • Inherited attributes: Presence via hasattr includes inherited members, so a subclass that overrides draw with a non-callable still affects the result. Verify on the concrete instance.
  • Generic protocols: Type arguments are erased at runtime, so isinstance(x, Container[int]) is not allowed for a parametrised protocol — only the bare Container.

Common mistakes

  • Trusting isinstance for validation: A True result means “names present”, not “structurally valid”. For real validation, check types explicitly or use a TypedDict with an actual validator.
  • Calling issubclass on a data-member protocol: Raises TypeError. Use isinstance on an instance instead.
  • Forgetting @runtime_checkable entirely: Without it, isinstance(x, MyProtocol) raises TypeError: Instance and class checks can only be used with @runtime_checkable protocols.

FAQ

Does mypy warn that the runtime check is incomplete? No — the static checker validates the Protocol structurally and is satisfied. It does not know you rely on isinstance at runtime, so the presence-only gap is invisible until something breaks.

How do I actually validate a structure at runtime? Write explicit checks for each member’s type, or use a dedicated validation library. isinstance against a runtime-checkable Protocol is a name-presence gate, not a schema validator.

Back to Protocol & Structural Subtyping