コンテンツにスキップ

Unions

Unionは、Pydanticが検証する他のすべてのタイプとは根本的に異なります。すべてのフィールド/アイテム/値が有効であることを要求するのではなく、Unionは1つのメンバーのみが有効であることを要求します。

これは、Unionの検証方法に関するいくつかのニュアンスにつながります。

  • Unionのどのメンバーに対して、どのような順序でデータを検証する必要がありますか?
  • 検証が失敗した場合に発生するエラー

Unionの検証は、検証プロセスに別の直交次元を追加するように感じられます。

これらの問題を解決するために、PydanticはUnionを検証するための3つの基本的なアプローチをサポートしています。

  1. left-to-right mode - 最も簡単な方法で、Unionの各メンバーが順番に試行され、最初の一致が返されます。
  1. smart mode - "左から右へのモード"と同様に、メンバーは順番に試行されます。ただし、検証は最初の一致を超えて進行し、より適切な一致を見つけようとします。これは、ほとんどのUnion検証のデフォルトモードです。
  1. 識別されたUnion - 識別子に基づいて、Unionの1つのメンバーのみが試行されます。

Tip

一般的に、discriminatedUnionを使用することをお薦めします。これらは、どのUnionのメンバーに対して検証するかを制御できるため、タグなしUnionよりもパフォーマンスが高く、予測可能です。

複雑なケースでは、タグなしUnionを使用している場合、Unionメンバーに対する検証試行の順序を保証する必要がある場合は、union_mode='left_to_right'を使用することをお勧めします。

非常に特殊な動作を探している場合は、custom validatorを使用できます。

Union Modes

Left to Right Mode

Note

このモードは予期しない検証結果をもたらすことが多いため、Pydantic>=2ではデフォルトではなく、union_mode='smart'がデフォルトです。

この手法では、Unionの各メンバーに対して、定義された順序で検証が試行され、最初に成功した検証が入力として受け入れられます。

すべてのメンバーで検証が失敗した場合、検証エラーにはUnionのすべてのメンバーからのエラーが含まれます。

union_mode='left_to_right'は、使用するUnionフィールドのFieldパラメータとして設定する必要があります。

Union with left to right mode
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]
    """

この場合、メンバーの順序が非常に重要になります。これは、上記の例を微調整して示しています。

Union with left to right - unexpected results
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
  1. 予想通り、入力はintメンバーに対して検証され、結果は予想通りです。
  1. laxモードになっていて、数値文字列'123'がUnionの最初のメンバーintへの入力として有効です。これが最初に試行されるので、idstrではなくintであるという驚くべき結果が得られます。

Smart Mode

union_mode='left_to_right'は意外な結果をもたらす可能性があるため、Pydantic>=2ではUnion検証のデフォルトモードはunion_mode='smart'です。

このモードでは、pydanticはUnionメンバからの入力に最も一致するものを選択しようとします。正確なアルゴリズムは、パフォーマンスと精度の両方を向上させるために、Pydanticのマイナーリリース間で変更される可能性があります。

Note

Pydanticの将来のバージョンでは、内部のsmartマッチングアルゴリズムを微調整する権利を留保します。非常に特殊なマッチング動作に依存している場合は、union_mode='left_to_right'またはdiscriminated unionsを使用することをお勧めします。

Smart Mode Algorithm

スマートモードアルゴリズムは、次の2つのメトリックを使用して、入力の最適な一致を判断します。

1. 有効なフィールドセットの数(モデル、データクラス、および型指定された辞書に関連) 2. 一致の正確さ(すべてのタイプに関連)

Number of valid fields set

Note

このメトリックは、Pydantic v2.8.0で導入されました。このバージョン以前は、正確さのみを使用して最適な一致を決定していました。

このメトリックは現在、モデル、データクラス、および型付き辞書にのみ関連しています。

設定された有効なフィールドの数が多いほど、一致度が高くなります。ネストされたモデルに設定されたフィールドの数も考慮されます。 これらのカウントは最上位レベルのUnionまでバブルアップされ、最も高いカウントを持つUnionメンバーが最適な一致と見なされます。

For data types where this metric is relevant, we prioritize this count over exactness. For all other types, we use solely exactness. このメトリックが関連するデータ型では、このカウントが正確さよりも優先されます。その他のすべてのデータ型では、正確さのみが使用されます。

Exactness

正確さのために、Pydanticは組合員の試合を次の3つのグループのいずれかに採点します(最高得点から最低得点まで)。

- 正確な型の一致。たとえば、float intUnion検証へのint入力は、intメンバーの正確な型の一致です。 - 検証はstrictモードで成功します。 - 検証はlaxモードで成功します。

最も高い正確さのスコアを生成したUnionマッチが、ベストマッチと見なされます。

スマートモードでは、入力に最適なものを選択するために次の手順が実行されます。

1. Union・メンバーは左から右に試行され、成功した一致は前述の3つの正確さのカテゴリーのいずれかにスコア付けされ、有効なフィールド・セット・カウントも集計されます。 2. すべてのメンバーが評価された後、"有効なフィールド・セット"カウントが最も高いメンバーが戻されます。 3. 最大の"有効フィールド・セット"カウントに対してタイがある場合、正確さのスコアがタイ・ブレーカーとして使用され、正確さのスコアが最も高いメンバーが戻されます。 4. すべてのメンバーで検証が失敗した場合は、すべてのエラーが戻されます。

1. Unionは左から右に試みられ、成功した試合は上記の3つの正確さのカテゴリーのいずれかに得点されます。 - 型が完全に一致して検証が成功した場合、そのメンバーはすぐに戻され、後続のメンバーは試行されません。 2. 少なくとも1つのメンバーで検証が"厳密な"一致として成功した場合、それらの"厳密な"一致のうち最も左のものが戻されます。 3. "lax"モードで少なくとも1つのメンバーの検証が成功した場合、一番左の一致が戻されます。 4. すべてのメンバーで検証が失敗すると、すべてのエラーが戻されます。

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

Tip

Optional[x]Union[x, None]の省略形です。

詳細については、Required fieldsを参照してください。

Discriminated Unions

識別されたUnionは、"タグ付きUnion"と呼ばれることがあります。

識別されたUnionを使用して、どのUnionのメンバーに対して検証するかを選択することで、Unionタイプをより効率的に検証することができる。

これにより、検証がより効率的になり、検証が失敗した場合のエラーの急増も回避されます。

Unionに識別子を追加することは、生成されたJSONスキーマがassociated OpenAPI specificationを実装することも意味します。

Discriminated Unions with str discriminators

多くの場合、複数のモデルを持つUnionの場合、Unionのすべてのメンバーに共通のフィールドがあり、どのUnionケースに対してデータを検証すべきかを区別するために使用できます。これは、OpenAPIでは"識別子"と呼ばれています。

その情報に基づいてモデルを検証するには、同じフィールド(my_discriminatorと呼びましょう)を、1つ(または複数)のLiteral値である識別された値を持つ各モデルに設定します。

Unionでは、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

pydantic.types.Discriminator

複数のモデルを持つUnionの場合、すべてのモデルにわたって識別子として使用できる単一の均一なフィールドが存在しないことがあります。 これは、呼び出し可能な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は、モデルとプリミティブ型の組み合わせでUnion型を検証するためにも使用できます。

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]
    """
  1. 識別子の値が見つからない場合、呼び出し可能な識別子関数がNoneを返すことに注意してください。 Noneが返された場合、このunion_tag_not_foundエラーが発生します。

