@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 (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.

Protocol runtime-check state machine A plain Protocol is static-only and raises TypeError on isinstance; adding the decorator moves it to a state where isinstance and issubclass are permitted. class P(Protocol) static-only isinstance → TypeError @runtime_checkable isinstance allowed presence-only check apply decorator
The decorator only unlocks the runtime gate; it never upgrades the check to a structural one.
# 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.

Signature-blind runtime check flow An object with a string draw attribute passes isinstance because the name exists, then fails with a TypeError at the call site. Broken() draw = "not a method" hasattr(obj, "draw") name present ✓ isinstance → True check passes obj.draw() TypeError ✗
Presence passes; the mismatch only surfaces when the "method" is finally called.
# 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.

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. 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.

Data-member type ignored at runtime A protocol declares name as str, the instance sets name to an int, and the runtime check only confirms the name exists. Named (Protocol) name: str declared type Widget() name = 123 int, not str hasattr("name") → True
The name is present, so the check passes; the declared str is never enforced.
# 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.

When issubclass is allowed on a protocol issubclass against a runtime-checkable protocol is allowed only if every member is a method; a data member makes it raise TypeError, while isinstance on an instance still works. issubclass(cls, RuntimeCheckableProtocol) members? method-only vs data all methods has data member issubclass allowed names checked on the class TypeError non-method members use isinstance on an instance
A single data member disqualifies a protocol from issubclass; fall back to isinstance on an instance.
# 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.

Cost of a protocol isinstance versus a nominal one A nominal isinstance is one C-level check, while a runtime-checkable protocol isinstance runs a hasattr probe per member, so its cost grows with the number of members. Relative cost of the isinstance check nominal one MRO check fast, C-level protocol hasattr hasattr hasattr … per member cost grows with the number of protocol members
A nominal check is a single MRO lookup; a protocol check probes every member in turn.

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.

Runtime-checkable protocol edge cases Wrong arity passes then fails on call, inherited attributes count as present, and a subscripted generic protocol raises TypeError. Edge case Runtime result callable with wrong arity isinstance True, TypeError when called inherited attribute counts as present via hasattr isinstance(x, Container[int]) TypeError — use bare Container
Three edge cases where the presence-only check answers differently than a full structural test would.
  • 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. The runtime check never inspects __code__.co_argcount or the signature, so arity mismatches are invisible until invocation.
  • Inherited attributes: Presence via hasattr includes inherited members, so a subclass that overrides draw with 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 raises TypeError: Subscripted generics cannot be used with class and instance checks. Only the bare isinstance(x, Container) is permitted, and it checks the unparametrised structure; the int argument is a static-only constraint.
  • Property-backed members: a @property on the protocol is a data member for runtime purposes, so hasattr triggers the property’s getter — a getter with side effects or one that raises will make the isinstance check itself behave unexpectedly.
  • Dynamically added attributes: because the probe runs against the live object, an attribute set by setattr after 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, since hasattr can 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.

Guard rules for runtime-checkable protocols Three stacked rules: a True result means names present not structurally valid, use isinstance on an instance for data protocols, and without the decorator isinstance raises TypeError. Three rules that prevent runtime-check bugs True = names present, not structurally valid isinstance on an instance for data protocols, never issubclass no @runtime_checkable → isinstance raises TypeError
Three rules that cover every pitfall on this page.
  • 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. 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 issubclass on a data-member protocol: Raises TypeError. Use isinstance on an instance instead, and remember that even a passing issubclass on a method-only protocol proves only name presence, not signature compatibility.
  • Forgetting @runtime_checkable entirely: Without it, isinstance(x, MyProtocol) raises TypeError: 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 hasattr cost 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.

Back to Protocol & Structural Subtyping