skip to content

Why can isinstance() pass against a runtime_checkable Protocol whose method signature does not match?

level: middleimportance: must knowfreq 58%

answer

  1. The check stops at one thing
  2. Names resolve; nothing else is compared
  3. Wrong arity still returns True
  4. Failure surfaces later, at the call
  5. Signatures are the type checker's job

basics

~20 s

The runtime check is presence-only: it asks whether the object has an attribute for each member name the protocol declares, and stops there. Parameter lists, argument counts, return types and attribute types are never compared, so a wrongly-shaped object passes.

solid answer

~40 s

`@typing.runtime_checkable` buys a name check, not a conformance check. `isinstance(obj, P)` looks up each member name the protocol declares — since Python 3.12 via `inspect.getattr_static()` — and returns True if all of them resolve. It never inspects the signature, so an object whose `fetch` takes no arguments passes a protocol demanding `fetch(self, since: int) -> list[int]`, and the mismatch surfaces later as a `TypeError` at the call site. Worse, an object with a *matching* signature but different semantics is indistinguishable to the check. The fixes are all outside the runtime check: run the static type checker in CI, where structural conformance including signatures **is** verified; have implementations subclass the protocol explicitly so the checker validates them at the definition; or simply call the object and handle the failure.

code

python · 25 lines
python
from typing import Protocol, runtime_checkable


@runtime_checkable
class InventoryFeed(Protocol):
    def fetch(self, since: int) -> list[int]: ...


class WarehouseFeed:
    def fetch(self, since: int) -> list[int]:
        return [since + 1, since + 2]


class LegacyFeed:
    def fetch(self) -> list[int]:
        return [1, 2]


for feed in (WarehouseFeed(), LegacyFeed()):
    print(type(feed).__name__, isinstance(feed, InventoryFeed))

try:
    LegacyFeed().fetch(7)
except TypeError as exc:
    print("only now:", exc)

go deeper

for a junior

Recall the headline: a runtime protocol check only asks whether the names exist. If the object's method takes different arguments, the check still says yes and the error appears later when you call it.

for a middle

Explain the mechanics — the per-member static attribute lookup, why arity and return types are never compared, and that a plain data attribute satisfies a declared method. Then name the fix: static checking, not a stronger runtime check.

for a senior

Demonstrate the production judgment: a matching signature with mismatched semantics, such as an inclusive versus exclusive cursor boundary, produces a data defect no runtime check can see. Contract tests and explicit protocol subclassing are the real defences.

for a principal

Own where the guarantee lives. Decide as a policy whether structural conformance is enforced by the checker in CI plus contract tests at the seams, and keep runtime checks confined to genuinely untyped boundaries.

### What the check actually compares When a protocol is decorated with `@typing.runtime_checkable`, `isinstance(obj, P)` iterates the member *names* declared on the protocol and tries to resolve each one on the object. Since Python 3.12 that resolution uses `inspect.getattr_static()`, which reads the class dictionaries and the instance `__dict__` without invoking descriptors or `__getattr__`; before 3.12 it used `hasattr()`. Either way, the comparison ends at the name. There is no attempt to compare parameter names, argument counts, keyword-only markers, defaults, return annotations, or the runtime type of a data attribute. This is not an oversight. Comparing signatures at runtime would mean deciding whether one callable's signature is *substitutable* for another's — a variance question that the type checker answers with a full type system and that `isinstance()` has no machinery for. The design decision was to keep the runtime check cheap and honest about being shallow, and to leave conformance to static analysis. ### The failure this produces Consider an inventory sync that pulls records from two upstream systems behind one protocol: ```python @runtime_checkable class InventoryFeed(Protocol): def fetch(self, since: int) -> list[int]: ... ``` One adapter implements `fetch(self, since)`; a legacy adapter still implements `fetch(self)` and pages from its own stored cursor. Both pass `isinstance(feed, InventoryFeed)`. The first call to the legacy adapter raises `TypeError: fetch() takes 1 positional argument but 2 were given` — a loud failure, which is the lucky case. The unlucky case is subtler and is the one worth telling an interviewer about. Suppose the legacy adapter *does* accept `since` but treats the cursor as **inclusive** while the protocol's contract is **exclusive**. Every sync re-imports the record sitting exactly on the boundary, or — with the sign the other way — silently skips it. The signature matches, the names match, `isinstance()` is delighted, and the defect is an off-by-one record per sync window that no runtime check will ever see. Presence checking cannot express contracts; it can only express vocabulary. ### Why a True is still not a guarantee Three separate gaps stack up. First, names without signatures, as above. Second, names without kinds: a protocol declaring a method is satisfied by a non-callable attribute of the same name, because `getattr_static` does not ask whether the result is callable. Third, names without semantics: even a perfectly-typed implementation can mean something different by the same operation. ### The fixes, in order of strength 1. **Static checking in CI.** A type checker validates structural conformance properly — member names, parameter kinds, and return types — at every point where an object is passed as the protocol. This is where the guarantee actually lives, and marking a protocol runtime-checkable neither helps nor hinders it. 2. **Explicit subclassing at the seam.** Having each adapter inherit from the protocol asks the checker to validate the implementation at the class definition, so a wrong signature is reported in the adapter's own file rather than at some caller. The runtime check then passes nominally, without any attribute inspection. 3. **Verify by calling.** For genuinely dynamic inputs — plugins loaded by name, objects crossing a deserialization boundary — invoke the operation inside a `try`/`except TypeError` at startup, or inspect the callable with `inspect.signature()` if you need a specific arity. Both are strictly more informative than `isinstance()`. 4. **Contract tests.** Nothing but a test can catch the inclusive/exclusive boundary case; a shared test suite that every adapter must pass is what a runtime check is often mistakenly asked to be. The interview-safe summary: `isinstance()` against a runtime-checkable protocol answers "does this object have these names?", and it is sound to use only where that is genuinely the question being asked.

  • How would you check the arity of a member at runtime if you really needed to?
    Use `inspect.signature()` on the bound attribute and compare parameters, or simply call the operation inside a `try`/`except TypeError` during start-up validation. Both are honest about being runtime checks. Neither belongs in a hot path, and neither substitutes for the static type checker, which validates every call site rather than one sampled object.
  • Does an object satisfying the runtime check also satisfy the static type checker?
    Not necessarily — the implications run only one way. The static checker is strictly stricter: it compares parameter kinds, defaults, return types and variance, and it flags an object the runtime check happily accepts. The reverse can also happen since 3.12: a proxy resolving members through `__getattr__` may type-check fine while failing `isinstance()`.
  • Where does a presence-only check still earn its place?
    At an input boundary handling objects you did not type-check — plugins, deserialized payloads, user-supplied callbacks — where the goal is a clear error message instead of an `AttributeError` deep in a call stack. It is also fine for optional-capability branching, such as calling `close` only when the object has it.
  • What would it take for the runtime check to compare signatures?
    It would have to decide substitutability: whether one callable can stand in for another given parameter kinds, defaults and variance of argument and return types. That is a type-system question, answered by a checker with full annotations, not by an attribute lookup. Keeping `isinstance()` shallow keeps it cheap and keeps its semantics predictable.

saying these in an interview costs you the question

  • Claiming isinstance verifies method signatures
  • Treating a True result as a behavioural contract
  • Saying a non-callable attribute cannot satisfy a method member
  • Believing the decorator makes the check as strict as the type checker
  • Proposing runtime checks instead of running a static checker in CI

context