Python Union Operator Type Hint: Syntax and Behavior
python union operator type hint: Learn how the Python union operator (`|`) simplifies type hints, replaces `Union` and `Optional`, and behaves at runtime across Python...
The Union Operator Syntax in Type Hints
Python 3.10 added PEP 604, which lets you write int | str in a type hint instead of Union[int, str]. The | operator between two types produces a union type that accepts values of either type. This is the python union operator type hint syntax, and it applies anywhere annotations are accepted: function parameters, return types, variable annotations, and class attributes.
def parse_id(value: int | str) -> int: return int(value)
The function accepts either an integer or a string and converts it. Before 3.10, the same signature required Union[int, str] from the typing module:
from typing import Union def parse_id(value: Union[int, str]) -> int: return int(value)
Both annotations mean the same thing to static type checkers. The | form is shorter and reads more naturally, especially when a signature has several union types.
How | Behaves at Runtime
The union operator is not just static syntax. When Python evaluates int | str, it creates a types.UnionType object. This object supports isinstance() checks, which means you can use the same union in runtime validation:
def coerce(value: int | str) -> int: if not isinstance(value, int | str): raise TypeError(f"Expected int or str, got {type(value).__name__}") return int(value)
isinstance(value, int | str) is equivalent to isinstance(value, (int, str)). The UnionType object also supports repr() and equality comparison, so two unions built from the same types compare equal:
assert (int | str) == (int | str)
This runtime behavior differs from Union[int, str], which is a typing.Union object. Both work with isinstance(), but UnionType is the native implementation and avoids importing from typing for simple cases.
Replacing Optional With | None
The most common use of the union operator is replacing Optional. Optional[int] and int | None are equivalent; both allow an integer or None. The | None form is shorter and makes the optional nature explicit at the call site:
def find_user(user_id: int) -> dict | None: # returns a dict when found, None otherwise ...
from typing import Optional def find_user(user_id: int) -> Optional[dict]: ...
For a return type that may be missing, dict | None communicates the same contract as Optional[dict] with less visual noise. Static type checkers treat both identically, so the choice is stylistic and consistency-driven rather than semantic.
Forward References and Python Version Compatibility
The | syntax is evaluated at runtime when a module is imported. In Python 3.9 and earlier, int | str raises a TypeError because type.__or__ does not exist. If you need to use the syntax in code that runs on 3.7 through 3.9, add the future import at the top of the module:
from __future__ import annotations def parse_id(value: int | str) -> int: return int(value)
The future import converts all annotations in the module into strings, so Python never evaluates int | str at runtime. Static type checkers still parse the string annotations and understand the union. This works on 3.7 and later, but the isinstance(value, int | str) form in the previous section will not work on older versions because the future import only affects annotations, not expressions in function bodies.
Using the Union Operator With Generics and Type Aliases
The union operator composes with generic types. You can write list[int] | dict[str, int] as a parameter type, and it behaves exactly like Union[list[int], dict[str, int]]:
def merge( source: list[dict[str, int]] | dict[str, int], ) -> dict[str, int]: ...
For a type alias, assign the union to a variable and reuse it across signatures:
JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"] def serialize(value: JsonValue) -> bytes: ...
The quoted "JsonValue" is a forward reference that defers evaluation of the recursive alias. In Python 3.12 and later, the type statement provides a cleaner alias declaration:
type JsonValue = str | int | float | bool | None | list[JsonValue] | dict[str, JsonValue]
The type statement does not require quotes for the recursive reference. On older versions, the assignment form with quoted forward references is the portable approach.
Common Mistakes and Edge Cases
A frequent mistake is using the union operator with a non-type operand. int | "str" evaluates int.__or__ with a string argument and raises TypeError at runtime unless the future import defers annotation evaluation. Keep both operands as actual types or use quoted forward references consistently.
Another edge case is precedence in complex annotations. The | operator binds more tightly than = in an assignment but can be ambiguous inside a larger expression. Parenthesize when combining unions with other constructs:
def handle(value: (int | str) | None) -> None: ...
Here the parentheses make it clear that the union of int and str is itself optional. Without them, int | str | None is equivalent, but the grouping is harder to read when the annotation spans multiple lines.
Maintainability: Choosing Between | and Union
The choice between | and Union is mostly a codebase consistency decision. Use | when the project targets Python 3.10 or later, or when the future import is already present. Use Union when supporting Python 3.9 and earlier without the future import, or when a codebase already uses typing extensively and mixing both styles would be confusing.
Mixed usage is the main maintainability risk. If one module uses int | None and another uses Optional[int], readers have to recognize both forms. Pick one style per project and document it in the contribution guidelines. The runtime cost of either form is negligible because annotations are evaluated once at import time; the real cost is cognitive, not computational.