🐍

Pydanticで役立つかもしれないTIPS(個人的に)

に公開
2026/01/03

Pydanticをよく使っているので、時間のある年末年始にドキュメントを改めて見直しました。その中で見つけた、これまであまり使っていなかった便利な機能を軽くまとめてみようと思います。

あくまで、「個人的に役立ちそうな」というTIPSなので、全くPydanticを知らない人にとってはあまり役に立たないかもしれないです。

※ Pydantic v2 を前提としています

改めて基本的な使い方を

Pydanticの基本的な使い方として、モデルの各フィールドに型を定義し、それをもとにバリデーションを行います。これにより、不正なデータの混入を防ぎつつ、型安全なコードを書くことができます。

超基本的には以下のような形ですね。

from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int

Annotatedを使って制約を追加する

数値型や文字列を定義する際、

  • 「数値範囲を指定したい!」
  • 「文字列の最大文字数を指定したい!」

という状況はよくあると思います。

私は今まで基本的に、以下のように定義していました。

class F1Driver(BaseModel):
    name: str = Field(title="ドライバー名", max_length=100)
    win_count: int = Field(title="優勝回数", ge=0)

しかし、改めてドキュメントを見直すと、Python 3.9で追加された Annotated を活用した以下の形式を推奨していました。

from typing import Annotated
from pydantic import BaseModel, Field

class F1Driver(BaseModel):
    name: Annotated[str, Field(title="ドライバー名", max_length=100)]
    win_count: Annotated[int, Field(title="優勝回数", ge=0)]

Annotated を使うパターンは、以下のようなメリットがあるのかな~?と思っています。

  • Python標準の仕組みである Annotated に依存できる
  • 型定義と制約が近い位置で見える(= Field() で書くより)
  • 必要に応じて再利用も可能である(= Field() でも行けるっちゃいけるが)

3つ目に関しては以下のような形ですね。より明確に型情報であることがわかるのかな~と思ったり。

from typing import Annotated
from pydantic import BaseModel, Field

DriverName = Annotated[str, Field(title="ドライバー名", max_length=100)]
WinCount = Annotated[int, Field(title="優勝回数", ge=0)]

class F1Driver(BaseModel):
    name: DriverName
    win_count: WinCount

ぶっちゃけ、まだ明確なメリットを感じられていないですが、推奨されている書き方なので取り入れていこうと思います。

https://docs.python.org/3/library/typing.html#typing.Annotated
https://docs.pydantic.dev/latest/concepts/fields/#the-annotated-pattern

数値系の役立ちそうな定義済みデータ型

数値型のフィールドを定義する際に、int や float で定義しつつ、最大値や最小値などの制約を加えることは多いと思います。

これはまさに、Annotated を使うパターンです。しかし、Pydantic側で事前に制約付きの数値型が用意されているので紹介します。

  • 制約付き整数
    • PositiveInt
    • NegativeInt
    • NonPositiveInt
    • NonNegativeInt
  • 制約付き浮動小数
    • PositiveFloat
    • NegativeFloat
    • NonPositiveFloat
    • NonNegativeFloat

先ほどの例を使えば、以下のように書き換えられますね。まぁわかりやすいのかもしれない...

from typing import Annotated
from pydantic import BaseModel, Field, NonNegativeInt

class F1Driver(BaseModel):
    name: Annotated[str, Field(title="ドライバー名", max_length=100)]
-    win_count: Annotated[int, Field(title="優勝回数", ge=0)]
+    win_count: Annotated[NonNegativeInt, Field(title="優勝回数")]

ちなみに、これらの型の実装を見てみると、裏側でシンプルに Annotated 定義しているのが見えます。

https://github.com/pydantic/pydantic/blob/be5c601f6a8a3c82de623f1f8a120d71244c208e/pydantic/types.py#L331

文字列系の役立ちそうな定義済みデータ型

文字列型のなかでは以下の3つを紹介します。

  • EmailStr, NameEmail
  • Json
  • CountryShortName

https://docs.pydantic.dev/2.3/usage/types/string_types/

EmailStr、NameEmail

アプリケーションを作っていれば、メールアドレスを扱うことはよくあります。この時、入力された文字列がメールアドレスとして正しい形式であるかどうかを検証したい、というシチュエーションも多いかと。こういったときに使えるのが EmailStr、NameEmail になります。

まずは、EmailStr の例から。

from pydantic import BaseModel, EmailStr, Field, NameEmail


class F1Team(BaseModel):
    name: Annotated[str, Field(title="チーム名", max_length=100)]
    email: Annotated[
        EmailStr, Field(title="連絡用メールアドレス", examples=["contact@sample.com"])
    ]

このような定義時、例えばemail=muri-na-keishikiとするとエラーとなります。エラー内容は以下の通り。

pydantic_core._pydantic_core.ValidationError: 1 validation error for F1Team
email
  value is not a valid email address: An email address must have an @-sign. [type=value_error, input_value='muri-na-keishiki', input_type=str]

