65.2 Optional, Union, and the | Operator (Python 3.10+)
Right, let’s talk about giving your code some choice. Up until now, you’ve probably been hinting that a variable is one type and one type only. But we don’t live in that kind of neat, orderly world, do we? Sometimes a function argument can be a string, or it can be None. Sometimes it returns a Dog object, or a Cat object, or it might just give up and raise an Exception. This is where Optional and Union come in—your tools for honestly describing the messy reality of your code.
I’ll warn you now, there’s a “new” way to do this (introduced in Python 3.10) that is so much cleaner you’ll wonder why we ever did it the old way. We’ll cover both, because you’ll see the old way in a mountain of existing code, but you should absolutely use the new way for everything you write from now on.
Optional: It’s Just a Union in a Trench Coat
Let’s start with the most common scenario: something that can be a type T or None. This is so common it got its own special type hint: Optional.
Imagine a function that looks up a user by ID. You might find them, or you might not. The honest thing to return is either a User object or None. Here’s how you’d say that:
from typing import Optional
def find_user(user_id: int) -> Optional[User]:
"""Find a user by their ID. Returns None if the user doesn't exist."""
# ... your database lookup logic here ...
if user_exists:
return User(...)
return None
Here’s the crucial thing you need to know: Optional[User] is literally just syntactic sugar for Union[User, None]. The type checker treats them as identical. I’m not joking. The source code for it is basically this:
# This is the spirit of what typing.py does
def Optional[T]:
return Union[T, None]
So why does Optional exist? Because it communicates intent more clearly. When I see Optional[User], I immediately understand “this might be a User or it might be nothing.” When I see Union[User, None], I might have to think for half a second longer. Always prefer Optional for the Something | None case. It’s a signal to other humans (and yourself, two days later) about the intended meaning.
Union: The General Case for Choice
Optional is great, but it only handles one specific case. Union is the general-purpose tool for when something can be one of several types, and None isn’t necessarily involved.
Let’s say you’ve written a function that’s flexible about what it accepts. It’s a classic Pythonic “duck-typing” function, but we want to be explicit about the expected types.
from typing import Union
def square_anything(value: Union[int, float, str]) -> float:
"""Accepts an int, float, or a string that can be converted to a float."""
if isinstance(value, str):
value = float(value)
return value ** 2
# These are all valid!
result1 = square_anything(5) # int
result2 = square_anything(3.14) # float
result3 = square_anything("2.5") # str
The type hint Union[int, float, str] tells the type checker, “Alright, buckle up. value can be any one of these three types. Don’t be surprised.” Now, inside the function, the type checker knows that value is still a potential minefield. You can’t do value ** 2 immediately because that operation isn’t defined for strings. That’s why we have to narrow the type first using that isinstance check. This is a key interaction: Union introduces uncertainty, and you use conditional blocks (if, isinstance) to narrow the type back down to something specific and safe.
The | Operator: A Welcome Dose of Sanity
Then Python 3.10 rolled around and said, “Why are we importing all this stuff and using these clunky square brackets? This is silly.” And it gave us the union operator, |.
The | operator is pure syntactic sugar for Union. It does the exact same thing, but it’s cleaner, requires no imports, and is frankly more readable.
Let’s rewrite our previous examples using the new hotness:
# No import needed! This is built-in syntax.
def find_user(user_id: int) -> User | None:
... # Same as Optional[User]
def square_anything(value: int | float | str) -> float:
... # Same as Union[int, float, str]
# You can even use it for isinstance checks (Python 3.10+)
def handle_value(x: int | str):
if isinstance(x, int | str): # Check for multiple types at once!
print(f"It's an int or a string: {x}")
This is unequivocally the best way to write unions now. It’s cleaner, it’s faster to type, and it’s visually distinct from the generic square brackets you use for List[int] etc. You should use this everywhere you possibly can. The old typing.Union and typing.Optional are now effectively legacy code for the purposes of new type hints.
The Critical Pitfall: Is That a Duck or a Dog?
Here’s the biggest “gotcha” with Union that has bitten every single one of us. You must avoid writing code that blindly assumes a shared interface unless you’ve explicitly narrowed the type.
from typing import Union
class Duck:
def quack(self):
print("Quack!")
class Dog:
def bark(self):
print("Woof!")
def make_noise(animal: Duck | Dog) -> None:
animal.quack() # TYPE CHECKER ERROR! Dog does not have a 'quack' method.
The type checker will immediately flag this. It has no guarantee that whatever is in animal has a .quack() method. The Union type only allows operations that are common to all members of the union. Since Duck and Dog share no common methods (besides those from object), you can’t do anything with animal until you figure out what it actually is.
The correct approach is to narrow the type:
def make_noise(animal: Duck | Dog) -> None:
if isinstance(animal, Duck):
animal.quack() # Type checker now knows 'animal' is specifically a Duck
elif isinstance(animal, Dog):
animal.bark() # Type checker now knows 'animal' is specifically a Dog
This is the disciplined, safe way to handle unions. It makes your intentions explicit and your code robust. Embrace the narrowing.