Many real functions don't return one neat type. A lookup might find a product or come back empty. A field might hold a customer ID or be missing. A parsed value might be an int when the input was numeric and a string otherwise. Python's type system handles all of this with two related constructs: Optional[X] for "X or None", and Union[A, B] for "one of several types". Since Python 3.10 (PEP 604), the | operator collapses both into a shorter syntax. This lesson covers every flavor, how to handle these types safely in code, and the patterns that keep annotations honest.
Optional[X] says a value is either an X or None. The "missing value" case shows up everywhere in Python code: dict lookups, database queries, parsed user input, function parameters that default to None.
dict.get() returns the value for a key or None if the key isn't present, so Optional[str] captures the two possible return shapes exactly. A caller who reads the annotation knows the None case must be handled before assuming the return is a string.
Optional[X] is equivalent to Union[X, None]. It's a shorthand, nothing more. The two forms compile to the same internal type and the type checker treats them identically:
Python normalizes Union[int, None] to Optional[int] because they're the same type. The name Optional exists purely for readability: it signals "this thing, or missing".
A common source of confusion with Optional. Optional[X] describes the value's type. It says nothing about whether the parameter has a default. A required parameter can be Optional, and an optional parameter (one with a default) doesn't have to be Optional.
The caller cannot omit customer_id because there's no default. But the value passed is allowed to be None. That's a different question from "is this parameter required."
The two ideas combine in the most common pattern: a parameter that defaults to None and is also typed as accepting None.
Here note is both optional (has a default) and Optional (allows None). That combination is so common that some style guides discourage Optional[X] = something_other_than_None. When the default isn't None, the type usually shouldn't include None either.
Union[A, B] describes a value that can be one of several types. Where Optional[X] is the special case of "X or None", Union is the general form for "any one of these".
A Union can hold any number of types. Union[int, str, float] says "int or str or float". Order doesn't matter; Union[int, str] and Union[str, int] are the same type to the checker.
A common use case is a function that accepts different but related input shapes. A search function that lets the caller pass either a product name or a numeric ID is a natural fit:
The Union[int, str] parameter and Optional[str] return together describe the function's contract in a single signature.
Since Python 3.10 (PEP 604), the | operator works on types directly, replacing both Optional and Union in most idiomatic code:
| Old form | New form (3.10+) |
|---|---|
Optional[str] | str | None |
Union[int, str] | int | str |
Union[int, str, float] | int | str | float |
Optional[Union[int, str]] | int | str | None |
The same find_product function in modern style:
No imports. The annotation reads left to right and matches the spoken description of the type: "int or string or none".
The | operator works in isinstance() checks too, also from Python 3.10:
isinstance(value, int | str) is shorthand for isinstance(value, (int, str)). Both work, but the pipe form lines up nicely with the annotation above.
For new code targeting 3.10+, prefer |. Libraries that still support 3.9 or older need to keep using Optional and Union, or add from __future__ import annotations to defer evaluation.
from __future__ import annotations makes all annotations strings at parse time, which means int | None works as a hint even on Python 3.9. The constraint: any runtime code that reads the annotation (like Pydantic) needs to use typing.get_type_hints() to resolve it. The same applies to forward references and any other deferred annotation.
The diagram captures the relationship. The two annotation styles produce different runtime objects (typing.Union for the old form, types.UnionType for the pipe form), but type checkers normalize them to the same logical type. Either form works; in new code, the pipe is the shorter and clearer choice.
Union is not always appropriate. A type checker can only help when the types describe what the function actually accepts. Three valid cases for Union (or |):
find_product(query: int | str) example earlier is the canonical case. There's no single shared type, and the function does different things for each branch.int | float | str depending on what it matched, for example.Result | Error where each side is a structured object.Three cases where Union is the wrong tool:
Union[int, str, bytes, list, dict] means the function is doing too much. Split it into separate functions, one per input shape.Pending | Processed (though discriminated unions, covered below, are sometimes the right answer here).Union[X, Y, Z] and Any look similar, but Union keeps the checker working: narrowing is required before calling type-specific methods. Any turns the checker off entirely. A Union written because the type was unclear usually signals Any was the intent, and something deeper is wrong.The general rule: keep unions narrow. Two or three types is fine. A union of six types is a signature smell.
A value typed X | None cannot be used as if it were just X. The type checker will reject any operation that would fail on None. The type must be narrowed first.
The standard approach is an explicit check. After the check, the checker knows the value can't be None in the relevant branch:
Inside the if customer is None: branch, the checker knows customer is None. After the early return, the checker knows it's a str, which is why .upper() is allowed without complaint.
For the common case of "use the value if it exists, otherwise fall back", the or operator and the conditional expression work well:
customer or "Guest" works because None is falsy. The constraint: empty strings and 0 are also falsy, so display_name("") returns "Guest", which may not be the intended behavior. When the type allows None and also a "real" falsy value like "" or 0, use the explicit is None check instead of or.
The walrus operator (:=, Python 3.8+) is useful for looking up a value and using it only if found, without naming it twice:
CATALOG.get(product_id) is called once. The walrus binds the result to name, and the is not None check narrows the type inside the body. Without the walrus, the alternative is either calling .get() twice or assigning on a separate line.
For deeply nested optional access, the pattern can get noisy. Five if x is not None checks in a row suggests the data model could be flatter, or that a tool like dataclasses with non-optional fields could replace the chain of optional lookups.
A union of unrelated types needs isinstance checks to tell the checker which branch applies. Inside an isinstance(value, T) block, the checker narrows the type to T:
After if isinstance(price, int):, the checker treats price as int inside that branch. After the early return, the checker knows price is float | str. After the second isinstance, it's narrowed to float. The final return line is reached only when price is a str, and the checker tracks that automatically.
The same narrowing happens with is None checks (as shown above) and with comparisons against literal values (covered next). The general name for this is "type narrowing", and it's what makes unions usable: the checker watches the control flow and removes types from the union as they are eliminated.
Literal (from typing, Python 3.8+) restricts a value to one of a set of exact constants. Combined with Union, it produces what's called a discriminated union: a union of types that each carry a tag field, where the tag tells the checker which variant the value holds.
Literal["placed", "shipped", "delivered", "cancelled"] is the type of any one of those four exact strings. A caller passing "in_transit" gets a type-checker error, because that string isn't in the literal set. At runtime nothing is enforced, but the static check catches typos before they ship.
The "discriminated" part comes in when each variant carries a tag plus extra fields:
Inside if payment.kind == "card":, the checker narrows payment from CardPayment | CashPayment to just CardPayment. This is why payment.last_four is allowed: the narrowed type has that attribute. Without the discriminator, the checker would only allow access to fields that both types share.
This pattern is the bedrock of modeling state machines, event types, and parser results. Each variant has its own structured fields, and the tag tells the type checker which one is held.
When running a type checker over Optional-heavy code, certain errors come up over and over.
Error 1: calling a method on a value that might be None.
mypy reports:
The fix is to narrow first, either by checking is None and handling it, or by raising an exception:
Error 2: assigning `None` where the type doesn't allow it.
mypy reports:
The fix is to widen the type when None is genuinely valid, or to use a different sentinel (like -1.0 or float("nan")) otherwise:
Error 3: implicit Optional from a `None` default.
In older versions of mypy this was accepted without warning; modern versions (and strict mode) reject it because the annotation says str but the default is None. The fix is to make the type honest:
The general lesson: when None is a possible value, say so in the type. The checker will then require handling it everywhere the value is used.
A union of dictionaries is a code smell, even when each variant has a Literal discriminator. The reason: dictionaries can be misspelled. payment["last_four"] versus payment["last4"] is a runtime error in waiting. Two structured-data alternatives keep the same shape but get more help from the type checker.
TypedDict describes a dict's exact keys and value types:
TypedDict is suited to JSON, database rows, or other dict-shaped data that can't be replaced with proper classes. The type checker validates keys and value types, like it would for a class.
Dataclasses are the alternative when the shape is under control and a class can be introduced. They provide attribute access, proper equality, and a constructor without extra code. The discriminated-union example earlier in this lesson used dataclasses for this reason.
The shorthand version of the trade-off: TypedDict for dict-shaped data received from outside (JSON, DB rows, third-party APIs), dataclasses for shaped data created in code under control. Both work with | unions for variants.
10 quizzes