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.
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.
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
py310while still shipping 3.9. ruff will emit runtime-unsafeint | None, and any runtime annotation evaluation raisesTypeError. Matchtarget-versionto your true minimum. - Adding
from __future__ import annotationsbut still callingget_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, Uniontriggers ruffF401; let--fixclean 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.