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.
Optional[T], Union[T, None], and T | None are interchangeable for type checkers; at runtime the pipe form creates a distinct types.UnionType.The PEP 604 Syntax Shift: T | None vs Optional[T]
typing.Optional[T] is exactly equivalent to typing.Union[T, None]. The native T | None syntax reduces import overhead and improves readability without altering runtime performance. Modern Python codebases should standardize on the pipe operator.
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. You can safely remove redundant typing imports when targeting Python 3.10+.
Static Analysis & Runtime Type Checking Compatibility
mypy and pyright normalize both syntaxes to the same internal representation, guaranteeing zero false positives in CI pipelines. However, runtime behavior differs.
The | operator creates a types.UnionType object at runtime in Python 3.10+. typing.Union creates a typing.Union object. These are different types at runtime but equivalent for static analysis.
import typing
alias = int | str | None
print(typing.get_args(alias))
# Output: (<class 'int'>, <class 'str'>, <class 'NoneType'>)
typing.get_args() works on both types.UnionType and typing.Union in Python 3.10+. Use it for runtime introspection instead of direct type comparisons. The standard library flattens nested unions automatically.
# Runtime isinstance with a union type (Python 3.10+)
def validate(val: int | str) -> bool:
return isinstance(val, (int, str)) # isinstance takes a tuple, not a union type
Note: isinstance(val, int | str) works in Python 3.10+ as the | union is accepted by isinstance. However, isinstance(val, typing.Union[int, str]) raises TypeError in all Python versions — always use tuples or native | unions with isinstance.
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
Automate bulk conversions using pyupgrade. Run the tool against your target Python version to rewrite signatures safely.
pyupgrade --py310-plus **/*.py
Configure Ruff to flag legacy imports automatically:
[tool.ruff.lint]
select = ["UP007"] # Enforces PEP 604 union syntax
Validate your pipeline with strict mypy mode. This catches residual type mismatches early.
mypy --strict --python-version 3.10 src/
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.
Edge Cases: Forward References and typing.get_args
String forward references parse identically under PEP 604. You can safely use "Node | None" in recursive type aliases. typing.get_args() returns flattened tuples for nested unions.
Avoid mixing Union and | in the same signature. Static analyzers may flag inconsistent syntax as a style violation. Standardize on | for all new code and legacy refactors.
# Safe forward reference using string annotation
def recursive(data: "Node | None") -> "Node | None":
...
Complex generics may trigger false positives in older mypy versions. Upgrade to mypy>=1.0 to resolve nested union evaluation bugs.
Common Mistakes
- Assuming
typing.Optionalis deprecated: It remains fully supported for backward compatibility. Using it adds verbosity in 3.10+ but is not an error. - Mixing
typing.Unionand|in the same signature: Static analyzers flag inconsistent union syntax as a style violation. Standardize on one form per project. - Using
isinstance(x, typing.Union[int, str])for runtime checks: This raisesTypeError. Useisinstance(x, (int, str))(tuple form) orisinstance(x, int | str)(Python 3.10+ only).
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.