skip to content

Why does isinstance(value, list[int]) raise TypeError, and what check works instead?

level: middleimportance: should knowfreq 34%

answer

  1. The second argument must be a class
  2. Element checking is not free, and Python knows it
  3. Reduce the alias before the check
  4. One helper turns any annotation into a class
  5. Unions are the exception here

basics

~10 s

list[int] is a parameterized generic alias, and isinstance rejects it: the interpreter cannot check element types without walking the whole value. Reduce the alias with typing.get_origin first, then check elements yourself using typing.get_args.

solid answer

~40 s

Subscripting a builtin container yields a `types.GenericAlias` object, not a class, and `isinstance` refuses it with `TypeError: isinstance() argument 2 cannot be a parameterized generic`. The refusal is deliberate: confirming `list[int]` would mean inspecting every element, which is unbounded work and would consume a one-shot iterable, so Python declines to pretend it is an O(1) type test. The runtime check is two steps — `isinstance(value, get_origin(ann) or ann)` to test the container, then your own loop over `get_args(ann)` to test the elements as deeply as you actually need. Note the asymmetry with unions: since 3.10, `isinstance('x', str | None)` is allowed and returns `True`, because a union check needs no element inspection. The legacy `typing.List[int]` spelling is rejected too, with a different message.

code

python · 12 lines
python
from typing import get_args, get_origin

ann = list[int]
try:
    isinstance([1, 2], ann)
except TypeError as exc:
    print("rejected:", exc)

origin = get_origin(ann) or ann
(item_type,) = get_args(ann) or (object,)
value = [1, 2]
print(isinstance(value, origin) and all(isinstance(v, item_type) for v in value))

go deeper

for a junior

Know that a subscripted container annotation is not a class and cannot be passed to isinstance, and that the container class itself is the thing to check against.

for a middle

Explain why the interpreter refuses rather than walking the elements, and write the two-step check: reduce with get_origin, then verify arguments yourself with get_args.

for a senior

Show the judgement about how much element checking is worth doing on a hot path, what an empty or lazily-produced container means for your answer, and where that policy is written down for the next reader.

for a principal

Own whether runtime shape validation belongs in your platform at all, given that annotations are declarations rather than guarantees, and what the team's single reduction helper must accept so no call site improvises.

### The object being passed is not a class `isinstance(obj, cls)` expects a class or a tuple of classes. `list[int]` is neither: subscripting the builtin produces a `types.GenericAlias`, an object that records the origin class and the argument tuple. Handing it to `isinstance` raises `TypeError: isinstance() argument 2 cannot be a parameterized generic`. The older `typing.List[int]` spelling is a different alias type and raises its own message about subscripted generics not being usable in class and instance checks — same rule, different wording. ### Why the refusal is a feature Python could, in principle, walk the list and check every element. It refuses on purpose. A container check would be O(n) hiding behind a call that everyone reads as O(1); it would have to decide what an empty list means, whether a heterogeneous list fails on the first bad element or reports all of them, and how deep to go for `dict[str, list[int]]`. Worst of all, applied to a lazily-produced value it would have to consume it to answer, and the caller would be handed an exhausted iterator. Raising immediately keeps `isinstance` honest and pushes the policy decisions to the code that actually knows the answer — yours. ### The check that works Reduce the alias to something `isinstance` accepts, then decide separately about the elements: ```python from typing import get_args, get_origin def matches(value, ann): origin = get_origin(ann) or ann if not isinstance(value, origin): return False args = get_args(ann) if not args: return True # bare container: nothing to verify if origin is list: return all(matches(v, args[0]) for v in value) return True ``` The `get_origin(ann) or ann` idiom is the workhorse: for a subscripted alias it yields the class, for a plain class it yields the class itself, because `get_origin` returns `None` there. Everything after that is your policy — check all elements, sample the first one, or trust the declaration and skip the loop entirely. Sampling is common in hot paths and is a defensible tradeoff as long as it is written down; what is not defensible is silently believing a declaration you have never verified against untrusted input. ### The union exception Unions behave differently and the difference trips people. `isinstance('x', str | None)` has been legal since 3.10 and returns `True`, because deciding a union needs no element inspection: it is just a set of ordinary class checks. So a validator can pass a union annotation straight through while it must reduce `list[int]` first. Code that assumes annotations are uniformly unusable with `isinstance` writes an unnecessary branch; code that assumes they are uniformly usable crashes on the first parameterized container. ### Related refusals `issubclass` behaves the same way — reduce with `get_origin` first, and `issubclass(list, get_origin(list[int]))` is `True`. `typing.Literal['a']` and `typing.Annotated[int, 'meta']` are also not classes and are not valid second arguments; the literal has to be checked by membership against `get_args`, and the annotated wrapper has to be stripped down to its wrapped type before any of this applies. Structural protocol classes are a separate mechanism with their own opt-in for runtime checking, and are not what `get_origin` reduction is for. ### The practical shape In real code, the reduction lives in one helper rather than being repeated at every call site, and it is written to return a class that `isinstance` will accept for *any* annotation the codebase allows — bare classes, subscripted containers, unions, wrapped-with-metadata declarations. Once that helper exists, validation and construction share the same front door, and the `TypeError` never reaches production because no call site passes a raw annotation to `isinstance` any more. ### Versions Subscripting builtins is 3.9 and later (PEP 585); `isinstance` accepting a pipe union is 3.10 and later (PEP 604). The rejection of parameterized generics in class and instance checks has always held and holds on 3.14.

  • Why is isinstance allowed to take a union but not a parameterized container?
    A union check is a set of ordinary class checks against the object itself — constant work, no traversal. A parameterized container check would have to inspect the elements: unbounded work, ambiguous for empty containers, and destructive for lazily-produced values. Python allows the cheap, unambiguous one and refuses the expensive one rather than hiding a traversal behind a call everyone reads as O(1).
  • What does get_origin(ann) or ann buy you over calling get_origin alone?
    `get_origin` returns `None` for an unsubscripted class, so the bare `int` case would pass `None` to `isinstance` and raise. The `or ann` fallback makes one expression handle both a subscripted alias and a plain class, which is what lets a single helper accept every annotation shape a codebase allows.

saying these in an interview costs you the question

  • Believes isinstance verifies element types when it succeeds
  • Thinks list[int] is a class like list
  • Says no annotation can be used with isinstance, including unions
  • Passes get_origin(int) to isinstance without a fallback
  • Claims issubclass accepts parameterized generics even though isinstance does not

context