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.
The distinction matters because an annotation lives in two worlds. Statically, mypy and pyright parse int | None from source text regardless of interpreter version — they never execute it, so the pipe form type-checks even in a file that targets 3.8. At runtime, though, the annotation on a function is stored in __annotations__, and whether that store holds a live types.UnionType object or an inert string depends entirely on the Python version and whether PEP 563 deferred annotations are active. On 3.10+ without deferral, def f(x: int | None): ... eagerly evaluates int | None at function-definition time, producing int | None as a types.UnionType. On 3.9, that same line raises TypeError the moment the module imports.
The equivalence to the legacy forms is exact for the type checker: Optional[int], Union[int, None], and int | None all denote the same union, so the rewrite never changes what mypy or pyright infer or which errors they emit. What changes is only the concrete runtime object and the imports you need. That is why the migration is best understood as a syntactic modernization that happens to carry a runtime footgun on sub-3.10 interpreters — the semantics are frozen, only the representation moves.
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).
The target-version key is doing real semantic reasoning, not cosmetics. When you set target-version = "py39", ruff withholds UP007 and UP045 unless it sees from __future__ import annotations at the top of the file, precisely because it knows the bare rewrite would be runtime-unsafe. Setting it higher than your true minimum is the classic self-inflicted wound: declare py310 on a package that still supports 3.9, and ruff will happily emit int | None into modules that get imported under 3.9, where any eager evaluation raises. Both tools read this value from a single source, so keep it aligned with the requires-python field in your pyproject.toml:
# pyproject.toml — keep these two in agreement
[project]
requires-python = ">=3.9"
[tool.ruff]
target-version = "py39" # not py310 — matches requires-python
pyupgrade takes the version as a command-line flag rather than config (--py39-plus, --py310-plus, --py311-plus, and so on), and the flag is a floor, not an exact match: --py310-plus means “assume at least 3.10,” enabling every rewrite gated at or below that. If you drive pyupgrade through pre-commit, the flag lives in the hook’s args: list, and it is a frequent source of drift when someone bumps requires-python but forgets the hook. Ruff centralizes the setting, which is one practical reason teams migrating a large tree lean on ruff’s UP rules over standalone pyupgrade — a single target-version governs the whole ruff rule set consistently.
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.
Order of operations matters here. On the first --fix pass ruff applies UP007/UP045 and rewrites the annotations, but the from typing import Optional, Union line is still syntactically present, so F401 (unused import) does not fire until a second pass re-analyzes the now-orphaned names. Running ruff check --fix twice — or simply letting pre-commit re-run until the tree is stable — produces a clean end state. If Optional or Union is still referenced elsewhere (a cast(), a TypeVar bound, an explicit Union[...] you could not rewrite), F401 correctly leaves the import in place. pyupgrade behaves the same way but does not remove imports at all; pair it with ruff or autoflake for the cleanup:
# pyupgrade rewrites annotations but leaves the dead import; ruff prunes it
pyupgrade --py310-plus src/**/*.py
ruff check --select F401 --fix src/
One subtlety: neither tool rewrites a Union that is not used as an annotation — for example Union[int, str] passed to isinstance (illegal at runtime anyway) or stored in a variable and reused. UP007 targets annotation positions and explicit Union[...]/Optional[...] subscripts; it will not touch a dynamically built union such as functools.reduce(operator.or_, types). Review the diff rather than trusting a blanket “all unions modernized” — the tools are deliberately conservative about expressions whose runtime evaluation they cannot vouch for.
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.
Treating mypy as a differential oracle is the safest way to land the change: capture the diagnostics before the rewrite, apply it, and diff the two runs. A truly mechanical UP007/UP045 pass produces a byte-identical error set. Two categories of churn are legitimate and expected. First, [unused-ignore] warnings, because a # type: ignore[arg-type] pinned to a specific line may no longer match after the annotation shortened. Second, error line numbers shifting if the rewrite changed line lengths enough to reflow wrapped signatures — the codes and messages stay identical, only the locations move. Run mypy in a mode that surfaces stale suppressions so they are caught in the same PR:
# surface ignores that no longer suppress anything, then re-check clean
mypy --warn-unused-ignores src/
reveal_type is a useful spot check that the union survived intact, especially for nested cases where a careless rewrite might drop a member:
# Python 3.10+, mypy 1.x — confirm nesting is preserved
def lookup(key: str) -> dict[str, int] | None:
return None
reveal_type(lookup("k")) # Revealed type is "dict[str, int] | None"
reveal_type(lookup("k") or {}) # Revealed type is "dict[str, int]" (None narrowed away)
pyright deserves an independent pass if it is in your CI, because its narrowing and reachability analysis differs from mypy’s in edge cases — but for the union rewrite specifically, pyright also treats Optional[X], Union[X, None], and X | None as the same type, so a clean mypy diff almost always implies a clean pyright diff. The one place they can diverge is --warn-unreachable/reportUnreachable: if a stale # type: ignore was masking dead-code analysis, removing it can newly expose an unreachable branch. That is a real finding surfaced by the cleanup, not a regression introduced by the pipe syntax.
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.
The mechanism is worth being precise about. PEP 563 replaces eager annotation evaluation with string storage: with the future import active, def f(x: int | None): ... stores the literal string "int | None" in f.__annotations__["x"] and never runs the | operator at definition time. That is what makes the rewrite safe on 3.9 — the runtime-unsupported operator is simply never executed. Static checkers are unaffected because they read the source text, not __annotations__. The catch is that the string is a loaded gun: anything that resolves it back to a real type reintroduces the TypeError. The standard resolver is typing.get_type_hints(), which evals each annotation string in the owning module’s namespace:
# Python 3.9 with deferred annotations — safe to define, unsafe to introspect
from __future__ import annotations
def fetch_user(uid: int) -> dict[str, str] | None:
...
# The definition above is fine on 3.9. This line is not:
import typing
typing.get_type_hints(fetch_user)
# TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
This is exactly where runtime-introspecting frameworks bite. Pydantic v1 calls get_type_hints() while building a model, so a v1 model with field: int | None on 3.9 raises at class-definition time even under the future import. Pydantic v2 tolerates more via its own resolution logic but still ultimately evaluates the union, so 3.9 remains unsafe. Stdlib dataclasses are lazier — the class definition itself does not resolve hints — but the moment you call dataclasses.fields() on a dataclass and inspect .type, or a library like cattrs runs get_type_hints() over it, the string is evaluated and fails. FastAPI resolves every route handler’s parameter hints at startup to build its validation and OpenAPI schema, so a param: int | None on 3.9 breaks app import. The rule of thumb: if a runtime library needs to know your types, it evaluates the string, and the pipe operator fails below 3.10. For those modules keep Optional[X]/Union[X, Y], which evaluate fine on every version; reserve the pipe rewrite for 3.9 code whose annotations are purely for static analysis.
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
The rewrite is uniform, but the contexts it touches are not — some positions are annotations (deferrable to strings), and some are ordinary runtime expressions that no __future__ import can save on 3.9. Keeping the two straight is what separates a clean migration from a mystery TypeError in production.
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. The failure is not limited to your own get_type_hints() calls: attrs, cattrs, Pydantic, SQLModel, typer, and FastAPI all resolve hints internally, so the break can surface deep inside a dependency with a confusing traceback.
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). Because it was already a string, the rewrite does not change when it is evaluated — it was always going to be resolved lazily — so the same 3.9 caveat applies if something later calls get_type_hints() on it.
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. The same holds for any union built in a runtime position: a default value like field: Any = Optional[int], a TypeAlias assignment such as MaybeInt = int | None (the right-hand side is an expression, evaluated on import), and get_args()/get_origin() operands. ruff and pyupgrade do rewrite TypeAlias right-hand sides under UP007, which is why a module-level alias MaybeInt: TypeAlias = Union[int, None] becoming int | None can start raising on 3.9 even though it looks like “just a type.” Guard those aliases behind a version check or keep them in Union form for sub-3.10 support:
# A runtime type alias is an expression — unsafe below 3.10 as pipe syntax
import sys
from typing import TypeAlias, Union
if sys.version_info >= (3, 10):
MaybeInt: TypeAlias = int | None # evaluates fine on 3.10+
else:
MaybeInt: TypeAlias = Union[int, None] # keep legacy form for 3.9
Runtime isinstance/issubclass. isinstance(x, int | None) requires 3.10 at runtime; the future import cannot help because the call executes immediately. Keep a tuple (isinstance(x, (int, type(None)))) for sub-3.10 checks. See typing.Optional vs Union in Python 3.10+ for the full runtime-vs-static breakdown.
Common mistakes
These three slips all trace back to a single root cause — a mismatch between the target version you tell the tool and the interpreter (or runtime evaluation) that actually executes the annotation. The table pairs each mistake with the symptom it produces and the one-line fix.
- 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.