Unions
Unions are fundamentally different to all other types Pydantic validates - instead of requiring all fields/items/values to be valid, unions require only one member to be valid.
This leads to some nuance around how to validate unions:
- which member(s) of the union should you validate data against, and in which order?
- which errors to raise when validation fails?
Validating unions feels like adding another orthogonal dimension to the validation process.
To solve these problems, Pydantic supports three fundamental approaches to validating unions:
- left to right mode - the simplest approach, each member of the union is tried in order and the first match is returned
- smart mode - similar to "left to right mode" members are tried in order; however, validation will proceed past the first match to attempt to find a better match, this is the default mode for most union validation
- discriminated unions - only one member of the union is tried, based on a discriminator
Union Modes¶
Left to Right Mode¶
Note
Because this mode often leads to unexpected validation results, it is not the default in Pydantic >=2, instead union_mode='smart'
is the default.
With this approach, validation is attempted against each member of the union in their order they're defined, and the first successful validation is accepted as input.
If validation fails on all members, the validation error includes the errors from all members of the union.
union_mode='left_to_right'
must be set as a Field
parameter on union fields where you want to use it.
from typing import Union
from pydantic import BaseModel, Field, ValidationError
class User(BaseModel):
id: Union[str, int] = Field(union_mode='left_to_right')
print(User(id=123))
#> id=123
print(User(id='hello'))
#> id='hello'
try:
User(id=[])
except ValidationError as e:
print(e)
"""
2 validation errors for User
id.str
Input should be a valid string [type=string_type, input_value=[], input_type=list]
id.int
Input should be a valid integer [type=int_type, input_value=[], input_type=list]
"""
The order of members is very important in this case, as demonstrated by tweak the above example:
from typing import Union
from pydantic import BaseModel, Field
class User(BaseModel):
id: Union[int, str] = Field(union_mode='left_to_right')
print(User(id=123)) # (1)
#> id=123
print(User(id='456')) # (2)
#> id=456
- As expected the input is validated against the
int
member and the result is as expected. - We're in lax mode and the numeric string
'123'
is valid as input to the first member of the union,int
. Since that is tried first, we get the surprising result ofid
being anint
instead of astr
.
Smart Mode¶
Because of the surprising side effects of union_mode='left_to_right'
, in Pydantic >=2 the default mode for Union
validation is union_mode='smart'
.
In this mode, pydantic scores a match of a union member into one of the following three groups (from highest score to lowest score):
- An exact type match, for example an
int
input to afloat | int
union validation is an exact type match for theint
member - Validation would have succeeded in
strict
mode - Validation would have succeeded in lax mode
The union match which produced the highest score will be selected as the best match.
In this mode, the following steps are taken to try to select the best match for the input:
- Union members are attempted left to right, with any successful matches scored into one of the three categories described above.
- If validation succeeds with an exact type match, that member is returned immediately and following members will not be attempted.
- If validation succeeded on at least one member as a "strict" match, the leftmost of those "strict" matches is returned.
- If validation succeeded on at least one member in "lax" mode, the leftmost match is returned.
- Validation failed on all the members, return all the errors.
from typing import Union
from uuid import UUID
from pydantic import BaseModel
class User(BaseModel):
id: Union[int, str, UUID]
name: str
user_01 = User(id=123, name='John Doe')
print(user_01)
#> id=123 name='John Doe'
print(user_01.id)
#> 123
user_02 = User(id='1234', name='John Doe')
print(user_02)
#> id='1234' name='John Doe'
print(user_02.id)
#> 1234
user_03_uuid = UUID('cf57432e-809e-4353-adbd-9d5c0d733868')
user_03 = User(id=user_03_uuid, name='John Doe')
print(user_03)
#> id=UUID('cf57432e-809e-4353-adbd-9d5c0d733868') name='John Doe'
print(user_03.id)
#> cf57432e-809e-4353-adbd-9d5c0d733868
print(user_03_uuid.int)
#> 275603287559914445491632874575877060712
Discriminated Unions¶
Discriminated unions are sometimes referred to as "Tagged Unions".
We can use discriminated unions to more efficiently validate Union
types, by choosing which member of the union to validate against.
This makes validation more efficient and also avoids a proliferation of errors when validation fails.
Adding discriminator to unions also means the generated JSON schema implements the associated OpenAPI specification.
Discriminated Unions with str
discriminators¶
Frequently, in the case of a Union
with multiple models,
there is a common field to all members of the union that can be used to distinguish
which union case the data should be validated against; this is referred to as the "discriminator" in
OpenAPI.
To validate models based on that information you can set the same field - let's call it my_discriminator
-
in each of the models with a discriminated value, which is one (or many) Literal
value(s).
For your Union
, you can set the discriminator in its value: Field(discriminator='my_discriminator')
.
from typing import Literal, Union
from pydantic import BaseModel, Field, ValidationError
class Cat(BaseModel):
pet_type: Literal['cat']
meows: int
class Dog(BaseModel):
pet_type: Literal['dog']
barks: float
class Lizard(BaseModel):
pet_type: Literal['reptile', 'lizard']
scales: bool
class Model(BaseModel):
pet: Union[Cat, Dog, Lizard] = Field(..., discriminator='pet_type')
n: int
print(Model(pet={'pet_type': 'dog', 'barks': 3.14}, n=1))
#> pet=Dog(pet_type='dog', barks=3.14) n=1
try:
Model(pet={'pet_type': 'dog'}, n=1)
except ValidationError as e:
print(e)
"""
1 validation error for Model
pet.dog.barks
Field required [type=missing, input_value={'pet_type': 'dog'}, input_type=dict]
"""
Discriminated Unions with callable Discriminator
¶
API Documentation
In the case of a Union
with multiple models, sometimes there isn't a single uniform field
across all models that you can use as a discriminator.
This is the perfect use case for a callable Discriminator
.
from typing import Any, Literal, Union
from typing_extensions import Annotated
from pydantic import BaseModel, Discriminator, Tag
class Pie(BaseModel):
time_to_cook: int
num_ingredients: int
class ApplePie(Pie):
fruit: Literal['apple'] = 'apple'
class PumpkinPie(Pie):
filling: Literal['pumpkin'] = 'pumpkin'
def get_discriminator_value(v: Any) -> str:
if isinstance(v, dict):
return v.get('fruit', v.get('filling'))
return getattr(v, 'fruit', getattr(v, 'filling', None))
class ThanksgivingDinner(BaseModel):
dessert: Annotated[
Union[
Annotated[ApplePie, Tag('apple')],
Annotated[PumpkinPie, Tag('pumpkin')],
],
Discriminator(get_discriminator_value),
]
apple_variation = ThanksgivingDinner.model_validate(
{'dessert': {'fruit': 'apple', 'time_to_cook': 60, 'num_ingredients': 8}}
)
print(repr(apple_variation))
"""
ThanksgivingDinner(dessert=ApplePie(time_to_cook=60, num_ingredients=8, fruit='apple'))
"""
pumpkin_variation = ThanksgivingDinner.model_validate(
{
'dessert': {
'filling': 'pumpkin',
'time_to_cook': 40,
'num_ingredients': 6,
}
}
)
print(repr(pumpkin_variation))
"""
ThanksgivingDinner(dessert=PumpkinPie(time_to_cook=40, num_ingredients=6, filling='pumpkin'))
"""
Discriminator
s can also be used to validate Union
types with combinations of models and primitive types.
For example:
from typing import Any, Union
from typing_extensions import Annotated
from pydantic import BaseModel, Discriminator, Tag, ValidationError
def model_x_discriminator(v: Any) -> str:
if isinstance(v, int):
return 'int'
if isinstance(v, (dict, BaseModel)):
return 'model'
else:
# return None if the discriminator value isn't found
return None
class SpecialValue(BaseModel):
value: int
class DiscriminatedModel(BaseModel):
value: Annotated[
Union[
Annotated[int, Tag('int')],
Annotated['SpecialValue', Tag('model')],
],
Discriminator(model_x_discriminator),
]
model_data = {'value': {'value': 1}}
m = DiscriminatedModel.model_validate(model_data)
print(m)
#> value=SpecialValue(value=1)
int_data = {'value': 123}
m = DiscriminatedModel.model_validate(int_data)
print(m)
#> value=123
try:
DiscriminatedModel.model_validate({'value': 'not an int or a model'})
except ValidationError as e:
print(e) # (1)!
"""
1 validation error for DiscriminatedModel
value
Unable to extract tag using discriminator model_x_discriminator() [type=union_tag_not_found, input_value='not an int or a model', input_type=str]
"""
- Notice the callable discriminator function returns
None
if a discriminator value is not found. WhenNone
is returned, thisunion_tag_not_found
error is raised.
Note
Using the typing.Annotated
fields syntax can be handy to regroup
the Union
and discriminator
information. See the next example for more details.
There are a few ways to set a discriminator for a field, all varying slightly in syntax.
For str
discriminators:
some_field: Union[...] = Field(discriminator='my_discriminator'
some_field: Annotated[Union[...], Field(discriminator='my_discriminator')]
For callable Discriminator
s:
some_field: Union[...] = Field(discriminator=Discriminator(...))
some_field: Annotated[Union[...], Discriminator(...)]
some_field: Annotated[Union[...], Field(discriminator=Discriminator(...))]
Warning
Discriminated unions cannot be used with only a single variant, such as Union[Cat]
.
Python changes Union[T]
into T
at interpretation time, so it is not possible for pydantic
to
distinguish fields of Union[T]
from T
.
Nested Discriminated Unions¶
Only one discriminator can be set for a field but sometimes you want to combine multiple discriminators.
You can do it by creating nested Annotated
types, e.g.:
from typing import Literal, Union
from typing_extensions import Annotated
from pydantic import BaseModel, Field, ValidationError
class BlackCat(BaseModel):
pet_type: Literal['cat']
color: Literal['black']
black_name: str
class WhiteCat(BaseModel):
pet_type: Literal['cat']
color: Literal['white']
white_name: str
Cat = Annotated[Union[BlackCat, WhiteCat], Field(discriminator='color')]
class Dog(BaseModel):
pet_type: Literal['dog']
name: str
Pet = Annotated[Union[Cat, Dog], Field(discriminator='pet_type')]
class Model(BaseModel):
pet: Pet
n: int
m = Model(pet={'pet_type': 'cat', 'color': 'black', 'black_name': 'felix'}, n=1)
print(m)
#> pet=BlackCat(pet_type='cat', color='black', black_name='felix') n=1
try:
Model(pet={'pet_type': 'cat', 'color': 'red'}, n='1')
except ValidationError as e:
print(e)
"""
1 validation error for Model
pet.cat
Input tag 'red' found using 'color' does not match any of the expected tags: 'black', 'white' [type=union_tag_invalid, input_value={'pet_type': 'cat', 'color': 'red'}, input_type=dict]
"""
try:
Model(pet={'pet_type': 'cat', 'color': 'black'}, n='1')
except ValidationError as e:
print(e)
"""
1 validation error for Model
pet.cat.black.black_name
Field required [type=missing, input_value={'pet_type': 'cat', 'color': 'black'}, input_type=dict]
"""
Union Validation Errors¶
When Union
validation fails, error messages can be quite verbose, as they will produce validation errors for
each case in the union.
This is especially noticeable when dealing with recursive models, where reasons may be generated at each level of
recursion.
Discriminated unions help to simplify error messages in this case, as validation errors are only produced for
the case with a matching discriminator value.
You can also customize the error type, message, and context for a Discriminator
by passing
these specifications as parameters to the Discriminator
constructor, as seen in the example below.
from typing import Union
from typing_extensions import Annotated
from pydantic import BaseModel, Discriminator, Tag, ValidationError
# Errors are quite verbose with a normal Union:
class Model(BaseModel):
x: Union[str, 'Model']
try:
Model.model_validate({'x': {'x': {'x': 1}}})
except ValidationError as e:
print(e)
"""
4 validation errors for Model
x.str
Input should be a valid string [type=string_type, input_value={'x': {'x': 1}}, input_type=dict]
x.Model.x.str
Input should be a valid string [type=string_type, input_value={'x': 1}, input_type=dict]
x.Model.x.Model.x.str
Input should be a valid string [type=string_type, input_value=1, input_type=int]
x.Model.x.Model.x.Model
Input should be a valid dictionary or instance of Model [type=model_type, input_value=1, input_type=int]
"""
try:
Model.model_validate({'x': {'x': {'x': {}}}})
except ValidationError as e:
print(e)
"""
4 validation errors for Model
x.str
Input should be a valid string [type=string_type, input_value={'x': {'x': {}}}, input_type=dict]
x.Model.x.str
Input should be a valid string [type=string_type, input_value={'x': {}}, input_type=dict]
x.Model.x.Model.x.str
Input should be a valid string [type=string_type, input_value={}, input_type=dict]
x.Model.x.Model.x.Model.x
Field required [type=missing, input_value={}, input_type=dict]
"""
# Errors are much simpler with a discriminated union:
def model_x_discriminator(v):
if isinstance(v, str):
return 'str'
if isinstance(v, (dict, BaseModel)):
return 'model'
class DiscriminatedModel(BaseModel):
x: Annotated[
Union[
Annotated[str, Tag('str')],
Annotated['DiscriminatedModel', Tag('model')],
],
Discriminator(
model_x_discriminator,
custom_error_type='invalid_union_member', # (1)!
custom_error_message='Invalid union member', # (2)!
custom_error_context={'discriminator': 'str_or_model'}, # (3)!
),
]
try:
DiscriminatedModel.model_validate({'x': {'x': {'x': 1}}})
except ValidationError as e:
print(e)
"""
1 validation error for DiscriminatedModel
x.model.x.model.x
Invalid union member [type=invalid_union_member, input_value=1, input_type=int]
"""
try:
DiscriminatedModel.model_validate({'x': {'x': {'x': {}}}})
except ValidationError as e:
print(e)
"""
1 validation error for DiscriminatedModel
x.model.x.model.x.model.x
Field required [type=missing, input_value={}, input_type=dict]
"""
# The data is still handled properly when valid:
data = {'x': {'x': {'x': 'a'}}}
m = DiscriminatedModel.model_validate(data)
print(m.model_dump())
#> {'x': {'x': {'x': 'a'}}}
custom_error_type
is thetype
attribute of theValidationError
raised when validation fails.custom_error_message
is themsg
attribute of theValidationError
raised when validation fails.custom_error_context
is thectx
attribute of theValidationError
raised when validation fails.
You can also simplify error messages by labeling each case with a Tag
.
This is especially useful when you have complex types like those in this example:
from typing import Dict, List, Union
from typing_extensions import Annotated
from pydantic import AfterValidator, Tag, TypeAdapter, ValidationError
DoubledList = Annotated[List[int], AfterValidator(lambda x: x * 2)]
StringsMap = Dict[str, str]
# Not using any `Tag`s for each union case, the errors are not so nice to look at
adapter = TypeAdapter(Union[DoubledList, StringsMap])
try:
adapter.validate_python(['a'])
except ValidationError as exc_info:
print(exc_info)
"""
2 validation errors for union[function-after[<lambda>(), list[int]],dict[str,str]]
function-after[<lambda>(), list[int]].0
Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='a', input_type=str]
dict[str,str]
Input should be a valid dictionary [type=dict_type, input_value=['a'], input_type=list]
"""
tag_adapter = TypeAdapter(
Union[
Annotated[DoubledList, Tag('DoubledList')],
Annotated[StringsMap, Tag('StringsMap')],
]
)
try:
tag_adapter.validate_python(['a'])
except ValidationError as exc_info:
print(exc_info)
"""
2 validation errors for union[DoubledList,StringsMap]
DoubledList.0
Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='a', input_type=str]
StringsMap
Input should be a valid dictionary [type=dict_type, input_value=['a'], input_type=list]
"""