8/24/2026

A | B means either: reading union type hints in Python

Python · Type hints

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

  1. One bar, two types — the whole of the notation
  2. The | None case — what used to be Optional
  3. Nobody checks it at runtime — annotations are documentation
  4. Narrowing a three-way union — the funnel pattern
  5. The bar builds a real object types.UnionType, and isinstance
  6. Where you put the bar changes the meaning list[int] | None vs list[int | None]
  7. Older Python from __future__ import annotations
  8. Traps
  9. 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".

brightness.py
def brightness(level: int | float) -> str:
    return f"{level:.1f} lux"

print(brightness(3))
print(brightness(3.5))
$ python brightness.py
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:

ModernPre-3.10 equivalent
int | floatUnion[int, float]
int | NoneOptional[int]
str | int | NoneOptional[Union[str, int]]
dict[str, float] | NoneOptional[Dict[str, float]]

They are not merely similar, they compare equal — the bar is a different way to spell the same object:

equivalence.py
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
$ python equivalence.py
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:

old_style.py
from typing import Optional, Union


def humidity(sensor: Optional[int]) -> Union[float, str]:
    if sensor is None:
        return "no sensor"
    return 41.5
$ ruff check --select UP --target-version py310 --diff old_style.py
--- 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:

nonetype.py
print(type(None))
print(int | None == int | type(None))
$ python nonetype.py
<class 'NoneType'>
True
Convention

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:

wrong
def scale(factor: float = None):
    # claims float, accepts None
    ...
right
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.

unchecked.py
def label(value: int | str) -> str:
    return value.upper()

print(label("dry"))
print(label(7))
$ python unchecked.py
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.

Consequence

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
narrow.py
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)))
$ python narrow.py
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.

Read the fourth line of output

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:

elimination.py
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  ") + "|")
$ python elimination.py
#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:

runtime_object.py
print(type(int | None))
print(int | None)

Celsius = float | int                    # a reusable alias
Reading = dict[str, float] | None
print(Celsius)
print(Reading)
$ python runtime_object.py
<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:

flatten.py
print(int | str | int)
print((int | str) | float)
print(int | None | None)
$ python flatten.py
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.

isinstance_union.py
print(isinstance(3, int | float))
print(isinstance("x", int | float))
print(isinstance([1], list | None))
print(isinstance([1], list[int] | None))    # parameterized -> rejected
$ python isinstance_union.py
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.

Types only

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:

AnnotationReads asAcceptsRejects
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:

nesting.py
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]))
$ python nesting.py
<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:

future_import.py
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))
$ python future_import.py
{'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.

Only annotations

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:

falsy.py
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("")))
$ python falsy.py
'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 wantWrite
Either of two typesint | str
May be absentint | None, with None last
Absent by defaultdef f(x: int | None = None) — annotation and default agree
A list, or nothinglist[int] | None
A list with gaps in itlist[int | None]
A name for a recurring unionSensorRef = Sensor | int | None
A runtime check against a unionisinstance(x, int | float) — unparameterized members only
The syntax on 3.7–3.9from __future__ import annotations, annotation positions only
To convert an old codebaseruff check --select UP007,UP045 --fix

The short list

  • A | B is the word "or". Order carries no meaning, chaining is just more members, and X | None is what Optional[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 isinstance and one is None at 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 in isinstance — unlike the typing spelling.
  • Bracket placement is semantic. list[int] | None and list[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

No comments:

Post a Comment