@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 (2017) and first shipped in the standard library’s typing module in
Python 3.8. On earlier interpreters you get the identical feature from the backport:
from typing_extensions import Protocol, runtime_checkable. By default a Protocol subclass is
static only — it participates in structural subtyping for mypy and pyright, but
isinstance(obj, MyProtocol) raises TypeError: Instance and class checks can only be used with @runtime_checkable protocols. Adding @runtime_checkable (from typing) sets an internal flag
(_is_runtime_protocol) and lets the protocol’s metaclass, _ProtocolMeta, answer isinstance and
issubclass. The decorator changes nothing about how the static checker treats the class; mypy and
pyright already understood the structure and continue to verify it in full.
The deliberate design decision in PEP 544 is that the runtime __instancecheck__ performs a
presence check, not a structural one. It walks the protocol’s members — the set the interpreter
records on the class as __protocol_attrs__ — and for each one asks the equivalent of
hasattr(obj, name). It never inspects parameter types, return types, method signatures, or the
declared types of data attributes. So “is a Drawable” means two different things depending on who
is asking: to the static checker it means “has a draw(self) -> str method”; to isinstance it
means only “has something named draw”. Every pitfall on this page is a consequence of that single
gap. See the parent Protocol and structural subtyping
guide for how the static side works.
# 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. Internally
_ProtocolMeta.__instancecheck__ loops over Drawable.__protocol_attrs__ (here just {"draw"}) and
succeeds the moment getattr(obj, "draw", <missing>) finds something. It does not call the method,
does not read its signature, and does not compare its return annotation against str. Presence is the
entire test.
# 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 annotated as str, not a
method with the right signature — pyright reports "Broken" is incompatible with protocol "Drawable"
and mypy emits a similar Argument ... has incompatible type note. The runtime check sees only the
name. CPython 3.12 narrowed this one crack slightly: the interpreter now precomputes which members are
callable, and if a method member is explicitly assigned None on the instance, isinstance returns
False (in 3.11 and earlier it returned True). That is the extent of the extra scrutiny — a draw
bound to a str, a lambda with the wrong arity, or any other callable still passes on every
version. Do not read the 3.12 change as “signatures are now checked”; they are not.
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. The declared type str is
recorded only for the static checker; at runtime the protocol treats name as a member to probe with
hasattr and nothing more. The annotation is metadata for mypy and pyright, discarded by the
isinstance path.
# 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
There is a second, subtler trap with data-member protocols: presence is checked on the concrete
object, so an attribute assigned only inside __init__ exists only after construction. A class that
declares name as a bare class-body annotation (name: str with no value) has no name in its
namespace until an instance sets it, so isinstance(SomeInstance(), Named) can be True while
hasattr(SomeClass, "name") is False. This is one reason issubclass is disallowed for such
protocols (see Pitfall 3). In CPython 3.12 the interpreter also precomputes the non-callable members
into __non_callable_proto_members__; if you rely on this at runtime, remember it still records only
which names are data members, never their types. For genuine type enforcement, validate explicitly
or reach for a TypedDict with a validator or a
data-validation library.
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()
The restriction is deliberate and follows directly from Pitfall 2. issubclass receives a class, not an instance, and a data attribute such as name typically does not exist on the class object — it is assigned to self inside __init__ and only materializes once an instance is constructed. If CPython allowed issubclass(Widget, Named), it would have to answer “does Widget have name?” by inspecting the class, where name is absent, and would report False for a class whose instances plainly satisfy the protocol. Rather than return a misleading answer, the interpreter refuses the operation outright with TypeError: Protocols with non-method members don't support issubclass(). Methods do not have this problem: a method is defined in the class body and lives on the class, so getattr(cls, "draw") succeeds and a method-only protocol can be issubclass-checked. Even then the check remains presence-only — issubclass(Button, Drawable) confirms the name draw exists on Button, never that its signature matches. Use isinstance on an instance for data-member protocols, never issubclass on the class, and treat a positive issubclass on a method-only protocol as “declares these method names”, not “implements them correctly”.
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.
The mechanism explains the gap. isinstance(x, SomeClass) is a C-level test that walks x’s method-resolution order once looking for SomeClass — effectively a pointer comparison per base. isinstance(x, RuntimeCheckableProtocol) instead iterates __protocol_attrs__ and performs a getattr/hasattr for each member, in Python-level logic, so a protocol with eight members runs eight attribute lookups every call. CPython 3.12 shaved some overhead by caching the member set and precomputing which members are callable, but the check is still fundamentally linear in the member count and still far slower than a nominal isinstance.
# Python 3.11+, illustrative timing
from timeit import timeit
# protocol check — repeated hasattr probes
t_proto = timeit("isinstance(b, Drawable)", globals=globals(), number=1_000_000)
# nominal check — single MRO walk
t_nom = timeit("isinstance(b, Button)", globals=globals(), number=1_000_000)
# t_proto is typically several times t_nom
The fix is structural, not a micro-optimization: hoist the check out of the hot path. Validate an object once at the boundary where it enters your system, then pass the narrowed, concretely-typed value inward so inner loops never re-check. Where you control the classes, a nominal base class or an explicit registry gives you a fast isinstance and a stronger guarantee at the same time; reserve the runtime-checkable protocol for the one place where you genuinely accept unknown third-party objects.
Edge cases
Beyond the four numbered pitfalls, a handful of smaller surprises follow from the same presence-only rule. Each is a case where a True result does not mean what it looks like.
- Callable but wrong arity:
isinstancepasses ifdrawis callable at all; adraw(self, x)that needs an extra argument still returnsTrue, then raisesTypeErrorwhen called. The runtime check never inspects__code__.co_argcountor the signature, so arity mismatches are invisible until invocation. - Inherited attributes: Presence via
hasattrincludes inherited members, so a subclass that overridesdrawwith a non-callable still affects the result, and a mixin far up the MRO can make an unrelated class “match” a protocol by accident. Verify on the concrete instance you actually hold. - Generic protocols: Type arguments are erased at runtime, so
isinstance(x, Container[int])is not allowed for a parametrised protocol — it raisesTypeError: Subscripted generics cannot be used with class and instance checks. Only the bareisinstance(x, Container)is permitted, and it checks the unparametrised structure; theintargument is a static-only constraint. - Property-backed members: a
@propertyon the protocol is a data member for runtime purposes, sohasattrtriggers the property’s getter — a getter with side effects or one that raises will make theisinstancecheck itself behave unexpectedly. - Dynamically added attributes: because the probe runs against the live object, an attribute set by
setattrafter construction, injected by a mixin, or added through__getattr__all count as “present” — an object with a catch-all__getattr__that returns something for every name will match any data-member protocol, sincehasattrcan never fail on it.
Common mistakes
Every mistake here reduces to forgetting one fact: the runtime check tests names, and the static checker tests structure. Hold those three guard rules in mind and the pitfalls stop biting.
- Trusting
isinstancefor validation: ATrueresult means “names present”, not “structurally valid”. For real validation, check types explicitly or use a TypedDict with an actual validator. This is the single most consequential mistake, because the code often works on well-behaved inputs and only fails on the malformed object the check was supposed to catch. - Calling
issubclasson a data-member protocol: RaisesTypeError. Useisinstanceon an instance instead, and remember that even a passingissubclasson a method-only protocol proves only name presence, not signature compatibility. - Forgetting
@runtime_checkableentirely: Without it,isinstance(x, MyProtocol)raisesTypeError: Instance and class checks can only be used with @runtime_checkable protocols. The decorator is opt-in precisely so that the weaker runtime semantics are a deliberate choice, not an accident. - Re-checking in a hot loop: validate once at the boundary and pass a narrowed type inward, rather
than paying the per-member
hasattrcost on every iteration (see Pitfall 4).
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.