目次
24 章

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 と同じライブラリですが 事実上、別の使用方式 に変わりました。主要な違い:

領域v1v2
コアPure PythonRust (pydantic-core)
性能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 — 単一フィールド単位 #

@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 変換前 #

mode='before'
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 v

mode="before"type 変換前 に呼ばれます。入力が "2026-05-17T12:00:00" のような文字列でも、int に変換する前に割り込めます。

デフォルトの mode="after" は type 変換が終わった後 — v: int が保証されます。

@model_validator — モデル全体 #

複数フィールドの関係を検証するとき。

@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 self

mode="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 — カスタムシリアライズ #

特定フィールドのシリアライズ形式を制御。

datetime → カスタムフォーマット
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 — モデル全体のシリアライズ #

@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 マスキング — 運用パターン #

パスワード、カード番号のような機微情報は ログ / レスポンスの両方で保護 する必要があります。

@field_serializer でマスキング
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() のオプションを一か所に整理します。

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 パターン — メタデータの分離 #

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: str

strict=True — 自動変換を切る #

Pydantic はデフォルトで 緩く 変換します。int フィールドに "30" を送ると 30 に変換。strict=True がそれを止めて 正確な型だけ を受け取ります。

strict の違い
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" — 未知のフィールドを遮断 #

forbid
class User(BaseModel):
    model_config = ConfigDict(extra="forbid")
    name: str

User(name="curtis", admin=True)
# ✗ ValidationError: Extra inputs are not permitted

API 入力検証で「タイプミス / 意図しないフィールドを早く捕まえたいとき」に有用です。デフォルトは "ignore" — 未知のフィールドを無視します。

RootModel — コレクション自体をモデルに #

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 つのキーで識別。

discriminated union
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 を作れます。

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 など) はすべてこの出力を活用。

ExamplesField(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 で 解いてください。

✅ 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 #

🚫 遅い union
class Payload(BaseModel):
    item: ItemA | ItemB | ItemC
# Pydantic が各モデルを順に試す — 遅い + 曖昧

3 つのモデルを一つずつ試して最初のマッチを選択。入力の形が似ていると誤ったモデルにマッチする可能性があり、試行自体のコストも大きい。

✅ discriminator を明示
Payload = Annotated[ItemA | ItemB | ItemC, Field(discriminator="kind")]

SQLAlchemy モデルとの変換 — from_attributes #

第25章 DB 連携 で ORM オブジェクトをレスポンスモデルに変換するとき。

ORM → Pydantic
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 を完成させる のスキーマ設計で一つの束として使われます。

第29章プレビュー — 本章パターンの総合
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章 の出発スキーマになります。

練習問題 #

  1. TodoCreate モデルに @field_validator("title") で (1) strip、(2) 空ならば ValueError、(3) HTML タグ (<>) 禁止の 3 つを 1 つの validator にまとめてください。@model_validator(mode="after")priority > 4 なら title に "緊急" 必須 検証を追加します。
  2. User(email, password) モデルで passwordSecretStr で受け取り、model_dump() の出力でパスワードが自動マスキングされるかを確認します。card_number: str フィールドを追加して @field_serializer で末尾 4 文字だけを見せるようにしてください。
  3. discriminated union パターンで ClickEvent / KeyEvent / ScrollEvent の 3 つのモデルを作り、EventAnnotated[..., Field(discriminator="type")] で定義します。JSON 入力で type キーだけを見て正確なモデルに分岐するか、model_json_schema() 出力の oneOf が正確かを確認します。

一行まとめ: v2 は Rust コアで 5〜50× 高速、API は v1 と異なる — @field_validator / @model_validatormodel_dumpConfigDict。検証ライフサイクルは mode=‘before’ → type 変換 → mode=‘after’ の 6 段階。フィールド単位は @field_validator、モデル全体は @model_validator。シリアライズは model_dump(exclude=..., exclude_unset=...) + @field_serializer / @model_serializerSecretStr で 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 を完成させる の出発点になります。

X