How to Migrate Union to | with pyupgrade and ruff

Migrating Union[X, Y] to X | Y and Optional[X] to X | None is a mechanical rewrite that pyupgrade — and ruff’s UP007/UP045 rules, which reimplement pyupgrade — can apply automatically with --py310-plus. On Python 3.10+ the new syntax works everywhere; on 3.7–3.9 it only works inside string (deferred) annotations, which is exactly what from __future__ import annotations gives you. This guide is the step-by-step: run the tool, target the right version, handle the 3.7–3.9 case, and verify with mypy.

Union-to-pipe migration routes On Python 3.10 plus the pipe syntax is fully runtime-supported; on 3.7 to 3.9 it works only as a string annotation via future annotations. Optional[int] / Union[int, str] legacy typing syntax ruff --fix (UP007 / UP045) Python 3.10+ int | None — runtime + static no __future__ needed Python 3.7–3.9 int | None — string annotation only needs from __future__ import annotations
The same rewrite; whether it runs at runtime depends entirely on your Python version.

Background: PEP 604 and which versions support it

PEP 604 introduced the X | Y operator for type unions, and Python 3.10 made it work at runtime — int | None is a real expression that builds a types.UnionType. Before 3.10, evaluating int | None at runtime raises TypeError: unsupported operand type(s). The migration is therefore safe everywhere as static syntax, but only safe at runtime on 3.10+ unless the annotation is deferred to a string. This is the central gotcha when modernizing Union and Optional types.

Step 1: pick the target version

pyupgrade and ruff both gate the rewrite behind a target version. Set it to your minimum supported Python. For pyupgrade, pass the flag; for ruff, set target-version in config.

# pyproject.toml — ruff targets the lowest Python you support
[tool.ruff]
target-version = "py310"   # enables UP007 / UP045 pipe rewrites

With py310 (or higher) ruff will rewrite Optional/Union to pipe syntax. With py39 or lower it will not perform the runtime-unsafe rewrite unless deferred annotations are in effect (see Step 4).

Step 2: run the rewrite

Standalone pyupgrade:

# pyupgrade — rewrite in place for a 3.10+ codebase
pyupgrade --py310-plus src/**/*.py

Or ruff, which carries the same rules as UP007 (Union) and UP045 (Optional):

# ruff — autofix UP007 and UP045 across the tree
ruff check --select UP007,UP045 --fix src/

Before:

# Python 3.10+, before migration
from typing import Optional, Union

def fetch_user(uid: int) -> Optional[dict[str, str]]:   # UP045
    ...

def coerce(value: Union[int, str, bytes]) -> int:        # UP007
    ...

After:

# Python 3.10+, after ruff --fix
def fetch_user(uid: int) -> dict[str, str] | None:       # UP045 applied
    ...

def coerce(value: int | str | bytes) -> int:             # UP007 applied
    ...

The unused from typing import Optional, Union import is then flagged by ruff’s F401 and removed on the next --fix pass.

Step 3: verify with mypy

The rewrite is semantically identical, so mypy should report exactly the same set of errors before and after. Run it to confirm nothing shifted:

# Python 3.10+, mypy 1.x — semantics unchanged by the rewrite
def fetch_user(uid: int) -> dict[str, str] | None:
    return None

reveal_type(fetch_user(1))   # Revealed type is "dict[str, str] | None"

If mypy now reports [unused-ignore] on a # type: ignore that used to suppress something on the old line, that’s expected churn — remove the stale ignore. A clean diff produces zero new mypy errors.

Step 4: the 3.7–3.9 route via future annotations

If your minimum is below 3.10 you cannot use pipe syntax at runtime — but you can still adopt it as a string annotation. from __future__ import annotations (PEP 563) makes every annotation in the module a lazily-evaluated string, so int | None is never evaluated at definition time and never raises.

# Python 3.7+, with deferred annotations — pipe syntax is safe as a string
from __future__ import annotations

def fetch_user(uid: int) -> dict[str, str] | None:   # stored as the string "dict[str, str] | None"
    ...

With this import at the top of the file, point ruff/pyupgrade at the same target and the rewrite becomes safe even on 3.9, because the annotations are no longer executed.

Runtime vs static analysis On Python 3.7–3.9, int | None only works because from __future__ import annotations turns the annotation into a string that is never evaluated. Code that reads annotations at runtime — Pydantic v1, dataclasses with get_type_hints(), FastAPI dependency resolution — will call typing.get_type_hints(), which evaluates that string and raises TypeError: unsupported operand type(s) for | on 3.9. Either stay on Optional[X] for those modules or upgrade to 3.10+, where the operator is real at runtime.

Edge cases

typing.get_type_hints() on 3.9. Even with deferred annotations, anything that resolves the string back to a type at runtime fails on 3.9. This is the most common production break — audit runtime-introspecting libraries before migrating sub-3.10 code.

Quoted forward references. A pre-existing string annotation like "Optional[Node]" is also rewritten by UP045 to "Node | None"; this is fine as long as the surrounding evaluation context supports it (3.10+ or deferred).

cast() and TypeVar bounds. cast(Optional[int], x) and TypeVar("T", bound=Optional[int]) evaluate their arguments at runtime, so on sub-3.10 they must keep Optional/Union even under from __future__ import annotations — future annotations only defers annotations, not arbitrary expressions.

Common mistakes

  • Targeting py310 while still shipping 3.9. ruff will emit runtime-unsafe int | None, and any runtime annotation evaluation raises TypeError. Match target-version to your true minimum.
  • Adding from __future__ import annotations but still calling get_type_hints(). The future import defers, it does not make the operator work at runtime — resolution still fails on 3.9.
  • Forgetting to remove the old import. After the rewrite, lingering from typing import Optional, Union triggers ruff F401; let --fix clean it up so the diff is complete.

FAQ

Does this change runtime behavior on 3.10+? No. On 3.10+ both Optional[int] and int | None produce equivalent union types; the rewrite is purely syntactic modernization.

Can I migrate gradually? Yes — ruff check --select UP045 --fix for only Optional, or scope the run to one package at a time. The rewrites are independent and safe to land piecemeal.

Back to Union and Optional Types