skip to content

Why does issubclass() raise TypeError for a runtime_checkable Protocol with a data attribute?

level: middleimportance: should knowfreq 26%

answer

  1. One check sees more than the other
  2. Instance state is not on the class
  3. Annotation in a class body creates nothing
  4. Method-only protocols keep both checks
  5. TypeError names the offending members

basics

~20 s

Conformance to a data member usually cannot be decided from the class alone, because such attributes are typically assigned in init and exist only on instances. Python refuses rather than guess, so use isinstance() for such protocols.

solid answer

~40 s

A runtime-checkable protocol supports two checks with different rules. `isinstance()` works for any such protocol, because it can inspect the object — the instance `__dict__` included — for each member name. `issubclass()` gets only a class, and an annotated data attribute like `cursor: int` normally lives on instances, assigned in `__init__`, with nothing on the class to find. Rather than answer wrongly, `typing` raises `TypeError`, naming the offending non-method members. So a protocol declaring only methods supports both checks; add one data member and `issubclass()` is off the table while `isinstance()` keeps working. If you need class-level checking, declare the protocol with methods only — for instance a read-only property expressed as a method — or restrict yourself to instance checks.

code

python · 23 lines
python
from typing import Protocol, runtime_checkable


@runtime_checkable
class HasCursor(Protocol):
    cursor: int

    def advance(self) -> None: ...


class Sync:
    def __init__(self) -> None:
        self.cursor = 0

    def advance(self) -> None:
        self.cursor += 1


print(isinstance(Sync(), HasCursor))
try:
    issubclass(Sync, HasCursor)
except TypeError as exc:
    print("TypeError:", exc)

go deeper

for a junior

Recall the split: with a data attribute in the protocol, isinstance() works and issubclass() raises TypeError. When in doubt, check an object rather than a class.

for a middle

Explain the information asymmetry — attributes assigned in init exist only on instances, and a bare annotation in a class body creates nothing — so a class-level answer would be a guess. Note the restriction is a property of the protocol.

for a senior

Show the design consequence: if a component must validate classes before instantiating them, the protocol has to be written method-only from the start. Retrofitting that later is a breaking change for every implementation.

for a principal

Own the interface-shape decision. Requiring state via accessor methods keeps class-level validation possible but pushes call syntax onto every consumer; deciding which side pays that cost is an API-design call, not a typing detail.

### Two checks, two amounts of information `@typing.runtime_checkable` unlocks both `isinstance()` and `issubclass()`, but they are not equally answerable. `isinstance(obj, P)` has an object in hand. Resolving a member name can consult the type, its bases, and the instance's own `__dict__`. So a protocol declaring `cursor: int` can be tested: the object either has a `cursor` or it does not. `issubclass(C, P)` has only a class. For a method member that is fine — methods are defined in the class body, so `C` either carries the name or it does not. For a data member it is usually hopeless. The overwhelmingly common Python idiom assigns instance state in `__init__`: ```python class Sync: def __init__(self) -> None: self.cursor = 0 ``` Nothing named `cursor` exists on `Sync` itself. A class-level check would have to answer False for a class whose every instance satisfies the protocol perfectly — a false negative baked into the language. The alternative, answering True whenever an annotation exists, would be a false positive, since a bare annotation in a class body creates no attribute at all. ### What Python does instead It refuses. `issubclass(Sync, HasCursor)` raises `TypeError`, with a message stating that protocols with non-method members do not support `issubclass()` and naming which members are the problem. That naming matters in practice: on a protocol with a dozen members the message points straight at the one annotation that closed the door. The rule is a property of the protocol, not of the argument. A protocol whose members are all methods supports `issubclass()`. Adding a single annotated attribute disables it permanently for that protocol, while leaving `isinstance()` fully functional. ### The workarounds * **Use `isinstance()`.** Almost always the right answer, because almost always you have an object. This is the intended path for data protocols. * **Express the requirement as a method.** A protocol that declares `def cursor(self) -> int: ...` is method-only, so both checks work, at the cost of a call at the use site. A `property` declared in the protocol body is still a non-method member for this purpose, so it does not buy back `issubclass()`. * **Declare class-level state.** If the attribute genuinely lives on the class — a class constant, or a value set by a metaclass — you can check for it with `inspect.getattr_static()` on the class yourself. But you are then writing the check by hand, not asking the protocol machinery. * **Subclass explicitly.** An implementation inheriting from the protocol satisfies `issubclass()` nominally and gets validated by the type checker at its own definition, sidestepping the whole question. ### The related version detail Python 3.12 tightened the surrounding machinery in two ways worth knowing. The lookup performed by `isinstance()` moved from `hasattr()` to `inspect.getattr_static()`, so a data member provided only through `__getattr__` no longer satisfies the check while one sitting in the instance `__dict__` still does. And the member list of a runtime-checkable protocol is settled when the class is created: monkey-patching attributes onto the protocol afterwards still works as an ordinary attribute assignment but has no effect on later `isinstance()` results. Both behaviours stand on 3.14. ### How to say it in an interview Lead with the information asymmetry rather than the error message: `issubclass()` is handed a class and asked a question whose answer lives on instances, so the language raises instead of guessing. Then note that this is exactly why a protocol intended for class-level checks — a plugin registry validating classes before instantiating them, say — should be written with methods only.

  • Does declaring the member as a property in the protocol body restore issubclass()?
    No. A property is still a non-method member for this rule, so `issubclass()` stays disabled. The only way back is to declare the requirement as an ordinary method — `def cursor(self) -> int: ...` — which makes the protocol method-only again, at the cost of forcing every implementation and every caller to use a call rather than an attribute.
  • Why does isinstance() find an attribute assigned in __init__ but a class-level check cannot?
    Because the lookup starts from the object. `inspect.getattr_static()` on an instance reads the instance `__dict__` as well as the type and its bases, so a value assigned in `__init__` resolves. Given only the class, that dictionary does not exist yet — the attribute is created per instance at construction time, so there is nothing to inspect.
  • You need to validate plugin classes before instantiating them. How do you design the protocol?
    Declare it with methods only, so `issubclass()` remains legal, and keep required state behind an accessor method or a documented constructor contract. If class-level constants are genuinely part of the interface, check for them yourself with `inspect.getattr_static()` on the class, or require plugins to subclass the protocol explicitly so the type checker validates them at definition.

saying these in an interview costs you the question

  • Saying issubclass fails because the class is not registered
  • Claiming isinstance also fails for data-member protocols
  • Believing a bare class-body annotation creates an attribute
  • Thinking a property counts as a method member here
  • Assuming the restriction depends on the class being tested

context