Optional and Union Types
Overview
In Python type hinting, a `Union` indicates that a variable can be one of several distinct types. Since Python 3.10, this is written concisely using the `|` operator. def process(data: int | str): pass `Optional[T]` is just a shorthand for `Union[T, None]` (or `T | None`). It represents a value that might be of type `T` or might be `None` (missing). def get_user(user_id: int) -> str | None: pass If a box has a union type, it's like sticking two labels on it: 'Only ints OR strings go here'. `Optional` specifically warns: 'This box might be empty (`None`), so check before you use it'. When combining unions with collections, `list[int | str]` is completely different from `list[int] | list[str]`. The latter means the entire list must be strictly integers or strictly strings, which bites developers expecting a mixed list. # Trap: Expecting a mixed list but typing it as two strict lists def process_items(items: list[int] | list[str]): items.append('hello') # Type error: items might be list[int] This interaction is covered fully in generic-types.
It forces you to explicitly handle cases where data might be missing (`None`) or take multiple valid forms, preventing runtime errors like `AttributeError: 'NoneType' object has no attribute '...'`.
Where used: `FastAPI` optional query parameters, Database queries that might return no rows, `Pydantic` model fields without defaults
Why learn this
- Preventing the infamous `NoneType` errors that crash applications.
- Correctly declaring optional fields in `Pydantic` or `FastAPI` parameters.
- Making API contracts explicitly clear about when an argument is truly required.
Code walkthrough
def format_status(code: int | str | None) -> str:
if code is None:
return 'Unknown'
if isinstance(code, int):
return f'Code {code}'
return code.upper()
print(format_status(404))
Focus: if isinstance(code, int):
Aha moment
from typing import Optional, Union
print(Optional[int] == Union[int, None])
Prediction: What does this print?
Common guess: `False`, they are different things.
It prints `True`. `Optional` is not a special type behavior; it is literally just an alias for a `Union` that includes `None`.
Common mistakes
- Confusing optional type with default values: `def fetch(limit: int | None):` means the type can be `None`, but you still MUST pass an argument. def fetch(limit: int | None): pass fetch() # TypeError: missing 1 required positional argument `def fetch(limit: int | None = None):` means the argument can be omitted entirely. They are completely different concepts.
- Failing to narrow the type: If a variable is `int | str`, you cannot safely call `.upper()` on it until you narrow the type. def process(data: int | str): if isinstance(data, str): return data.upper() # Safe, type is narrowed to str return data + 1 # Safe, type is narrowed to int Static type checkers will flag missing checks as an error.
Glossary
- type hinting
- Adding labels to your code to indicate what kind of data a variable should hold, helping catch errors early. Example: `name: str`.
- Static type checkers
- Tools that analyze your code without running it to find potential errors related to data types. Example: `mypy myfile.py`.
Recall questions
- What is the modern (Python 3.10+) syntax for `Union[int, str]`?
- What is `Optional[str]` actually a shorthand for?
- What is the difference between `list[int | str]` and `list[int] | list[str]`?
Understanding checks
What does this code print?
It prints `0`. The function guards against `None` by returning `0` early, so `len(None)` is never called and no error occurs.
The function correctly handles the case where `text` is `None`, returning `0` instead of crashing when attempting to call `len(None)`.
What is wrong with this function according to static analysis tools?
It calls `.upper()` on `user_id` without checking its type first. If `user_id` is an `int`, `.upper()` will cause an `AttributeError`.
When a variable has a `Union` type, you can only safely perform operations that are valid for ALL types in the union. You must check the type (`isinstance`) to narrow it before using type-specific methods.
Practice tasks
Handle optional data
Given this working code, modify `get_display_name` so it accepts a `str` OR `None`. If `name` is `None`, it should return the string `'Guest'`. Ensure the return type hint is always a `str`.
Challenge
Type a parsing function
Write a function `parse_id` that takes an argument that is either an `int` or a `str`. If it's a `str`, try to convert it to an integer using `int()`. If the conversion fails (`ValueError`), return `None`. If it's already an `int`, return it as-is. Provide complete type hints.
Continue learning
Previous: Basic Annotations
Next: Generic Types