Note

typing.Annotatedfields syntaxを使用すると、Unionおよびdiscriminator情報を簡単に再グループ化できます。詳細については、次の例を参照してください。

フィールドに識別子を設定する方法はいくつかありますが、構文はわずかに異なります。

str 識別子の場合:

some_field: Union[...] = Field(discriminator='my_discriminator'
some_field: Annotated[Union[...], Field(discriminator='my_discriminator')]

呼び出し可能なDiscriminatorの場合:

some_field: Union[...] = Field(discriminator=Discriminator(...))
some_field: Annotated[Union[...], Discriminator(...)]
some_field: Annotated[Union[...], Field(discriminator=Discriminator(...))]

Warning

識別されたUnionは、Union[Cat]のような単一のバリアントでのみ使用することはできません。

Pythonは解釈時にUnion[T]Tに変更するので、pydanticUnion[T]Tのフィールドを区別することはできません。

Nested Discriminated Unions

1つのフィールドに設定できる識別子は1つだけですが、複数の識別子を組み合わせたい場合があります。 これを行うには、ネストされたAnnotated型を作成します。

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]
    """

Tip

Unionのみに対してデータを検証したい場合は、標準のBaseModelから継承する代わりに、pydanticのTypeAdapter構文を使用できます。

前の例では、次のようになります。

type_adapter = TypeAdapter(Pet)

pet = type_adapter.validate_python(
    {'pet_type': 'cat', 'color': 'black', 'black_name': 'felix'}
)
print(repr(pet))
#> BlackCat(pet_type='cat', color='black', black_name='felix')

Union Validation Errors

Unionの検証が失敗した場合、エラーメッセージは非常に冗長になる可能性があります。なぜなら、エラーメッセージはUnionの各ケースに対して検証エラーを生成するからです。 これは、再帰モデルを処理する場合に特に顕著であり、再帰の各レベルで理由が生成される可能性があります。 識別された結合は、この場合のエラーメッセージを単純化するのに役立つ。検証エラーは、識別値が一致する場合にのみ生成されるからです。

以下の例に示すように、Discriminatorコンストラクタにこれらの指定をパラメータとして渡すことで、Discriminatorのエラータイプ、メッセージ、コンテキストをカスタマイズすることもできます。

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'}}}
  1. custom_error_typeは、検証が失敗したときに発生するValidationErrortype属性です。
  2. custom_error_messageは、検証が失敗したときに発生するValidationErrormsg属性です。
  3. custom_error_contextは、検証が失敗したときに発生するValidationErrorctx属性です。

各ケースにTagというラベルを付けることで、エラーメッセージを簡略化することもできます。 これは、この例のような複雑な型がある場合に特に便利です。

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]
    """