Pydantic v2 の深層 — 検証、シリアライズ、カスタム validator
FastAPI の核である Pydantic を独立した章で掘り下げます。v2 の性能と API の変化、model_validator/field_validator の使いどころ、シリアライズ制御、JSON Schema 生成までを扱います。
第23章 ルーティング、Pydantic モデル、依存性注入 で Pydantic の基本を見ました。本章ではそこから一段深く入り、検証 / シリアライズの正確なライフサイクル、カスタム validator / serializer のパターン、JSON Schema 統合、そして見落としやすい落とし穴まで扱います。
本章のパターンは 第25章 DB 連携 の ORM オブジェクト ↔ Pydantic 変換、第29章 総合実習 — TODO API を完成させる のドメインスキーマ設計、第31章 logging と観測性 の PII マスキングシリアライズで再び登場します。ここを先に整理しておくと、本書の後半を読む負担が少し軽くなります。
v1 → v2 — なぜマイグレーションするのか #
Pydantic v2 (2023 リリース) は v1 と同じライブラリですが 事実上、別の使用方式 に変わりました。主要な違い:
| 領域 | v1 | v2 |
|---|---|---|
| コア | Pure Python | Rust (pydantic-core) |
| 性能 | 1× | 5〜50× 高速 |
| 検証メソッド | @validator | @field_validator + @model_validator |
| シリアライズ | .dict()、.json() | .model_dump()、.model_dump_json() |
| 設定 | class Config: | model_config = ConfigDict(...) |
| Generic | 限定的 | PEP 695 フレンドリー |
| union 分岐 | 最初のマッチ | discriminated union または smart mode |
v1 → v2 の自動変換ツールがあります (bump-pydantic)。ただしユーザー定義 validator 部分は手で移すのが安全です。
旧 v1 API は v2 で deprecated 警告 とともにしばらく動作します。新しいコードは無条件に v2 API。
BaseModel vs dataclass vs TypedDict — 選択基準
#
本書でデータの形を表現する道具は 3 つあります。
| 道具 | 位置 | 向く場面 |
|---|---|---|
@dataclass | 第8章 | 内部ドメインモデル、軽量、検証ほぼなし |
TypedDict | 第9章 | 外部 dict (JSON レスポンスなど) の形を宣言、ランタイムでは普通の dict |
pydantic.BaseModel | 本章 | 外部入力の検証、シリアライズ / デシリアライズ、FastAPI 統合 |
選択基準:
- 入力検証が必要か? → BaseModel
- JSON 変換が頻繁か? → BaseModel
- 純粋な内部データ、変換 / 検証なし? → dataclass
- 外部 dict の形だけ宣言、ランタイムコスト 0? → TypedDict
FastAPI ルートの入出力はほぼ常に BaseModel。ORM モデルは SQLAlchemy Mapped[T] (第25章)、レスポンスとして送るときに BaseModel でもう一度包みます (from_attributes=True)。
検証ライフサイクル — 一枚絵 #
User.model_validate({"name": "curtis", "age": 30}) 一行が実行されるときに起こること:
入力 dict
│
▼
1. @model_validator(mode="before") 実行
├─ 入力を整形できる (raw dict 段階)
▼
2. 各フィールドの type 変換 + Field() constraint 検証
├─ ge / le / min_length / pattern など
▼
3. 各フィールドの @field_validator(mode="before") 実行
▼
4. 各フィールドの @field_validator(mode="after") 実行
▼
5. BaseModel インスタンス生成
▼
6. @model_validator(mode="after") 実行
├─ フィールド間の関係検証
▼
完成したインスタンスこの流れが頭に入っていると、「自分の validator はどこに置くべきか」が自然に決まります。
@field_validator — 単一フィールド単位
#
from pydantic import BaseModel, field_validator
class TodoCreate(BaseModel):
title: str
tags: list[str] = []
@field_validator("title")
@classmethod
def title_trim_and_check(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("title は空白だけにはできません")
if "<" in v or ">" in v:
raise ValueError("HTML タグは禁止")
return v
@field_validator("tags")
@classmethod
def tags_lowercase(cls, v: list[str]) -> list[str]:
return [t.lower() for t in v]ルール:
@classmethodが必須 (v2 は明示的)- 戻り値が 変換された値 — 単純な検証だけなら
return v - ValueError / TypeError / AssertionError を投げると検証失敗として捕まる
mode="before" — type 変換前
#
class Event(BaseModel):
timestamp: int
@field_validator("timestamp", mode="before")
@classmethod
def parse_iso(cls, v):
if isinstance(v, str):
from datetime import datetime
return int(datetime.fromisoformat(v).timestamp())
return vmode="before" は type 変換前 に呼ばれます。入力が "2026-05-17T12:00:00" のような文字列でも、int に変換する前に割り込めます。
デフォルトの mode="after" は type 変換が終わった後 — v: int が保証されます。
@model_validator — モデル全体
#
複数フィールドの関係を検証するとき。
from pydantic import BaseModel, model_validator
from typing import Self
class DateRange(BaseModel):
start: datetime
end: datetime
@model_validator(mode="after")
def check_order(self) -> Self:
if self.start > self.end:
raise ValueError("start が end より遅い")
return selfmode="after" (デフォルト) はすべてのフィールドが埋まった後、self を受け取って返します。Self 型が正確さを保証 (第20章 typing 高度 の Self)。
mode="before" — raw 入力の整形
#
class FlexibleInput(BaseModel):
name: str
age: int
@model_validator(mode="before")
@classmethod
def normalize(cls, data):
if isinstance(data, str):
# 文字列で受け取っても動作するように
name, age = data.split(",")
return {"name": name.strip(), "age": int(age)}
return data外部から多様な形の入力を受け取り、標準 dict に正規化するパターン。ユーザー入力 / レガシーシステム統合に有用。
field vs model validator — 選択 #
| やること | どちら |
|---|---|
| 単一フィールドの形式 / 値の検証 | @field_validator |
| 単一フィールドの変換 (strip、lowercase) | @field_validator |
| 2 つのフィールドの関係検証 (start ≤ end) | @model_validator(mode="after") |
| 入力自体の形変換 | @model_validator(mode="before") |
シリアライズ — model_dump / model_dump_json
#
検証の反対方向: BaseModel インスタンス → dict / JSON。
user = User(name="curtis", age=30, password="secret")
user.model_dump()
# {"name": "curtis", "age": 30, "password": "secret"}
user.model_dump_json()
# '{"name": "curtis", "age": 30, "password": "secret"}'exclude / include
#
user.model_dump(exclude={"password"})
# {"name": "curtis", "age": 30}
user.model_dump(include={"name"})
# {"name": "curtis"}
# ネスト
order.model_dump(exclude={"items": {"__all__": {"price"}}})
# items の各要素から price だけを除外exclude_unset / exclude_defaults / exclude_none
#
class TodoUpdate(BaseModel):
title: str | None = None
done: bool | None = None
upd = TodoUpdate(title="新しいタイトル")
upd.model_dump() # {"title": "新しいタイトル", "done": None}
upd.model_dump(exclude_unset=True) # {"title": "新しいタイトル"} ← 明示しなかったフィールドを除外
upd.model_dump(exclude_none=True) # {"title": "新しいタイトル"} ← None 値を除外
upd.model_dump(exclude_defaults=True) # {"title": "新しいタイトル"} ← デフォルト値を除外第23章 の PATCH パターンが exclude_unset=True を使う理由。クライアントが明示したフィールドだけを更新。
@field_serializer — カスタムシリアライズ
#
特定フィールドのシリアライズ形式を制御。
from pydantic import BaseModel, field_serializer
from datetime import datetime
class Event(BaseModel):
name: str
occurred_at: datetime
@field_serializer("occurred_at")
def serialize_dt(self, dt: datetime) -> str:
return dt.strftime("%Y-%m-%d %H:%M:%S KST")デフォルトの ISO フォーマットの代わりにカスタム文字列で出力。
@model_serializer — モデル全体のシリアライズ
#
from pydantic import BaseModel, model_serializer
class Coordinates(BaseModel):
lat: float
lng: float
@model_serializer
def serialize(self) -> str:
return f"{self.lat},{self.lng}"
c = Coordinates(lat=37.5, lng=127.0)
c.model_dump() # "37.5,127.0"モデル全体を dict ではなく別の形に変換。外部 API が独特な形式を要求するとき。
PII マスキング — 運用パターン #
パスワード、カード番号のような機微情報は ログ / レスポンスの両方で保護 する必要があります。
from pydantic import BaseModel, field_serializer, SecretStr
class User(BaseModel):
email: str
password: SecretStr # 自動で '*' 表示
card_number: str
@field_serializer("card_number")
def mask_card(self, v: str) -> str:
return f"****-****-****-{v[-4:]}"SecretStr はビルトインのマスキング型 — repr / dump で自動的に '**********' と表示され、.get_secret_value() でのみ原本にアクセス。第31章 logging と観測性 でこのパターンを運用環境のロギングと結びつけて扱います。
Field() — フィールドメタデータのすべて
#
第23章 で簡単に見た Field() のオプションを一か所に整理します。
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from typing import Annotated
class Product(BaseModel):
# 検証制約
price: int = Field(ge=0, le=1_000_000)
name: str = Field(min_length=1, max_length=200)
sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
tags: list[str] = Field(min_length=1, max_length=10)
# デフォルト値
stock: int = Field(default=0)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
# エイリアス (入力時の別名)
internal_id: int = Field(alias="id")
# OpenAPI ドキュメント化
description: str = Field(
default="",
description="商品説明 (Markdown 許可)",
examples=["美味しいりんご 1 箱"],
)
# deprecated
legacy_code: str | None = Field(default=None, deprecated=True)
# 除外表示 (他のツールが読む)
internal_note: str = Field(default="", exclude=True)Annotated パターン — メタデータの分離
#
from typing import Annotated
Price = Annotated[int, Field(ge=0, le=1_000_000)]
SKU = Annotated[str, Field(pattern=r"^[A-Z]{3}-\d{4}$")]
class Product(BaseModel):
price: Price
sku: SKU同じ制約を複数のモデルで再利用するときに有用。第20章 typing 高度 の Annotated と同じパターン。
ConfigDict — モデル単位の設定
#
v1 の class Config: は v2 で model_config = ConfigDict(...) に。
from pydantic import BaseModel, ConfigDict
class User(BaseModel):
model_config = ConfigDict(
# 1. ORM オブジェクトから属性として読み取り (SQLAlchemy など)
from_attributes=True,
# 2. すべてのフィールドを strict に (自動 type 変換を切る)
strict=True,
# 3. イミュータブル
frozen=True,
# 4. 未定義フィールドの扱い
extra="forbid", # 追加フィールドならエラー (安全)
# extra="allow", # 許可 (緩い)
# extra="ignore", # 無視 (デフォルト)
# 5. エイリアスと元の名前の両方を許可
populate_by_name=True,
# 6. 文字列入力を自動 strip
str_strip_whitespace=True,
)
id: int = Field(alias="user_id")
name: strstrict=True — 自動変換を切る
#
Pydantic はデフォルトで 緩く 変換します。int フィールドに "30" を送ると 30 に変換。strict=True がそれを止めて 正確な型だけ を受け取ります。
class Loose(BaseModel):
age: int
class Strict(BaseModel):
model_config = ConfigDict(strict=True)
age: int
Loose(age="30") # OK → age=30
Strict(age="30") # ✗ ValidationError運用で入力形式を厳格に制御したいときに。
extra="forbid" — 未知のフィールドを遮断
#
class User(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
User(name="curtis", admin=True)
# ✗ ValidationError: Extra inputs are not permittedAPI 入力検証で「タイプミス / 意図しないフィールドを早く捕まえたいとき」に有用です。デフォルトは "ignore" — 未知のフィールドを無視します。
RootModel — コレクション自体をモデルに
#
from pydantic import RootModel
class TagList(RootModel[list[str]]):
pass
t = TagList.model_validate(["python", "fastapi"])
t.root # ["python", "fastapi"]
t.model_dump_json() # '["python","fastapi"]'ルートが dict / list の JSON 入力を受け取るとき。FastAPI ルートが list[str] をそのまま受け取れば自動処理されますが、検証ロジック / メソッドを付けたければ RootModel。
Generic モデル — 再利用可能なレスポンス #
from pydantic import BaseModel
from typing import Generic, TypeVar
T = TypeVar("T")
class Paginated(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
class TodoOut(BaseModel):
id: int
title: str
@router.get("/todos", response_model=Paginated[TodoOut])
def list_todos(): ...第9章 typing 本格 の Generic + Pydantic。Python 3.12+ の新文法 (class Paginated[T](BaseModel):) も動作します。
Discriminated Union — 正確な分岐 #
異なる形のモデルが 1 つの union に入っているとき、どのモデルか を 1 つのキーで識別。
from pydantic import BaseModel, Field
from typing import Literal, Annotated
class ClickEvent(BaseModel):
type: Literal["click"]
x: int
y: int
class KeyEvent(BaseModel):
type: Literal["key"]
code: str
Event = Annotated[ClickEvent | KeyEvent, Field(discriminator="type")]
class Payload(BaseModel):
event: Event
Payload.model_validate({"event": {"type": "click", "x": 10, "y": 20}})
# event は自動的に ClickEvent に分岐discriminator="type" があると Pydantic は そのキーだけを見て 正確なモデルを選択。より速く、JSON Schema も正確。
第9章 typing 本格 の discriminated union パターンが Pydantic でどう活用されるかを示す箇所です。第13章 パターンマッチ深層 の match-case と組み合わせると、入力 → 検証 → 分岐までが自然につながります。
JSON Schema 生成 — OpenAPI 統合 #
Pydantic モデルは自動的に JSON Schema を作れます。
class TodoCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
done: bool = False
print(TodoCreate.model_json_schema())
# {
# "properties": {
# "title": {"type": "string", "minLength": 1, "maxLength": 200},
# "done": {"type": "boolean", "default": false}
# },
# "required": ["title"]
# }FastAPI はすべてのルートの入出力モデルをこのメソッドで変換して OpenAPI 仕様に入れます。Swagger UI の「Schema」セクション、クライアント自動生成ツール (openapi-generator など) はすべてこの出力を活用。
Examples と Field(examples=...)
#
class User(BaseModel):
email: str = Field(examples=["alice@example.com"])
age: int = Field(examples=[30, 42])
model_config = ConfigDict(
json_schema_extra={
"examples": [
{"email": "alice@example.com", "age": 30},
{"email": "bob@example.com", "age": 42},
]
}
)Swagger UI の「Try it out」に予め埋められた例が入り、ユーザー体験がよくなります。
よくある落とし穴 #
1) ミュータブルなデフォルト値 #
class A(BaseModel):
items: list[str] = [] # ⚠ 実は安全 — Pydantic が処理してくれるPydantic は dataclass と異なり ミュータブルなデフォルト値を自動的にコピー してくれます。v2 では [] をそのまま書いても安全です。ただし default_factory を明示すれば意図がより明確になります。
class A(BaseModel):
items: list[str] = Field(default_factory=list)2) __init__ のオーバーライド
#
class User(BaseModel):
name: str
name_lower: str
def __init__(self, **data):
super().__init__(**data)
self.name_lower = self.name.lower()__init__ オーバーライドは検証ライフサイクルを迂回します。@model_validator または computed field で 解いてください。
from pydantic import BaseModel, computed_field
class User(BaseModel):
name: str
@computed_field
@property
def name_lower(self) -> str:
return self.name.lower()computed_field がレスポンスのシリアライズに含まれる動的フィールドを作ります。
3) Forward reference と self-referencing #
class Node(BaseModel):
name: str
children: list["Node"] = []
# Python 3.12+ ではそのまま動作
# 3.11 以下は最後に .model_rebuild() が必要ツリー構造などでよく出会うパターン。forward reference が解けなければ Node.model_rebuild() 一行で強制再構成。
4) Discriminated union なしの union #
class Payload(BaseModel):
item: ItemA | ItemB | ItemC
# Pydantic が各モデルを順に試す — 遅い + 曖昧3 つのモデルを一つずつ試して最初のマッチを選択。入力の形が似ていると誤ったモデルにマッチする可能性があり、試行自体のコストも大きい。
Payload = Annotated[ItemA | ItemB | ItemC, Field(discriminator="kind")]SQLAlchemy モデルとの変換 — from_attributes
#
第25章 DB 連携 で ORM オブジェクトをレスポンスモデルに変換するとき。
class TodoOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
done: bool
# SQLAlchemy モデル
todo_orm = await db.get(Todo, 1)
# 変換
todo_out = TodoOut.model_validate(todo_orm)from_attributes=True が dict ではなく オブジェクトの属性アクセス でデータを読ませます (todo_orm.title など)。FastAPI の response_model=TodoOut も内部的にこのメカニズム。
次の章につながる例 #
本章のすべてのパターンが 第29章 総合実習 — TODO API を完成させる のスキーマ設計で一つの束として使われます。
from pydantic import BaseModel, ConfigDict, Field, field_validator, computed_field
from typing import Annotated, Literal
from datetime import datetime
Priority = Annotated[int, Field(ge=1, le=5)]
class TodoBase(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
title: str = Field(min_length=1, max_length=200)
description: str = ""
priority: Priority = 3
tags: list[str] = Field(default_factory=list)
@field_validator("tags")
@classmethod
def lowercase_tags(cls, v: list[str]) -> list[str]:
return list({t.lower() for t in v}) # 重複除去 + 小文字化
class TodoCreate(TodoBase):
pass
class TodoUpdate(BaseModel):
title: str | None = None
done: bool | None = None
priority: Priority | None = None
class TodoOut(TodoBase):
model_config = ConfigDict(from_attributes=True)
id: int
done: bool
created_at: datetime
updated_at: datetime
@computed_field
@property
def is_overdue(self) -> bool:
# 仮ロジック — 実際は due_date フィールドを見る形
return Falseこのコードが 第29章 の出発スキーマになります。
練習問題 #
TodoCreateモデルに@field_validator("title")で (1) strip、(2) 空ならば ValueError、(3) HTML タグ (<、>) 禁止の 3 つを 1 つの validator にまとめてください。@model_validator(mode="after")でpriority > 4 なら title に "緊急" 必須検証を追加します。User(email, password)モデルでpasswordをSecretStrで受け取り、model_dump()の出力でパスワードが自動マスキングされるかを確認します。card_number: strフィールドを追加して@field_serializerで末尾 4 文字だけを見せるようにしてください。discriminated unionパターンでClickEvent/KeyEvent/ScrollEventの 3 つのモデルを作り、EventをAnnotated[..., Field(discriminator="type")]で定義します。JSON 入力でtypeキーだけを見て正確なモデルに分岐するか、model_json_schema()出力のoneOfが正確かを確認します。
一行まとめ: v2 は Rust コアで 5〜50× 高速、API は v1 と異なる —
@field_validator/@model_validator、model_dump、ConfigDict。検証ライフサイクルは mode=‘before’ → type 変換 → mode=‘after’ の 6 段階。フィールド単位は@field_validator、モデル全体は@model_validator。シリアライズはmodel_dump(exclude=..., exclude_unset=...)+@field_serializer/@model_serializer。SecretStrで PII 自動マスキング。ConfigDict(strict=True, extra="forbid", from_attributes=True)が運用オプション。Discriminated union で高速・正確な分岐。ORM ↔ Pydantic はfrom_attributes=True。
次の章 #
次の 第25章 DB 連携 — SQLAlchemy 2.x + Alembic で本章の Pydantic パターン (from_attributes=True など) が ORM オブジェクトと結びつきます。本章のスキーマ設計は再び 第29章 総合実習 — TODO API を完成させる の出発点になります。