@runtime_checkable Protocol Pitfalls
@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.
Context: PEP 544 and the runtime decorator
Protocols come from PEP 544. By default a Protocol is static only — isinstance 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.
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:
isinstancepasses ifdrawis callable at all; adraw(self, x)that needs an extra argument still returnsTrue, then raisesTypeErrorwhen called. - Inherited attributes: Presence via
hasattrincludes inherited members, so a subclass that overridesdrawwith 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 bareContainer.
Common mistakes
- Trusting
isinstancefor validation: ATrueresult means “names present”, not “structurally valid”. For real validation, check types explicitly or use a TypedDict with an actual validator. - Calling
issubclasson a data-member protocol: RaisesTypeError. Useisinstanceon an instance instead. - Forgetting
@runtime_checkableentirely: Without it,isinstance(x, MyProtocol)raisesTypeError: 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.