基本的に文字列として扱いつつ、バリデーションチェックの際に文字列がメールアドレスとして正しいかもチェックしてくれている形ですね。

続いて、 NameEmail も見てみます。

こちらは、 Taro Yamada <user@example.com> のような「表示名 + メールアドレス」形式をサポートしているようです。これはメールヘッダなどで用いられる形式ですね。

定義としては以下の形です。

class F1Team(BaseModel):
    name: Annotated[str, Field(title="チーム名", max_length=100)]
    name_email: Annotated[
        NameEmail,
        Field(title="名前とメールアドレス", examples=["redbull <contact@sample.com>"]),
    ]

こちら、挙動としては以下のようになります。


team1 = F1Team(name="Red Bull Racing", name_email="Red Bull <redbull@sample.com>")
team1.name_email.name  # "Red Bull"
team1.name_email.email  # "redbull@sample.com"

team2 = F1Team(name="McLaren", name_email="mclaren@sample.com")
team2.name_email.name  # "mclaren"
team2.name_email.email  # "mclaren@sample.com"

team3 = F1Team(name="McLaren", name_email="mclaren")  # ValidationError

名前とメールアドレスを別々のフィールドで扱って、 @property を使って組み立てた方がよいのでは、と思いつつ、有用な場面があるのかもしれないですね。

ちなみに、この2つのバリデーションは別ライブラリの「email_validator」に依存しているようです。

Json

こちらは超わかりやすいです。JSON文字列を扱う際に利用するフィールドです。

from typing import Annotated, Literal

from pydantic import BaseModel, Json

class F1Rules(BaseModel):
    season: Annotated[int, Field(title="シーズン年", ge=1950)]
    rules_json: Annotated[Json, Field(title="シーズンルールのJSONデータ")]

rules = F1Rules(season=2025, rules_json='{"min_weight": 800}')
type(rules.rules_json) #> dict

JSON文字列を受け取り、一度インスタンス化されると辞書型として扱われます。

CountryShortName

国名を検証してくれるデータ型です。ただ、こちらは以下2つのライブラリを追加しないと使うことができません。

使い方は以下の通りです。

from typing import Annotated

from pydantic import BaseModel, Field
from pydantic_extra_types.country import CountryShortName


class F1Team(BaseModel):
    name: Annotated[str, Field(title="チーム名", max_length=100)]
    factory_location: Annotated[CountryShortName, Field(title="本拠地")]


team = F1Team(name="Red Bull Racing", factory_location="Austria")
print(team.factory_location)  # > 'Austria'
print(team.factory_location.alpha2)  # > 'AT'
print(team.factory_location.alpha3)  # > 'AUT'
print(team.factory_location.numeric_code)  # > '040'


team = F1Team(name="Red Bull Racing", factory_location="AUSTRIA")
# pydantic_core._pydantic_core.ValidationError: 1 validation error for F1Team
# factory_location
#   Invalid country short name [type=country_short_name, input_value='AUSTRIA', input_type=str]


team = F1Team(name="McLaren F1 Team", factory_location="MasaraTown")
# pydantic_core._pydantic_core.ValidationError: 1 validation error for F1Team
# factory_location
#   Invalid country short name [type=country_short_name, input_value='MasaraTown', input_type=str]

国名として、正しい文字列を入れないとバリデーションエラーとなります。大文字小文字が違ってもダメなようですね。

以下の国際規格に基づいてバリデーションをかけていると思われます。国の情報を扱う際は利用を検討してもよいかもしれません。

https://www.iban.com/country-codes

@computed_field と @property の使い分け

@computed_field と @property は似た使い方をしますが、明確に違う点があるので紹介します。(@computed_field は同僚に教えていただいて以降、多用しています😀)

早速具体例をベースに説明していきます。まずは @property のパターンです。よくある、誕生日と現在の日時から年齢を算出する @property を用意します。

from datetime import date
from typing import Annotated

from pydantic import BaseModel, Field


class F1Driver(BaseModel):
    name: Annotated[str, Field(title="ドライバー名", max_length=100)]
    birthday: Annotated[date, Field(title="生年月日", example="1996-01-11")]

    @property
    def age(self) -> int:
        """満年齢を返す

        Returns
        -------
        int
            現在の満年齢
        """
        today = date.today()
        years = today.year - self.birthday.year
        if (today.month, today.day) < (self.birthday.month, self.birthday.day):
            return years - 1
        return years


driver = F1Driver(name="Yuki Tsunoda", birthday=date(2000, 5, 11))
print(driver.age)  # 25 (2026年1月1日時点)
print(driver.model_dump()) # {'name': 'Yuki Tsunoda', 'birthday': datetime.date(2000, 5, 11)}

このように、計算して算出可能なフィールドや、フィールド同士の組み合わせで構成可能なフィールドなどは、Python標準の機能でもある @property で定義していました。

