skip to content

What does typing.get_origin return for str | None versus typing.Union[str, None]?

level: middleimportance: must knowfreq 40%

answer

  1. A union has no container class to return
  2. The origin is the union type itself
  3. None becomes a class in the arguments
  4. Two spellings, and they merged recently
  5. 3.14 made Union and UnionType one object

basics

~20 s

On Python 3.14 both return typing.Union, because typing.Union and types.UnionType are now the same object. typing.get_args gives (str, NoneType) for either spelling. On 3.10 through 3.13 the pipe form reported types.UnionType and the bracket form reported typing.Union.

solid answer

~40 s

A union is not a container, so `get_origin` cannot hand back a class the way it does for `list[int]`; it hands back the union type itself. On 3.14, `get_origin(str | None)` and `get_origin(typing.Union[str, None])` both return `typing.Union`, and `typing.Union is types.UnionType` is `True` — the two spellings were merged into one runtime object. `get_args` then returns `(str, NoneType)`, with `types.NoneType` standing in for `None`; filtering that out is how runtime code recovers the "real" member of an optional field. On 3.10 through 3.13 the two spellings produced *different* origins, so portable dispatch code had to accept both, and library code written before 3.14 still carries that double check. Unions also flatten and de-duplicate at construction: `typing.Union[int, int]` collapses to plain `int`, whose origin is `None`.

code

pycon · 10 lines
pycon
>>> import types, typing
>>> from typing import get_args, get_origin
>>> get_origin(str | None) is typing.Union
True
>>> get_origin(typing.Union[str, None]) is typing.Union
True
>>> typing.Union is types.UnionType
True
>>> get_args(str | None)
(<class 'str'>, <class 'NoneType'>)

go deeper

for a junior

Know that a union annotation reports a union origin rather than a container class, and that the arguments list the members with None appearing as the NoneType class.

for a middle

Be able to write the optional check: compare the origin against the union type, then filter NoneType out of the arguments, and say which Python version merged the two union spellings into one object.

for a senior

Show the compatibility judgement — whether your annotation walker still has to accept both union origins for the interpreter versions you support, and how you would verify that on the oldest one rather than assuming.

for a principal

Own the policy question: how much runtime type introspection your platform code should depend on when the union representation itself moved in 3.14, and what your support window costs in code you must keep dual-path.

### Two spellings, one meaning Python has two ways to write a union in an annotation: `typing.Union[str, None]`, which has existed since typing was introduced, and `str | None`, the PEP 604 syntax added in 3.10. They have always compared equal, but for years they were *different runtime objects*, and code that inspected annotations had to know it. ### What get_origin gives you For a container alias like `list[int]`, `get_origin` returns a class you can call. A union has no such class — there is nothing to construct — so `get_origin` returns the union type itself, as a marker you compare with `is`: ```python import types, typing from typing import get_args, get_origin get_origin(str | None) is typing.Union # True on 3.14 typing.Union is types.UnionType # True on 3.14 get_args(str | None) # (<class 'str'>, <class 'NoneType'>) ``` `get_args` returns the union members in written order, after flattening and de-duplication. `None` written in an annotation is normalized to `types.NoneType`, the class of `None`, because a union's members are types. ### The 3.14 merge, and why it shows up in interviews Python 3.14 made `typing.Union` an alias for `types.UnionType`: the two names now refer to the same object, both spellings build the same kind of value, and their `repr` is the pipe form. Before that — on 3.10 through 3.13 — `get_origin(int | str)` returned `types.UnionType` while `get_origin(typing.Union[int, str])` returned `typing.Union`, and those two are not identical. The practical consequence was a double check that appears all over annotation-walking code written in that era: ```python # what portable code had to say before 3.14 origin = get_origin(ann) if origin is typing.Union or origin is types.UnionType: ... ``` On 3.14 the first test alone suffices, but the double check is harmless and still correct, since the two operands are now the same object. Do not "simplify" it in a codebase that still supports 3.13. ### Recovering an optional's inner type The common task is: given an annotation, is this field allowed to be missing, and if so what is it otherwise? The pattern is origin check, then argument filter: ```python def optional_inner(ann): if get_origin(ann) is typing.Union: rest = tuple(a for a in get_args(ann) if a is not types.NoneType) if len(rest) == 1 and len(rest) < len(get_args(ann)): return rest[0] return None ``` This correctly reports `str` for `str | None`, `list[int]` for `typing.Optional[list[int]]` — `Optional[X]` is just `X | None`, not a distinct runtime thing — and nothing for `int | str`, which is a union but not an optional. Note the inner value can itself be an alias, so a converter recurses on it rather than assuming a class. ### Normalization traps Unions normalize eagerly at construction, and that catches people writing generic code. `typing.Union[int, int]` is not a union at all: it collapses to the class `int`, so `get_origin` on it returns `None`. `typing.Union[int, typing.Union[str, bytes]]` flattens to three members. If you build union annotations dynamically from a list of types, a one-element list yields a plain class and your union branch is never taken — handle that case explicitly instead of assuming the shape you meant to build. ### What still differs between unions and containers Unions are usable in `isinstance` since 3.10 — `isinstance('x', str | None)` is `True` — while a parameterized container alias like `list[int]` is rejected there. So a validator can hand a union straight to `isinstance` but must reduce a container alias to its origin first. That asymmetry is a frequent source of "it worked for the optional fields and blew up on the list fields" bugs. ### Versions PEP 604 pipe unions: 3.10. `types.NoneType` re-exposed: 3.10. `typing.Union` and `types.UnionType` unified into one object: **3.14**. Everything above is stated for 3.14 unless a version is named.

  • Why does typing.get_origin return None for typing.Union[int, int]?
    Because unions de-duplicate and flatten when they are built. `typing.Union[int, int]` never becomes a union object at all — it collapses to the plain class `int`, which has no origin. The same happens when you build a union dynamically from a one-element list of types, so code that constructs annotations at runtime must handle the collapsed case rather than assuming its union branch will be reached.
  • Can you pass a union straight to isinstance, and does the same hold for list[int]?
    Yes for the union: since 3.10, `isinstance('x', str | None)` works and returns `True`. No for the container: `isinstance([1], list[int])` raises `TypeError`, because a parameterized generic cannot be used in a class or instance check. Runtime validators therefore reduce container annotations to `get_origin(ann)` first, while unions can be checked directly.
  • How does typing.Optional[str] show up under this introspection?
    Identically to `str | None`: `Optional[X]` is a spelling for `X | None`, not a separate runtime construct. `get_origin` reports the union type and `get_args` returns `(str, NoneType)`. There is no `Optional` origin to test for, so any code looking for one will never match.

saying these in an interview costs you the question

  • Expects a separate Optional origin to test against
  • Thinks get_args(str | None) contains the value None
  • Claims the pipe and bracket unions still have different origins on 3.14
  • Assumes get_origin returns a class you can call for a union
  • Believes Union[int, int] stays a union with two members

context