📌

PydanticでUnion型の自動判別とexclude_unset=Trueでハマった

に公開

はじめに

FastAPIのPydanticベースのAPI開発で、Union[...].model_dump(exclude_unset=True) を使う場面がでてきたのですが、
どハマりしたので、備忘録としてまとめておきます。


問題1: Union型の自動判別は「順番依存」

PydanticのUnion型(例: Union[TextItem, ImageItem])は、データの内容から自動でどちらの型か判別してくれます。
しかし、判別ロジックは「Unionで最初に定義された型」から順にマッチを試みるため、意図しない型でインスタンス化されることがあります。

from pydantic import BaseModel
from typing import Literal, Union

class TextItem(BaseModel):
    item_type: Literal["text"]
    description: str

class ImageItem(BaseModel):
    item_type: Literal["image"]
    url: Optional[str] = None
    description: str

class ResponseModel(BaseModel):
    item: Union[TextItem, ImageItem]

このとき、Pydanticは上から順に型マッチを試みるため、仮にitem_type="image"のデータを渡しても、TextItemの構造にマッチすればそちらの型が選ばれます。

data = {
    "item": {
        "item_type": "image",
        "description": "サンプル画像"
        # url は未指定(None)
    }
}

parsed = ResponseModel(**data)
print(type(parsed.item))  # <class '__main__.TextItem'> 

解決策1: discriminatorを使う

Pydantic v1.8以降では、discriminator(判別子)を使って
「どの型か」を明示的に判別できます。

from pydantic import Field

class ResponseModel(BaseModel):
    item: Union[TextItem, ImageItem] = Field(..., discriminator="item_type")

こうすることで、item_typeの値に応じて正しい型が選ばれます。
Union型を使う場合は必ずdiscriminatorを指定しましょう。


問題2: exclude_unset=Trueでフィールドが消える

Pydanticの.model_dump(exclude_unset=True)は、「デフォルト値のまま変更されていないフィールド」を辞書化時に除外します。
Updateメソッドを実装する際には便利なのですが、Union型と組み合わせ使う際には注意が必要です。
つまり、上記の例だと、

text_item = TextItem(description="foo") # item_typeはデフォルト値
text_item.model_dump(exclude_unset=True)  # => {'description': 'foo'}  # item_typeが消える!

と、item_typeがデフォルト値のままなので、discriminatorに使うフィールドも除外されてしまいます。

このとき、Union/discriminatorで使うフィールド(item_type)も除外されることがあり、
DB保存やAPIレスポンスで「型情報が消える」→「復元時に型判別できない」
という問題が発生します。


解決策2-1: discriminatorフィールドは必ず明示的にセット

  • discriminatorで使うフィールド(例: item_type)は必ず明示的にセットする
  • exclude_unset=Trueを使う場合は、型判別に必要なフィールドが除外されていないか注意

解決策2-2: __init__で自動補完しておく

  • __init__メソッドをオーバーライドして、item_typeを自動でセットすれば、exclude_unset=Trueを回避できます。
class TextItem(BaseModel):
    item_type: Literal["text"]
    description: str

    def __init__(self, **data):
        data["item_type"] = "text"  # item_typeを自動でセット
        super().__init__(**data)

まとめ

  • Union型+discriminator指定で「型安全」な自動判別を実現
  • exclude_unset=True使用時は、型判別に必要なフィールドが除外されないよう注意
  • discriminatorに使うフィールドは
    • 明示的にセットする
    • __init__メソッドをオーバーライドして自動でセットする

参考


GitHubで編集を提案

Discussion