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 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.

Legacy typing unions rewritten to the pipe operator Optional of a dict and Union of int and str on the legacy side become dict pipe None and int pipe str, and the typing import disappears. before — from typing import … after — PEP 604 from typing import Optional, Union x: Optional[dict[str, int]] y: Union[int, str] # no typing import needed x: dict[str, int] | None y: int | str
The rewrite is mechanical: Optional[X] becomes X | None, Union[A, B] becomes A | B, and the typing import is dropped.
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.

How int | str and Union[int, str] behave under runtime introspection get_args returns the same tuple for both, but get_origin, isinstance, and cross-form equality differ between types.UnionType and typing.Union. operation int | str (types.UnionType) Union[int, str] (typing.Union) get_args(u) (int, str) (int, str) get_origin(u) types.UnionType typing.Union isinstance(x, u) ok on 3.10+ TypeError cross-form == int | str != Union[int, str] (distinct objects)
get_args() is identical for both; origin, isinstance, and object identity are not.
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).

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

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.

Bulk Union-to-pipe migration pipeline Legacy code passes through pyupgrade or ruff autofix, then unused import removal, then a strict mypy check that stays green. legacy code Optional / Union autofix ruff UP007 / UP045 or pyupgrade drop imports ruff F401 --fix mypy --strict still green
Each stage is mechanical; the final strict mypy run should report the same result as before the rewrite.

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.

Is the pipe union safe below Python 3.10? If the annotation is never evaluated at runtime the pipe form is safe from 3.7 with future annotations; if it is introspected at runtime it needs Python 3.10 or the legacy spelling. Is the annotation evaluated at runtime? no yes pure static, or from __future__ import annotations get_type_hints, pydantic, cast(), TypeVar(bound=…) X | None safe on 3.7+ need 3.10+, else keep Optional
Deferred or never-introspected annotations make the pipe safe early; runtime evaluation of the annotation is what forces 3.10+.

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.Optional is deprecated: It remains a fully supported alias for Union[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.Union and | is a checker error: A single signature may contain both, and mypy and pyright accept it without complaint. Consistency is a lint/style preference — ruff UP007/UP045 will rewrite the legacy Union/Optional form 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 raises TypeError on every version. Use isinstance(x, (int, str)) (tuple form) or isinstance(x, int | str) (Python 3.10+ only).
  • Targeting py310 while still shipping 3.9: The autofixer emits int | None, and any runtime evaluation of that annotation — get_type_hints, pydantic, a dataclass field resolve — raises TypeError on 3.9. Match target-version to your true minimum, or defer annotations with from __future__ import annotations and avoid runtime introspection.
  • Adding from __future__ import annotations and expecting isinstance to work: The future import defers annotations only; it does not make the | operator evaluate, so runtime isinstance(x, int | str) still needs 3.10+.

Mapped to their corrections, the recurring ones look like this:

Union and Optional mistakes and their corrections Passing a typing.Union to isinstance, targeting 3.10 while shipping 3.9, and thinking Optional is deprecated each map to a concrete fix. mistake fix isinstance(x, Union[int, str]) raises TypeError isinstance(x, (int, str)) or int | str on 3.10+ target py310 while shipping 3.9 runtime eval breaks match target-version to real min or keep Optional "Optional is deprecated" it is not supported alias; pick one style no runtime warning
Each frequent slip has a one-line correction.

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