How to Use typing.Optional vs Union in Python 3.10+

TL;DR

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 vs Union vs pipe syntax equivalence Optional[T] equals Union[T, None] equals T | None for static analysis. At runtime, T | None produces types.UnionType while typing.Union produces typing.Union — different objects, same static meaning. Optional[T] typing (legacy) Union[T, None] typing (legacy) T | None PEP 604 (3.10+) Static analysis: identical — same error messages, same narrowing mypy / pyright normalize both to the same internal union type Runtime: different objects types.UnionType vs typing.Union
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.

Runtime vs static analysis mypy and pyright normalize both 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.Optional is deprecated: It remains fully supported for backward compatibility. Using it adds verbosity in 3.10+ but is not an error.
  • Mixing typing.Union and | 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 raises TypeError. Use isinstance(x, (int, str)) (tuple form) or isinstance(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.

Back to Union and Optional Types