How to Use typing.Optional vs Union in Python 3.10+
In Python 3.10+, prefer X | None over Optional[X] and X | Y over Union[X, Y]. Both forms are semantically identical to static analyzers; the difference is runtime: X | Y creates types.UnionType, while typing.Union creates a typing.Union object. Use from __future__ import annotations to enable pipe syntax in annotation strings on Python 3.7+, but note runtime isinstance checks with | still require 3.10+.
Python 3.10 introduced PEP 604, enabling native union syntax with the | operator. This guide details the exact migration path from typing.Union and typing.Optional to modern equivalents, ensuring compatibility with static analyzers like mypy and pyright. For foundational concepts, review Core Type Hints Fundamentals before implementing syntax changes.
Understanding the semantic equivalence between legacy imports and native operators is critical. It ensures consistent Union and Optional Types across enterprise codebases. Static type checkers treat both forms identically in Python 3.10+. Runtime checks require specific handling.
The PEP 604 Syntax Shift: T | None vs Optional[T]
typing.Optional[T] is defined as exactly typing.Union[T, None] — despite the name, Optional never means “optional argument,” only “the value may be None.” PEP 604 (accepted for Python 3.10) added the | operator directly on type objects, so the same union spells as T | None with nothing imported from typing. The three forms Optional[T], Union[T, None], and T | None denote an identical set of runtime values, and both mypy and pyright normalise all three into one canonical union node before checking anything, so no analyzer can tell them apart or emit a different error for one versus another.
The pipe form composes left to right and flattens automatically: int | str | None is a single flat three-member union, exactly as Union[int, str, None] collapses Union[Union[int, str], None]. Member order is irrelevant to type checkers — int | None and None | int are the same type — and duplicates are removed, so int | int reduces to int. Most codebases still keep None last purely for readability.
from typing import Optional, Union
# All three annotate the identical type:
a: Optional[int] # == Union[int, None]
b: Union[int, None] # spelled out
c: int | None # PEP 604, Python 3.10+
# Unions flatten and de-duplicate; order does not matter:
d: int | str | int # -> int | str
e: Union[int, Union[str, None]] # -> int | str | None
A practical reason to prefer the operator is that it stacks with PEP 585 builtin generics: Optional[Dict[str, List[int]]] becomes dict[str, list[int]] | None, letting many signatures drop their typing import entirely. The operator also removes one asymmetry of the bracket form — Optional accepts a single argument, so Optional[int, str] is a TypeError; you must write Optional[Union[int, str]] or, more cleanly, int | str | None.
Both process_legacy and process_modern below annotate the same type. Once you target 3.10+, removing the now-redundant from typing import Optional, Union is safe, and ruff’s F401 flags the leftover import for you.
from typing import Optional, Union
# Legacy syntax
def process_legacy(data: Optional[Union[str, int]]) -> None:
pass
# Python 3.10+ syntax
def process_modern(data: str | int | None) -> None:
pass
Both signatures are semantically equivalent. Type checkers resolve both to the same internal representation, so reveal_type() on a value of either type prints the same union. You can safely remove redundant typing imports when targeting Python 3.10+.
Static Analysis & Runtime Type Checking Compatibility
For type checkers there is nothing to reconcile: mypy and pyright each parse Optional[T], Union[T, None], and T | None into one canonical union node, so narrowing, error messages, overload resolution, and reveal_type() output are identical across the forms. Mixing them in a single file is therefore never a type error — the only tool that “objects” is a linter you have configured to rewrite the legacy spellings (see the migration section), not the checker itself.
At runtime the two spellings are genuinely different objects, and that surfaces the moment you introspect an annotation. int | str evaluates to a types.UnionType (new in 3.10); typing.Union[int, str] evaluates to a typing.Union special form. They are not equal to each other and report different get_origin() values, even though get_args() returns the same tuple.
import typing, types
pipe = int | str
legacy = typing.Union[int, str]
typing.get_args(pipe) # (int, str)
typing.get_args(legacy) # (int, str) — same
typing.get_origin(pipe) # <class 'types.UnionType'>
typing.get_origin(legacy) # typing.Union
pipe == legacy # False — distinct objects
Prefer typing.get_args() and typing.get_origin() over type(u) is … or is checks when you inspect a union programmatically. Both helpers understand both spellings on 3.10+ and both flatten nested unions, so typing.get_args(int | (str | None)) is (int, str, NoneType). Testing type(u) is types.UnionType only catches the pipe form and silently misses any typing.Union annotation — the reverse trap of code written before 3.10.
isinstance deserves its own note, because PEP 604 also enabled runtime isinstance against a pipe union. Since 3.10 the interpreter special-cases types.UnionType, so you can pass the union straight in:
# Runtime isinstance with a union type (Python 3.10+)
def validate(val: int | str) -> bool:
return isinstance(val, int | str) # OK on 3.10+
But isinstance(val, typing.Union[int, str]) raises TypeError: Subscripted generics cannot be used with class and instance checks on every version, and the pipe form only works from 3.10 — on 3.9 isinstance(val, int | str) raises TypeError: unsupported operand type(s) for |: 'type' and 'type' because the | itself never evaluates. The portable spelling remains the tuple, isinstance(val, (int, str)), which works on every version and is exactly the runtime narrowing pattern type checkers recognise (see TypeGuard and type narrowing).
Union[X, Y] and X | Y to the same internal representation — you get identical error messages and narrowing behaviour for either form. At runtime, however, X | Y produces a types.UnionType instance and typing.Union[X, Y] produces a typing.Union instance. Both are accepted by isinstance in Python 3.10+, but they are not equal to each other (int | str != typing.Union[int, str]), which matters if your code inspects union objects directly.
Step-by-Step Migration for Large Codebases
Both pyupgrade and ruff implement the same rewrite behind a target-version gate — ruff reimplements pyupgrade’s rules, so most projects run only ruff. Two rules are involved: UP007 rewrites Union[X, Y] to X | Y, and UP045 (split out in newer ruff releases) rewrites Optional[X] to X | None; older ruff folded both into UP007. Point the tool at the lowest Python you still support, because the gate is what keeps a runtime-unsafe rewrite out of code that must run on 3.9.
Run the autofixer across the tree — standalone pyupgrade, or ruff carrying the same rules:
# Standalone pyupgrade
pyupgrade --py310-plus **/*.py
# Or ruff, autofixing both union rules at once
ruff check --select UP007,UP045 --fix src/
Configure the gate in pyproject.toml so every contributor and CI run agrees on the target and cleans up the leftover imports in the same sweep:
[tool.ruff]
target-version = "py310" # gate the runtime-unsafe rewrite
[tool.ruff.lint]
select = ["UP007", "UP045", "F401"] # union rewrites + unused-import cleanup
With target-version = "py310" (or higher) ruff performs the rewrite; with py39 or lower it refuses, because emitting int | None into code that runs on 3.9 would raise unless annotations are deferred. After the union rewrite the old from typing import Optional, Union is unused, and F401 removes it on the same or next --fix pass, completing the diff.
The rewrite is purely syntactic, so it must not change what mypy reports. Validate your pipeline with strict mode before and after and diff the output — an empty diff is the goal:
mypy --strict --python-version 3.10 src/
--python-version 3.10 tells mypy to analyse as 3.10 regardless of the interpreter running it, which matters when you type-check on a newer local Python than you deploy; pyright takes the equivalent setting via pythonVersion in pyrightconfig.json or [tool.pyright]. If a # type: ignore sat on a line whose union you rewrote, mypy may now report [unused-ignore] under warn_unused_ignores — expected churn; delete the stale ignore.
Note that from __future__ import annotations enables | syntax in annotation strings for Python 3.7+, but runtime isinstance checks with | still require 3.10+. Isolate static hints from runtime logic when supporting older interpreters; the full sub-3.10 path, including how the autofixer becomes safe once annotations are deferred, is covered in migrating Union to the pipe operator and in the edge cases below.
Edge Cases: Forward References and typing.get_args
A forward reference — a string annotation naming a not-yet-defined type — parses under the same grammar whether it contains Optional, Union, or |. So "Node | None" is a valid recursive annotation, provided the context that eventually evaluates that string supports the operator (Python 3.10+, or never evaluates it at all). Under from __future__ import annotations you can even drop the quotes and write the pipe directly, because every annotation in the module is stored as a string.
from __future__ import annotations
class Node:
next: Node | None = None # stored as a string under PEP 563; safe on 3.7+
def children(self) -> list[Node]:
...
The subtlety is when that string is turned back into a real type. from __future__ import annotations (PEP 563) makes every annotation a string and never evaluates it during normal execution, so the pipe is free even on 3.9. But any tool that calls typing.get_type_hints() — dataclasses resolving hints, pydantic, FastAPI dependency resolution, attrs — evaluates that string, and on 3.9 "int | None" raises TypeError: unsupported operand type(s) for | at that moment, not at import. This is the single most common sub-3.10 break.
typing.get_args() and typing.get_origin() are the correct tools for taking a union apart, and they flatten nested unions for you — including a mixed expression that combines both spellings:
import typing
alias = int | typing.Union[str, None] # mixed forms in one expression
typing.get_args(alias) # (int, str, NoneType) — flattened
typing.get_origin(alias) # typing.Union
That mixed expression is worth dwelling on: int | Union[str, None] is legal and evaluates fine on 3.10+. Mixing the two spellings is not itself a checker error — contrary to a common belief, no type error is raised for an inconsistent signature. Consistency is a lint preference, and ruff’s UP007/UP045 will rewrite the legacy Union/Optional half to | if you enable them; standardising is a style choice, not a correctness requirement. What genuinely fails is evaluating any | expression on a pre-3.10 runtime.
Two more corners are worth knowing. cast(Optional[int], x) and TypeVar("T", bound=Optional[int]) evaluate their arguments eagerly at runtime — from __future__ import annotations does not defer them, because they are ordinary call and class arguments, not annotations. On sub-3.10 they must keep Optional/Union. Separately, some very old mypy releases (pre-1.0) mishandled nested PEP 604 unions in string annotations; upgrade to mypy>=1.0 to clear those. pyright has understood the operator since well before 3.10 shipped, gated on your configured pythonVersion, so a low target correctly flags runtime-unsafe pipes there too.
Common Mistakes
Nearly every failure here has one root cause: X | Y is simultaneously a static-analysis construct that any modern checker understands and a runtime expression that only evaluates on 3.10+. Keep the static and runtime views separate and each mistake below disappears.
- Assuming
typing.Optionalis deprecated: It remains a fully supported alias forUnion[T, None], with no deprecation warning and no runtime cost. Using it in 3.10+ is a style choice, not an error. - Believing mixing
typing.Unionand|is a checker error: A single signature may contain both, and mypy and pyright accept it without complaint. Consistency is a lint/style preference — ruffUP007/UP045will rewrite the legacyUnion/Optionalform to|if you enable them, but the mix itself is neither a type error nor a runtime error on 3.10+. - Using
isinstance(x, typing.Union[int, str])for runtime checks: This raisesTypeErroron every version. Useisinstance(x, (int, str))(tuple form) orisinstance(x, int | str)(Python 3.10+ only). - Targeting
py310while still shipping 3.9: The autofixer emitsint | None, and any runtime evaluation of that annotation —get_type_hints, pydantic, a dataclass field resolve — raisesTypeErroron 3.9. Matchtarget-versionto your true minimum, or defer annotations withfrom __future__ import annotationsand avoid runtime introspection. - Adding
from __future__ import annotationsand expectingisinstanceto work: The future import defers annotations only; it does not make the|operator evaluate, so runtimeisinstance(x, int | str)still needs 3.10+.
Mapped to their corrections, the recurring ones look like this:
FAQ
Is typing.Optional officially deprecated in Python 3.10+?
No. It remains fully supported for backward compatibility. PEP 604 recommends T | None for cleaner syntax, but there is no deprecation warning.
How does mypy handle X | Y vs Union[X, Y] internally?
mypy normalizes both to an identical internal union representation. Static analysis yields identical error messages for both forms.
Can I use the | operator in Python 3.9 with __future__?
You can use X | Y in annotation strings with from __future__ import annotations in Python 3.7+, because annotations are stored as strings and not evaluated at runtime. However, runtime isinstance(x, int | str) requires Python 3.10+ regardless.