def sensor_id(sensor: Sensor | int | None) -> int has three bars in one signature and
looks like bit arithmetic. It is not arithmetic and it is not new syntax to memorise: each bar is the
word or, and the whole annotation is one sentence — "a Sensor, or an
int, or nothing".
Unions pile up in exactly one situation: a function that is polite about what callers hand it. A station can be identified by name or by number, a reading may be missing, an option may be left unset. Each politeness adds a member to the union, and the annotation grows to match. Reading one is a two-minute skill; the interesting part is what the notation does not promise, and that is where most of this post goes.
Everything below runs on CPython 3.10, and every output block is copied from a real run.
Contents
- One bar, two types — the whole of the notation
- The
| Nonecase — what used to beOptional - Nobody checks it at runtime — annotations are documentation
- Narrowing a three-way union — the funnel pattern
- The bar builds a real object —
types.UnionType, andisinstance - Where you put the bar changes the meaning —
list[int] | Nonevslist[int | None] - Older Python —
from __future__ import annotations - Traps
- Cheat sheet
1 · One bar, two types
The whole of the notation
A | B in an annotation means a value of type A, or a value of type
B. That is the entire feature. It reads left to right, the order carries no
meaning, and it chains: A | B | C is "any one of the three".
def brightness(level: int | float) -> str:
return f"{level:.1f} lux"
print(brightness(3))
print(brightness(3.5))
3.0 lux
3.5 lux
This spelling arrived in Python 3.10 (PEP 604). Before it, the same thing was written with a helper
imported from typing, and you will still meet that spelling in any code that supports
older interpreters:
| Modern | Pre-3.10 equivalent |
|---|---|
int | float | Union[int, float] |
int | None | Optional[int] |
str | int | None | Optional[Union[str, int]] |
dict[str, float] | None | Optional[Dict[str, float]] |
They are not merely similar, they compare equal — the bar is a different way to spell the same object:
from typing import Optional, Union
print(int | None == Optional[int])
print(int | str == Union[int, str])
print(str | None == None | str) # order is not part of the meaning
True
True
True
Which explains why nobody writes the old form in new code, and why a linter will rewrite it for you.
Ruff's UP rules do it as a fix:
from typing import Optional, Union
def humidity(sensor: Optional[int]) -> Union[float, str]:
if sensor is None:
return "no sensor"
return 41.5
--- old_style.py
+++ old_style.py
@@ -1,7 +1,7 @@
from typing import Optional, Union
-def humidity(sensor: Optional[int]) -> Union[float, str]:
+def humidity(sensor: int | None) -> float | str:
if sensor is None:
return "no sensor"
return 41.5
Would fix 2 errors.
UP045 is the Optional one, UP007 the Union one.
Both are auto-fixable, so a whole codebase converts in one command once every supported interpreter
is 3.10 or newer.
2 · The | None case
"or nothing" is the common member
By far the most frequent union is X | None, because "this may be absent" is the most
frequent extra case in any real signature. None here is standing in for its type,
NoneType, which is why it can appear in a union at all:
print(type(None))
print(int | None == int | type(None))
<class 'NoneType'>
True
Put None last. int | None and None | int are the same type,
but the reader parses "an int, possibly absent" faster than "absent, or an int", and every style
guide and auto-fixer produces the former.
A parameter with a None default should say so in the annotation. The default does not
add the member for you:
def scale(factor: float = None):
# claims float, accepts None
...
def scale(factor: float | None = None):
# the annotation matches the default
...
The wrong version runs perfectly. It is a documentation bug: the annotation says the argument is
always a float while the default proves otherwise, so a reader who trusts it will skip the
None branch they needed to write. A type checker flags it; the interpreter never will.
3 · Nobody checks it at runtime
Annotations are documentation with syntax
This is the single most important fact about the notation, and the one that surprises people coming
from a compiled language. Writing int | str does not make the interpreter reject a
list. Nothing is validated on the way in, and nothing is validated on the way out.
def label(value: int | str) -> str:
return value.upper()
print(label("dry"))
print(label(7))
DRY
AttributeError: 'int' object has no attribute 'upper'
The annotation was honest — 7 really is an allowed argument — and the body was not. It
called a string method on a value the signature promised might be an integer. The union is a claim
about the caller; honouring it inside the body is the author's job, and that job is called
narrowing.
Every union member you add is a branch you owe the body. A three-member union with one code path through it is a bug waiting for the second-most-common input.
4 · Narrowing a three-way union
The funnel pattern
When a signature accepts several shapes, the usual body converts them to one shape as early as possible and then gets on with the actual work. The union is wide at the top of the function and narrow everywhere after.
sensor: Sensor | int | None three shapes in, one int out │ │ │ │ │ │ isinstance(sensor, Sensor) ▼ │ │ sensor.index │ │ (int | None) │ │ │ │ │ └────┬────┘ │ │ │ ▼ ▼ int None Sensor(None) lands here too │ │ ▼ ▼ return sensor return 0
class Sensor:
def __init__(self, index: int | None) -> None:
self.index = index
def sensor_id(sensor: Sensor | int | None) -> int:
if isinstance(sensor, Sensor):
sensor = sensor.index # Sensor | int | None -> int | None
if sensor is None:
return 0 # int | None -> int
return sensor
print(sensor_id(Sensor(4)))
print(sensor_id(9))
print(sensor_id(None))
print(sensor_id(Sensor(None)))
4
9
0
0
Two lines removed two members. The first if replaces a Sensor with the
integer inside it, so after that line the variable is int | None even though the
parameter was declared wider. The second turns the remaining None into a value, so the
return sensor at the bottom is an int and matches the return annotation. A
type checker follows this reasoning statically and will complain if a branch is missing.
sensor_id(Sensor(None)) returns 0, the same as passing nothing at all.
That is not a bug here, but it is a decision, and it is the kind of decision a wide union hides: an
object whose interior field is empty collapses onto the same branch as a missing object. If those
two cases should behave differently, the union has to be narrowed in a different order.
Ordering the checks is not narrowing by itself
isinstance and is None narrow. Falling off the end of a chain of
ifs also narrows, by elimination, and is idiomatic — but only if the members really are
exhausted:
def to_text(value: int | str) -> str:
if isinstance(value, int):
return f"#{value}"
return value.strip() # nothing left but str
print(to_text(7), to_text(" wet ") + "|")
#7 wet|
5 · The bar builds a real object
Not a special form the parser swallows
int | None is an ordinary expression evaluated at runtime. It produces an instance of
types.UnionType, it has a readable repr, and it can be stored in a
variable like any other value:
print(type(int | None))
print(int | None)
Celsius = float | int # a reusable alias
Reading = dict[str, float] | None
print(Celsius)
print(Reading)
<class 'types.UnionType'>
int | None
float | int
dict[str, float] | None
Because it is a real object, duplicates collapse and nesting flattens, which is why you never see a union of unions:
print(int | str | int)
print((int | str) | float)
print(int | None | None)
int | str
int | str | float
int | None
It works in isinstance
A genuine convenience of the new spelling: the resulting object is accepted as the second argument to
isinstance and issubclass, which Union[...] never was. It
behaves like the tuple form.
print(isinstance(3, int | float))
print(isinstance("x", int | float))
print(isinstance([1], list | None))
print(isinstance([1], list[int] | None)) # parameterized -> rejected
True
False
True
TypeError: isinstance() argument 2 cannot contain a parameterized generic
The last line is the general rule about runtime checks, not a union quirk: nothing can test
list[int] at runtime, because that would mean walking the list. Check
list and trust the annotation for the element type.
The bar between two instances is still plain __or__, and most objects do not
implement it. 3 | None raises
TypeError: unsupported operand type(s) for |: 'int' and 'NoneType'. Unions are built
from types, and a value that happens to be a type — a variable holding int — works
exactly as well as the literal.
6 · Where you put the bar changes the meaning
Outside or inside the brackets
Two annotations one character apart in appearance, and entirely different in what they permit:
| Annotation | Reads as | Accepts | Rejects |
|---|---|---|---|
list[int] | None |
a list of ints, or no list at all | [1, 2], [], None |
[1, None] |
list[int | None] |
always a list, whose items may be missing | [1, None], [] |
None |
The structure is visible at runtime — the outer construct is different in each case:
from typing import get_args, get_origin
print(get_origin(list[int] | None), get_args(list[int] | None))
print(get_origin(list[int | None]), get_args(list[int | None]))
<class 'types.UnionType'> (list[int], <class 'NoneType'>)
<class 'list'> (int | None,)
For the first, the outermost thing is the union and the list is a member. For the second, the
outermost thing is a list and the union is its element type. When a signature needs both — an
optional list of optional readings — say so: list[float | None] | None. It is a
mouthful, and that is a fair warning about the interface.
7 · Older Python
The from __future__ escape
On 3.7 through 3.9 the bar between two types raises TypeError when evaluated. Since
annotations are evaluated at function-definition time by default, that makes
def f(x: int | None) a runtime error on those versions — not a warning, a crash on
import.
from __future__ import annotations, at the top of the module, stops annotations from
being evaluated at all. They are stored as strings and only ever inspected by tools:
from __future__ import annotations
def calibrate(offset: int | None) -> float | None:
return None if offset is None else offset * 0.5
print(calibrate.__annotations__)
print(calibrate(4), calibrate(None))
{'offset': 'int | None', 'return': 'float | None'}
2.0 None
The values are strings, quotes included. Nothing ever evaluated int | None, so the
syntax is free on any version that has the future import.
The future import covers annotation positions and nothing else. An alias assignment
(Celsius = float | int), a call to isinstance(x, int | None), or anything
that resolves annotations at runtime — typing.get_type_hints, and the validation
libraries built on it — still evaluates the expression and still fails below 3.10. Those places
need Union and Optional until the floor moves.
8 · Traps
Four that survive the nicer syntax
A wide union is a cost, not a courtesy
str | int | Path | None is four branches for every caller and for the body. If
one of the members is only there because a single caller was lazy, converting at that call site is
usually cheaper than teaching every reader of the signature about a case that occurs once.
or is not the same as is None
The narrowing shortcut value or default collapses every falsy value, not just
None, and an empty string is a legitimate member of str | None:
def site_name(name: str | None) -> str:
return name or "unnamed"
def site_name_strict(name: str | None) -> str:
return "unnamed" if name is None else name
print(repr(site_name("")), repr(site_name_strict("")))
'unnamed' ''
The first discarded a caller's deliberate empty name. Same for 0 in
int | None and [] in list[str] | None — the values most likely
to matter are the falsy ones.
The return type is a union too
-> float | None is a promise that every caller must unwrap. It is the right
annotation when absence is a real outcome, and the wrong one when the function could simply raise;
returning None for failure pushes a branch onto everybody downstream, and the ones who
forget it get an AttributeError far from the cause.
Mixing the spellings in one file
Optional[int] on one line and str | None on the next both work, and both
mean what they say, but the reader now has to hold two vocabularies. Pick the modern one and let the
auto-fixer convert the rest.
Wrapping up
Cheat sheet
| You want | Write |
|---|---|
| Either of two types | int | str |
| May be absent | int | None, with None last |
| Absent by default | def f(x: int | None = None) — annotation and default agree |
| A list, or nothing | list[int] | None |
| A list with gaps in it | list[int | None] |
| A name for a recurring union | SensorRef = Sensor | int | None |
| A runtime check against a union | isinstance(x, int | float) — unparameterized members only |
| The syntax on 3.7–3.9 | from __future__ import annotations, annotation positions only |
| To convert an old codebase | ruff check --select UP007,UP045 --fix |
The short list
-
A | Bis the word "or". Order carries no meaning, chaining is just more members, andX | Noneis whatOptional[X]used to spell. - Nothing is enforced at runtime. The union constrains callers; the body still has to handle every member, and forgetting one fails later with an ordinary attribute error.
-
Narrow early. One
isinstanceand oneis Noneat the top turn a three-member parameter into a single concrete type for the rest of the function. -
It is a real object.
types.UnionType, printable, assignable to an alias, and usable inisinstance— unlike thetypingspelling. -
Bracket placement is semantic.
list[int] | Noneandlist[int | None]accept disjoint sets of values. - Every member is a branch someone maintains. Three is usually a helpful interface; six is usually a missing conversion at one call site.
Further reading
- PEP 604 — the proposal that introduced
X | Y, including why the bar was chosen - Standard types — union type:
types.UnionType, flattening, and theisinstancesupport - PEP 563 —
from __future__ import annotationsand what "postponed evaluation" does and does not cover - Typing spec — narrowing: the checks a type checker recognises as narrowing a union
- Ruff — UP007 and UP045
No comments:
Post a Comment