Optional, Union & the | Operator
Unions express values that may take multiple types. The X | Y syntax (3.10+) replaces Union[X, Y]; nullable types are unions with None.
Recipe
def parse_count(raw: str | None) -> int:
if raw is None:
return 0
return int(raw)
def format_id(value: int | str) -> str:
return str(value)When to reach for this:
- Parsing optional config and env vars
- Functions returning "value or missing"
- Accepting multiple input representations
- Modeling success/failure without exceptions
Working Example
from dataclasses import dataclass
@dataclass
class User:
id: int
email: str | None
def email_domain(user: User) -> str | None:
if user.email is None:
return None
return user.email.split("@", 1)[1]
def describe(value: int | str | None) -> str:
match value:
case None:
return "missing"
case int(n):
return f"number {n}"
case str(s):
return f"text {s}"
def coalesce(*values: str | None) -> str:
for v in values:
if v is not None:
return v
return ""
if __name__ == "__main__":
print(describe(42))
print(email_domain(User(1, None)))What this demonstrates:
is None/is not Nonenarrows optional types for checkersmatch/casewith typed patterns refines unionsstr | Nonein variadic args for fallback chains- Returning
Noneexplicit in return type
Deep Dive
How It Works
- Union - Value may be any member type; checker requires handling all cases.
- Narrowing - Control flow refinements shrink union to specific member.
- Optional alias -
Optional[T]meansT | None- still valid,|preferred style. - Exhaustiveness - mypy
--strictwarns on unhandled union members in match. - TypeGuard - Functions that narrow types for callers (advanced).
Narrowing Tools
| Tool | Example |
|---|---|
is None | Optional -> T |
isinstance | Union members |
match/case | Structural + types |
assert | Checker-only hint |
Python Notes
from typing import TypeGuard
def is_str_list(val: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)Gotchas
- Truthy check vs None -
if email:treats empty string as missing differently fromis None. Fix: Pick explicit None vs falsy semantics. - Optional overuse - Everything
T | Noneobscures required fields. Fix: Required vs optional model fields separated. - Union order - Generally irrelevant; checkers treat symmetrically.
- Returning Any from union branch - Widens result. Fix: Consistent return type per branch.
- JSON null - Maps to Python None - validate at boundary with Pydantic.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Result/Either types | Explicit error channel | Exceptions idiomatic |
Literal | Fixed set of values | Open-ended types |
| exceptions | Truly exceptional missing | Expected optional field |
| sentinel object | Distinguish missing vs None | Simpler Optional suffices |
FAQs
Optional or | None?
Same meaning - prefer str | None in modern Python 3.10+ codebases.
how narrow union?
isinstance, match, equality to None, or TypeGuard functions.
Union in isinstance?
isinstance(x, (int, str)) valid at runtime; checker narrows accordingly.
optional dataclass field?
field: str | None = None or Optional default None.
PEP 604 | with None?
int | None replaces Optional[int] - cleaner reading.
exhaustive match?
Include case _: or mypy may warn on unhandled union under strict settings.
union of models?
User | Admin with shared Protocol or base class for common fields.
None in JSON API?
Pydantic Optional fields map null; document OpenAPI nullable carefully.
assert for checker?
assert x is not None narrows for mypy - runtime no-op if optimization removes asserts (-O).
too many unions?
Refactor to base class, Protocol, or discriminated union with literal tag field.
Related
- Type Hints Basics - fundamentals
- Literal, Final & Annotated - precise unions
- TypedDict & NamedTuple - optional keys
- Control Flow - match narrowing
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.