Pydanticで役立つかもしれないTIPS(個人的に)
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
ぶっちゃけ、まだ明確なメリットを感じられていないですが、推奨されている書き方なので取り入れていこうと思います。
数値系の役立ちそうな定義済みデータ型
数値型のフィールドを定義する際に、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 定義しているのが見えます。
文字列系の役立ちそうな定義済みデータ型
文字列型のなかでは以下の3つを紹介します。
- EmailStr, NameEmail
- Json
- CountryShortName
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]
国名として、正しい文字列を入れないとバリデーションエラーとなります。大文字小文字が違ってもダメなようですね。
以下の国際規格に基づいてバリデーションをかけていると思われます。国の情報を扱う際は利用を検討してもよいかもしれません。
@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 を使わない場合は以下のような問題があるようです。
- Pydanticはどのモデルに当てはまるかを 順番に総当たりで検証する
- フィールド構造が似ていると 判定が曖昧になる
- モデル数が増えるほど パフォーマンスが落ちる
- エラーメッセージが分かりにくい
- 「なぜこのモデルになったのか」がコードから読み取りづらい
1、2、3番目の問題は、以下の方も記事にしていました。
このように、フィールドに対して複数のモデルをUnionで定義するのは、業務で書いてるコードにも登場しているので採用するのもありだなぁ~と感じました。
まとめ
Pydanticの基本的な使い方は触れず、あくまで「自分目線で知っておくと役立ちそうな」という観点でまとめてみました。
よく使うライブラリでも、あらためてドキュメントを眺めてみるのはイイですね。
Discussion