@computed_field も基本的には同じような使い方をします。では、どう使い分けるのでしょうか?

@propertyと@computed_field の違いは .model_dump() (辞書型への変換)時の挙動となります。

上の例でみてもわかるように、@property で定義した age は.model_dump()の出力に含まれていません。一方で、@computed_field を利用した場合は以下のような挙動となります。

from datetime import date
from typing import Annotated

-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, computed_field


class F1Driver(BaseModel):
    name: Annotated[str, Field(title="ドライバー名", max_length=100)]
    birthday: Annotated[date, Field(title="生年月日", example="1996-01-11")]

-   @property
+   @computed_field
    def age(self) -> int:
        """満年齢を返す

        Returns
        -------
        int
            現在の満年齢
        """
        today = date.today()
        years = today.year - self.birthday.year
        if (today.month, today.day) < (self.birthday.month, self.birthday.day):
            return years - 1
        return years


driver = F1Driver(name="Yuki Tsunoda", birthday=date(2000, 5, 11))
print(driver.age)  # 25 (2026年1月1日時点)
print(driver.model_dump())
-# {'name': 'Yuki Tsunoda', 'birthday': datetime.date(2000, 5, 11)}
+# {'name': 'Yuki Tsunoda', 'birthday': datetime.date(2000, 5, 11), 'age': 25}

見ての通り、 .model_dump() 時に age も出力されていることがわかります。.model_dump_json() 時も同様の挙動となります。

この違いは、FastAPIのレスポンスモデルとして利用した際に、APIのレスポンスとしてフィールドが含まれるかどうかにも影響が出てきます。

from datetime import date
from typing import Annotated

from fastapi import FastAPI
from pydantic import BaseModel, Field, computed_field

app = FastAPI(
    title="サンプルFastAPIアプリケーション",
    version="0.1.0",
    redirect_slashes=True,
)


class F1Driver(BaseModel):
    name: Annotated[str, Field(title="ドライバー名", max_length=100)]
    birthday: Annotated[date, Field(title="生年月日", example="1996-01-11")]

    @computed_field
    def age(self) -> int:
        """満年齢を返す

        Returns
        -------
        int
            現在の満年齢
        """
        today = date.today()
        years = today.year - self.birthday.year
        if (today.month, today.day) < (self.birthday.month, self.birthday.day):
            return years - 1
        return years

# nameは受け取りますが、今回特に意味はありません。
@app.get("/drivers/{name}", response_model=F1Driver)
async def get_driver(name: str):
    sample_driver = F1Driver(name="Yuki Tsunoda", birthday=date(2000, 5, 11))
    return sample_driver

このように、@computed_field として指定したフィールドは、レスポンスとして age が返ってきていることがわかると思います。

一方で @property の場合は、レスポンスに age は含まれません。

Discriminated Unions(判別付き?ユニオン型)

Pydanticで定義した複数のモデルをまとめて扱いつつ、入力データに含まれる識別用フィールドの値によって、適切なモデルを自動選択したいときに利用します。

この説明では使いどころがイメージしづらいと思うので具体例から。

from typing import Annotated, Literal

from pydantic import BaseModel, Field


class RaceStartEvent(BaseModel):
    event_type: Literal["race_start"]
    lap: int
    # レッドフラッグ後の再スタート時にも使用されるため、ラップ数を含む

class PitStopEvent(BaseModel):
    event_type: Literal["pit_stop"]
    driver: str
    duration: float  

class RaceEvent(BaseModel):
    event_detail: Annotated[
        RaceStartEvent | PitStopEvent, Field(..., discriminator="event_type")
    ]
    timestamp: str

この例では、「F1レース中に起こる各イベントモデルを定義しつつ、全体のイベントログ用のモデルも用意する」というシチュエーションを想定しています。

これに関しては、設定しなかった場合と設定した場合での違いが分かりにくいのでドキュメントを追ってみました。discriminator を使わない場合は以下のような問題があるようです。

  1. Pydanticはどのモデルに当てはまるかを 順番に総当たりで検証する
  2. フィールド構造が似ていると 判定が曖昧になる
  3. モデル数が増えるほど パフォーマンスが落ちる
  4. エラーメッセージが分かりにくい
  5. 「なぜこのモデルになったのか」がコードから読み取りづらい

1、2、3番目の問題は、以下の方も記事にしていました。

https://zenn.dev/kicchan/articles/0013_llyssm_tips_on_type_management

このように、フィールドに対して複数のモデルをUnionで定義するのは、業務で書いてるコードにも登場しているので採用するのもありだなぁ~と感じました。

まとめ

Pydanticの基本的な使い方は触れず、あくまで「自分目線で知っておくと役立ちそうな」という観点でまとめてみました。

よく使うライブラリでも、あらためてドキュメントを眺めてみるのはイイですね。

Discussion