Real-world data is rarely a single, uniform type ā an API might return a string or a number, a parser might produce a Success or a Failure. Union types let you express that honestly in the type system, instead of quietly typing everything as Any and losing all safety.
1From typing.Union to the | Operator
Before Python 3.10, expressing a union required from typing import Union and Union[int, str]. PEP 604 introduced the | operator as syntactic sugar directly on types, so int | str means exactly the same thing with far less ceremony ā and unlike Union[...], it also works as a genuine runtime value in some contexts (e.g. isinstance(x, int | str) is valid from 3.10 onward too).
Optional[X] is really just shorthand for Union[X, None], which is why it's now equally idiomatic to write X | None directly ā many style guides and linters (including Ruff) now flag Optional[X] in favor of the pipe syntax for consistency in codebases targeting 3.10+.
On Python versions before 3.10, you still need Union[int, str] from the typing module, or from __future__ import annotations combined with string-quoted annotations to use the pipe syntax without a runtime error ā a detail that matters a great deal when a library needs to support older interpreters.
def format_id(value: int | str) -> str:
return f"ID-{value}"
format_id(42) # OK
format_id("A7") # also OK'ID-42'
2Narrowing: Turning a Union Back Into a Specific Type
A union type is deliberately vague ā int | str tells the checker only that the value is one of the two, not which one, so calling a method that only exists on str would be flagged as unsafe before you've established which branch you're in. Narrowing is the process of proving, within a specific block of code, that the value must be one specific member of the union.
isinstance() checks are the most common narrowing tool: inside if isinstance(value, int):, mypy treats value as int for the remainder of that block, and ā because of the implicit else ā as str in whatever follows, since int | str minus int leaves only str. Equality checks against literal values (if x == "success":), is None checks, and pattern matching (covered in the next lesson) are other constructs the checker recognizes as narrowing.
Writing code this way ā checking, then acting only within the guarded branch ā is not just a type-checker satisfaction exercise. It's the same discipline that prevents AttributeError and TypeError at runtime; the type checker is simply making that discipline visible and verifiable before the code ever executes.
def double(value: int | str) -> int | str:
if isinstance(value, int):
return value * 2 # mypy knows: value is int here
return value + value # mypy knows: value is str herevalue narrowed from int | str to int
3Discriminated Unions for Modeling Outcomes
A particularly powerful pattern is the *discriminated* (or *tagged*) union: a union of types that each carry a shared literal field ā often called kind or type ā whose value uniquely identifies which variant you're holding. Modeling a Result as Success | Failure, each a small dataclass with its own kind literal, lets you branch on result.kind == "success" and have the type checker narrow result to Success inside that branch, giving you safe access to .data without a manual getattr or a try/except AttributeError.
This pattern directly replaces a common anti-pattern: returning None on failure and a value on success, which forces every caller to remember to check for None with no way for the type checker to guarantee they did. A discriminated union return type makes 'did you handle the failure case?' a question the type checker can actually answer, not just a code-review reminder.
Libraries like pydantic and attrs, and the standard library's own dataclasses, all work naturally with this pattern ā combined with match/case structural pattern matching (the next lesson), discriminated unions become one of the most ergonomic ways to model 'one of several distinct shapes of data' in modern Python.
from dataclasses import dataclass
@dataclass
class Success:
kind: str = "success"
data: dict = None
@dataclass
class Failure:
kind: str = "failure"
error: str = ""
Result = Success | FailureEach variant is a distinct, narrowable dataclass
4Step-by-Step Breakdown
Not every value has exactly one type. Python's union syntax lets you say 'this is a string OR an int' ā and actually have your tools check it.
Since Python 3.10, you write unions with the pipe operator: int | str means 'an int or a str'. No typing import needed.
Checkpoint: On Python 3.10+, what does int | str mean as a type annotation?
- āThe value is either an int or a str
- āThe value must satisfy both int and str simultaneously
Before narrowing a union, you can't safely call type-specific methods. isinstance() checks let mypy narrow the type within each branch.
Checkpoint: Why does mypy allow value * 2 inside the isinstance(value, int) branch but not before the check?
- āType narrowing ā mypy tracks that the branch guarantees value is int
- āPython itself restricts the variable at runtime after the check
Discriminated unions use a shared 'tag' field (often via dataclasses) so you can safely branch on which variant you have.
Once you can express 'one of several types', pattern matching becomes the natural way to handle each case ā that's next.
Handle a Real Union Type. Finish process_result(): isinstance() narrows a union type before handling each case.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Prefer X | None over Optional[X] on 3.10+ codebases
They are semantically identical, but the pipe syntax is now the community and tooling standard (Ruff's UP007 rule flags Optional[X] for this reason), keeping the codebase visually consistent.
Model exclusive outcomes as discriminated unions, not sentinel None returns
A Success | Failure return type forces callers to handle both cases explicitly and lets the type checker verify it, unlike a bare Optional return that's easy to silently ignore.
Frequent Bugs
Calling a type-specific method on a union value before narrowing it, then hitting an AttributeError at runtime on the branch the type checker would have caught.
Always isinstance()-check (or otherwise narrow) a union value before calling a method that only exists on one of its members, and let mypy/pyright verify every branch is covered.
Real-World Examples
Parsing an API Response That Can Be a String ID or a Full Object
A third-party API sometimes returns just a numeric ID and sometimes a full nested object for the same field, depending on a query parameter ā a common real-world API inconsistency.
def extract_user_id(value: int | dict) -> int:
if isinstance(value, dict):
return value["id"]
return value