How does Python's `int | str` union spelling differ from `typing.Union[int, str]`?
answer
- Same meaning, different era
- One of them needs no import
- Think 3.10 and a PEP number
- Also legal as an isinstance argument
basics
~20 sThey denote the same type. The operator form, added by PEP 604 in Python 3.10, needs no import, reads better nested, and works in isinstance; typing.Union is the older spelling, still common in pre-3.10 code.
solid answer
~40 sTo a type checker they are identical, and `Optional[X]` is simply that union with `None` as a member. PEP 604 added the operator form in Python 3.10, and it wins on ergonomics: no import, far more readable when nested such as `dict[str, list[int] | None]`, and usable at runtime — `isinstance(value, int | str)` is a legal check. Both spellings normalize their members the same way: nested unions flatten, duplicates collapse, a single-member union collapses to that member, and order does not affect equality, so `int | str == str | int`. The operator form has limits: before 3.10 it raised `TypeError` when the annotation was evaluated, and `isinstance` still refuses a parameterized generic, so `isinstance(rows, list[int] | None)` fails. For new code on any supported Python, prefer the operator form and stay consistent.
code
pycon · 11 lines>>> from typing import Optional, Union
>>> Union[int, Union[str, int]]
int | str
>>> Optional[Optional[str]]
str | None
>>> int | str == str | int
True
>>> isinstance(3, int | str)
True
>>> isinstance(None, str | None)
Truego deeper
Recall that int | str and typing.Union[int, str] mean the same thing and that the operator form needs no import. Prefer the operator form in new code and be able to read the older spelling.
Explain that PEP 604 added the operator in Python 3.10, that unions flatten, deduplicate and ignore order, and that the operator form is also legal as the second argument to isinstance.
Demonstrate awareness of the edges: what breaks on older runtimes, why isinstance refuses a parameterized generic, and when a growing union is a signature drifting toward Any rather than a useful type.
Own the consistency decision across repositories: one union spelling, a plan for mechanically rewriting the legacy form, and a standard for how wide a public union may get before the API should be split instead.
### One type, two spellings `typing.Union[int, str]` and `int | str` denote the same type. Every checker treats them identically, and `Optional[X]` is just the same union with `None` as one member: `Optional[int]`, `Union[int, None]` and `int | None` are three ways of writing one thing. ### What PEP 604 added in Python 3.10 The operator form is the newer spelling and it brought three practical wins: 1. **No import.** `def load(shard: str, retries: int | None) -> bytes | None:` needs nothing from `typing`. 2. **It reads better nested.** `dict[str, list[int] | None]` against `Dict[str, Optional[List[int]]]`. 3. **It works at runtime in `isinstance` and `issubclass`.** `isinstance(value, int | str)` is a legal check from 3.10 onward, so the same expression can serve as both an annotation and a runtime test. Before 3.10 the operator raised `TypeError` when the annotation was evaluated, which is why code targeting 3.9 and earlier either used `typing.Union`, quoted the annotation as a string, or turned on the `__future__` import that stores annotations unevaluated. ### Normalization: unions are sets, not sequences Both spellings normalize the members, and the rules are worth knowing because they explain surprising `repr` output and equality results: - **Nested unions flatten.** `Union[int, Union[str, int]]` is `int | str`. - **Duplicates collapse.** Writing the same member twice changes nothing. - **A one-member union collapses to the member.** `Union[int]` is simply `int`, not a union at all. - **Order does not affect equality.** `int | str == str | int` is `True`. The order is preserved for display, but two unions with the same members are the same type. - **`Optional` folds in.** `Optional[Optional[str]]` is `str | None`, and `Optional[int | str]` is `int | str | None`. A consequence: you cannot rely on member order for dispatch, and you cannot build a "union with a duplicate" to carry extra meaning. ### Where the operator form does not reach - **Both operands must be types.** `int | str` works because both sides are classes; an expression like a string literal on the left is a `TypeError`, since `|` on `str` values is simply not defined. - **`isinstance` still refuses parameterized generics.** `isinstance(rows, list[int] | None)` raises `TypeError: isinstance() argument 2 cannot be a parameterized generic`. The check has no way to inspect the element types without walking the object. Erase the parameter — `isinstance(rows, list | None)` — and check elements separately if you truly need to. - **Some annotations are strings anyway.** A union written inside a quoted annotation is not evaluated where it appears, so it does not need the operator to exist at that moment. ### `Optional` is not deprecated `typing.Optional` and `typing.Union` remain part of the standard library and are not scheduled for removal; enormous amounts of code use them and reading that code is part of the job. The guidance is about new code and consistency: pick one spelling per codebase, and on any currently supported Python that spelling should be the operator form. ### The 3.14 wrinkle Through 3.13 the two spellings produced different kinds of runtime object that nonetheless compared equal, which occasionally surprised code that inspected annotations. In 3.14 they were unified: `typing.Union[int, str]` and `int | str` now produce the same kind of union object, so the last observable difference between the spellings is gone. For annotation purposes nothing changed — they always meant the same type. ### Choosing members deliberately The mechanical question ("which spelling?") is easy; the design question is which members belong in the union at all. A union of two unrelated types often means the function is doing two jobs, and a union that grows a member per release is a signature drifting toward `Any`. Unions earn their place when the alternatives are genuinely equivalent to the caller — a value or its absence, a parsed result or a raw fallback — and each member is something the caller can act on. ### What an interviewer is listening for That you state the equivalence flatly rather than hedging; that you attach the operator form to Python 3.10 and PEP 604; that you know unions flatten, deduplicate and ignore order; and that you can name at least one place the runtime form does not work, such as `isinstance` with a parameterized generic.
- Does `int | str` work in an annotation on Python 3.9?Not as an evaluated expression: before 3.10 the operator raises TypeError on those objects. Code targeting 3.9 either uses `typing.Union`, quotes the annotation so it is never evaluated, or turns on the `__future__` import that stores annotations unevaluated. Every currently supported Python is 3.10 or newer, so new code can use the operator freely.
- Why does `isinstance(rows, list[int] | str)` raise TypeError?Because `list[int]` is a parameterized generic and isinstance cannot check element types - it would have to walk the object, and an exhausted iterator could not be checked at all. Use the bare class, `isinstance(rows, list | str)`, and check elements separately if you genuinely need to. The union itself is fine; the parameterization is what isinstance refuses.
- Is `typing.Optional` deprecated now that `| None` exists?No. `typing.Optional` and `typing.Union` remain in the standard library with no removal scheduled, and large codebases are full of them, so reading them is part of the job. The guidance is about new code: pick one spelling as house style, and on any supported Python that spelling should be the operator form.
saying these in an interview costs you the question
- Thinks int | str is only valid inside a quoted annotation
- Believes Union[int, int, str] keeps the duplicate member
- Says typing.Union is required for any isinstance check
- Claims member order changes the resulting type
- Expects isinstance to accept list[int] | None