Back to Blog
Python

Python Union Type Hint: Syntax and Runtime Behavior

python union type hint: How to declare union type hints in Python with Union and the | operator, including runtime behavior, version compatibility, and maintainability...

pythontype-hintstypingunion-typesstatic-analysiscode-maintainability
Diagram showing two distinct value types converging into a single union type annotation in Python.

A Python union type hint declares that a value may be one of several types. The standard form uses typing.Union:

from typing import Union def parse(value: Union[int, str]) -> str: return str(value)

Since Python 3.10, the same annotation can be written with the | operator, defined in PEP 604:

def parse(value: int | str) -> str: return str(value)

Both forms mean the same thing to a type checker. int | str accepts an integer or a string; Union[int, str] is identical. The | syntax is shorter and reads more naturally in signatures, especially when a union appears inside a larger type such as list[int | None]. The Union form remains necessary for code that must run on Python 3.9 or earlier, where evaluating int | str raises a TypeError because type.__or__ is not defined.

What a Union Type Hint Does at Runtime

Type hints are not enforced by the interpreter. A union annotation does not validate that an argument actually matches one of the declared types. The annotation is stored in the function's __annotations__ dictionary, and tools such as typing.get_type_hints() can read it, but no runtime check occurs when the function is called.

def process(item: int | str) -> None: print(item) process(3.14) # Runs without error

This surprises developers who expect the hint to act as a guard. It does not. If a function must reject values outside the declared union, the validation must be written explicitly:

def process(item: int | str) -> None: if not isinstance(item, (int, str)): raise TypeError(f"Expected int or str, got {type(item).__name__}")

The runtime cost of a union hint is effectively zero: it is metadata stored alongside the function, not a wrapper or a check. The only runtime work happens when a tool evaluates the annotation, for example when a framework calls get_type_hints() to build schemas or validate inputs.

Optional Is a Union With None

Optional[int] is exactly Union[int, None]. The two are interchangeable, and typing documents Optional[X] as shorthand for Union[X, None]. In Python 3.10+, int | None is the equivalent | form.

from typing import Optional def find_user(user_id: int) -> Optional[str]: # Returns a name, or None when the user does not exist ... def find_user_pep604(user_id: int) -> str | None: ...

The Optional name is clearer when the primary meaning is "this value may be absent." The | None form is clearer when the union is genuinely between two or more concrete types. Both are valid, and type checkers treat them identically. A common mistake is writing Optional[int, str], which is invalid; Optional takes exactly one type argument.

Python Version Compatibility and Lazy Annotations

The | operator for unions requires Python 3.10 at runtime. On Python 3.8 and 3.9, evaluating the expression int | str raises TypeError. However, if the module starts with from __future__ import annotations, all annotations are stored as strings and never evaluated at runtime, so the | syntax can be written even on older interpreters.

from __future__ import annotations def parse(value: int | str) -> str: # Valid syntax on 3.8+ with the future import return str(value)

The tradeoff appears when something evaluates the annotations at runtime. typing.get_type_hints() resolves the string annotations and evaluates them in the module's namespace. On Python 3.8 or 3.9, evaluating int | str fails, so get_type_hints() raises TypeError for that function. If your code runs on older versions and relies on runtime annotation inspection, stick with Union from typing.

Type checkers such as mypy and Pyright handle both syntaxes, but they need to know the target Python version to allow | unions. When the target version is 3.9 or earlier, mypy reports an error for int | str unless the future import is present.

When a Wide Union Signals a Design Problem

A union with many members often indicates that a function is trying to handle too many unrelated shapes:

def normalize(value: int | str | list[int] | dict[str, int] | None) -> ...: ...

Every branch in the implementation has to account for a different structure, and the type checker cannot help narrow the logic beyond the explicit isinstance checks. Before widening a union further, consider whether a protocol, a dataclass with a common interface, or a dedicated type for each case would make the contract clearer. A union of two or three closely related types is usually fine; a union that grows with every call site is a maintenance signal.

The maintainability cost is not only in the function body. Every caller sees the union in the signature, and any downstream code that consumes the result must handle every member of the union. Narrowing the return type by splitting the function or introducing a common base type reduces the branching burden across the codebase.

Common Mistakes With Union Type Hints

A frequent error is confusing a list whose elements may be either type with a value that is either one list type or another:

# A list where each element is an int or a str values: list[int | str] # Either a list of ints or a list of strings, but not mixed values: list[int] | list[str]

The first allows [1, "a", 2]; the second allows [1, 2] or ["a", "b"] but rejects a mixed list. Type checkers enforce this distinction, so choosing the wrong form produces false errors or, worse, silently permits invalid data.

Another mistake is omitting None from a union and then returning None anyway. A function annotated -> int | str that returns None in one branch will fail type checking. The fix is to include None explicitly: -> int | str | None. There is no implicit None in a union.

Runtime Type Checks With Union Types

The runtime counterpart of a union hint is isinstance with a tuple of types:

def process(item: int | str) -> None: if isinstance(item, (int, str)): print(item)

Since Python 3.10, isinstance also accepts a union type directly:

if isinstance(item, int | str): print(item)

This works because PEP 604 extended isinstance and issubclass to accept union types. On Python 3.9 and earlier, the tuple form is the only option. Note that isinstance(item, Union[int, str]) does not work in any version, because typing.Union is not a runtime type; pass a tuple or a | union instead.

When a union includes a generic type such as list[int], isinstance cannot verify the element type. isinstance(item, list[int]) raises TypeError because parameterized generics are not valid arguments to isinstance. The check must be split: verify the container type, then verify the elements if needed.

python union type hint: Practical Usage and Code Examples | RYUSLOG DEV