メインコンテンツへスキップ
友田 陽大
Pydantic・型安全バリデーション
Python
Pydantic
型安全
バリデーション
データモデリング
アーキテクチャ設計

Pydantic 高度な型・カスタムバリデータ実践ガイド:Annotated で再利用可能な「ドメインの型」を作る

Pydantic v2公式ドキュメントに忠実に、AfterValidator/BeforeValidator/WrapValidator/PlainValidatorのAnnotatedパターン、StringConstraintsとannotated_typesの制約型、__get_pydantic_core_schema__によるカスタム型、判別共用体・RootModel・ジェネリックモデルまで、再利用可能なドメイン型を設計する高度なバリデーションを実コードで解説します。

公開日
読了時間
14分
著者
友田 陽大
シェア

導入:検証ロジックは「散らかる」

Pydantic v2 実践ガイド で、@field_validator@model_validator の基礎を扱いました。あれで多くの検証は書けます。けれど、実務でモデルが増えてくると、ある種の臭いが漂い始めます。

  • ユーザー名の正規化(前後空白除去・小文字化・長さチェック)を、UserProfileComment の各モデルで同じ @field_validator をコピペしている。
  • 「正の金額」「日本の郵便番号」「在庫数」のような業務上の値の型が、ただの int / str として散らばり、制約がモデル定義の中に埋もれている。
  • 同じ検証なのに、書く場所(デコレータ vs Field 制約)がモデルごとにバラバラで、どこを直せばいいのか分からない。

これは DRY(繰り返さない)と SRP(単一責任)が崩れているサインです。本記事のテーマは、Pydantic v2 の Annotated パターンを中心に、検証ロジックを「再利用可能なドメインの型」へ昇格させること。「メールアドレス」「ユーザー名」「正の整数」を一度だけ定義し、どこでも使い回せる型にすれば、検証は散らからず、変更は一点に集約されます(CLAUDE.md でいう ETC=Easy To Change の実践です)。

公式ドキュメント に忠実でありながら、それより一段わかりやすく、Annotated バリデータ・制約型・カスタム型・判別共用体・RootModel・ジェネリックモデルまでを、実コードで体系化します。


1. Annotated バリデータ:検証を「型」に焼き込む

v2 には、バリデーションの書き方が 2 つあります。@field_validator デコレータ(モデルに紐づく)と、Annotated パターン(型に紐づく)です。後者の本質は、「この型は、こう検証される」を型の定義そのものに埋め込めることです。

from typing import Annotated
from pydantic import AfterValidator, BaseModel


def is_even(value: int) -> int:
    if value % 2 == 1:
        raise ValueError(f"{value} は偶数ではありません")
    return value


class Model(BaseModel):
    number: Annotated[int, AfterValidator(is_even)]

AfterValidator(is_even) は、int の標準検証が終わった後is_even を走らせます。ポイントは、これを名前付きの型として切り出せること。

from typing import Annotated
from pydantic import AfterValidator, BaseModel


def _normalize_username(value: str) -> str:
    cleaned = value.strip().lower()
    if not 3 <= len(cleaned) <= 30:
        raise ValueError("ユーザー名は3〜30文字です")
    return cleaned


# ✅ 再利用可能な「ドメインの型」を一度だけ定義する
Username = Annotated[str, AfterValidator(_normalize_username)]


class User(BaseModel):
    name: Username


class Comment(BaseModel):
    author: Username      # 同じ検証を、コピペせず再利用
    body: str

公式も*「アノテーションパターンを使う主な利点の一つは、バリデータを再利用できることです」*と明言しています。@field_validator を 3 つのモデルにコピペする代わりに、Username を 3 箇所で使う。検証ロジックの**単一の真実(Single Source of Truth)**が手に入ります。

H3: 4 つのモード——After / Before / Plain / Wrap

Annotated に置けるバリデータは 4 種類。それぞれ「標準検証に対してどこで走るか」が違います。

バリデータ走るタイミング受け取る値主な用途
AfterValidator標準検証の型が保証された値検証・正規化(第一選択)
BeforeValidator標準検証の生の入力(Any入力形式の事前整形
PlainValidator標準検証の代わり生の入力(Any検証ロジックを完全に自前実装
WrapValidator標準検証を包むAnyhandler例外捕捉・フォールバック
from typing import Annotated, Any
from pydantic import BaseModel, BeforeValidator


def ensure_list(value: Any) -> Any:
    # "a,b,c" のような単一文字列も、検証前にリストへ整形する
    if isinstance(value, str):
        return [t.strip() for t in value.split(",")]
    return value


class Article(BaseModel):
    tags: Annotated[list[str], BeforeValidator(ensure_list)]

⚠️ PlainValidator は検証を打ち切るPlainValidator は標準検証の「代わり」に走るため、その後の型検証は行われません。Annotated[int, PlainValidator(f)] と書いても、f が返した値が int であることは保証されない——f の責任で型を担保する必要があります。性能・安全の両面から、まずは AfterValidator を第一に検討してください。

H3: 実行順——「視覚的な並び」と「実際の順」は違う

複数のバリデータを重ねたとき、その実行順は直感に反します。公式の定義は明確です。

before and wrap validators are run from right to left, and after validators are then run from left to right.

from typing import Annotated
from pydantic import AfterValidator, BaseModel, BeforeValidator, WrapValidator


class Model(BaseModel):
    name: Annotated[
        str,
        AfterValidator(runs_3rd),   # after:左→右
        AfterValidator(runs_4th),
        BeforeValidator(runs_2nd),  # before/wrap:右→左
        WrapValidator(runs_1st),
    ]
# 実行順:runs_1st → runs_2nd → runs_3rd → runs_4th

before / wrap は右から左、after は左から右。同じリストの中で向きが逆になります。視覚的な上から下への並びと一致しないので、複数を重ねるときは順序に意識的になってください。


2. 制約型:constr ではなく Annotated + 制約で書く

「3〜30 文字」「正の整数」「正規表現マッチ」といった制約は、3 通りで書けます。どれを選ぶべきかが要点です。

from decimal import Decimal
from typing import Annotated
from annotated_types import Gt, Len
from pydantic import BaseModel, Field, StringConstraints


class Product(BaseModel):
    # ① Field メタデータで制約(最も一般的)
    price: int = Field(gt=0)
    sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
    amount: Decimal = Field(max_digits=7, decimal_places=2)

    # ② annotated_types の制約クラス(再利用しやすい)
    quantity: Annotated[int, Gt(0)]
    tags: Annotated[list[str], Len(max_length=10)]

    # ③ StringConstraints(文字列の整形+制約をまとめて)
    code: Annotated[str, StringConstraints(
        strip_whitespace=True, to_upper=True, pattern=r"^[A-Z]+$"
    )]

annotated_typesGt(0)Len(...) を使えば、第1章と同じ要領で再利用可能な制約型を作れます。

from typing import Annotated
from annotated_types import Gt

PositiveInt = Annotated[int, Gt(0)]   # プロジェクト全体で使い回せる

⚠️ constr / conlist は使わない:v1 由来の constr(min_length=3)conlist(...) という「制約付き型を返す関数」は、まだ動きます。しかし公式は constr について*「この関数は AnnotatedStringConstraints の使用を推奨し、非推奨とする。Pydantic 3.0 で deprecated になる」*と明記しています。新規コードでは必ず Annotated[str, StringConstraints(...)] 形式で書き、将来の破壊的変更を避けてください。

なぜ Annotated 形式が優れているのか? constr(...) は型チェッカー(mypy / Pyright)から見ると「実行時に型を返す関数」で、静的解析と相性が悪い。一方 Annotated[str, ...] は、静的には素直に str として扱われ、追加情報(制約)はメタデータとして横に乗るだけ。型チェッカーには str、Pydantic には制約付き str という、両取りが成立します。


3. カスタム型:__get_pydantic_core_schema__ で独自型に検証を埋め込む

Annotated バリデータは「既存の型に検証を足す」仕組みでした。一歩進めて、strint を継承した独自クラスそのものに、検証ロジックを内蔵させることもできます。これが __get_pydantic_core_schema__ です。

from typing import Any
from pydantic_core import CoreSchema, core_schema
from pydantic import GetCoreSchemaHandler, TypeAdapter


class Username(str):
    """検証込みのユーザー名型。str を継承しているのでそのまま文字列として使える。"""

    @classmethod
    def __get_pydantic_core_schema__(
        cls, source_type: Any, handler: GetCoreSchemaHandler
    ) -> CoreSchema:
        # handler(str) で「まず str として検証」、その後に cls を適用する
        return core_schema.no_info_after_validator_function(cls, handler(str))


ta = TypeAdapter(Username)
result = ta.validate_python("alice")
print(type(result))  # <class '__main__.Username'>

handler(str) が肝です。基底スキーマ(ここでは str の検証)を自分で組み立てるのではなく、handler に作らせる。そのうえで no_info_after_validator_function で「検証後に cls を適用する」と宣言します。

H3: 再利用可能なマーカークラス——「自前の AfterValidator

__get_pydantic_core_schema__ を持つマーカークラスを作れば、Annotated に乗せられる独自の検証注釈を量産できます。

from dataclasses import dataclass
from typing import Annotated, Any, Callable
from pydantic_core import CoreSchema, core_schema
from pydantic import BaseModel, GetCoreSchemaHandler


@dataclass(frozen=True)
class MyAfterValidator:
    func: Callable[[Any], Any]

    def __get_pydantic_core_schema__(
        self, source_type: Any, handler: GetCoreSchemaHandler
    ) -> CoreSchema:
        return core_schema.no_info_after_validator_function(
            self.func, handler(source_type)
        )


# 標準ライブラリの関数すら、そのまま検証として使える
Lowercased = Annotated[str, MyAfterValidator(str.lower)]


class Account(BaseModel):
    email: Lowercased

H3: 検証の強弱を切り替える——InstanceOfSkipValidation

from pydantic import BaseModel, InstanceOf, SkipValidation


class Fruit:
    ...


class Basket(BaseModel):
    # InstanceOf:型強制せず「そのクラスのインスタンスか」だけを検証する
    fruits: list[InstanceOf[Fruit]]
    # SkipValidation:このフィールドの検証を意図的にスキップする
    raw_labels: list[SkipValidation[str]]

InstanceOf[T] は「isinstance(v, T) だけを要求し、coercion はしない」型。任意の Python オブジェクト(ORM エンティティ等)をそのまま受けたいときに有効です。SkipValidation[T] は逆に「検証を省く」マーカー。

⚠️ SkipValidation は最後に置く:公式によれば SkipValidation はスキーマを any_schema(=何でも通す)に変えるため、その後に他の注釈を重ねても効かないことがあります。*「適用する型の最後の注釈にすべき」*とされています。検証を外すのは型安全の穴になるので、用途を限定してください。


4. 判別共用体:多態データを安全にモデリングする

「種類によって持つフィールドが違うデータ」——イベント(click / purchase / signup)、通知(email / sms / push)、図形(circle / rect)——は、判別共用体で表すのが定石です。

from typing import Literal, Union
from pydantic import BaseModel, Field


class EmailNotification(BaseModel):
    channel: Literal["email"]
    to_address: str
    subject: str


class SmsNotification(BaseModel):
    channel: Literal["sms"]
    phone_number: str


class PushNotification(BaseModel):
    channel: Literal["push"]
    device_token: str


class Envelope(BaseModel):
    # channel の値で、検証すべきメンバーを一意に確定する
    notification: Union[
        EmailNotification, SmsNotification, PushNotification
    ] = Field(discriminator="channel")


Envelope.model_validate(
    {"notification": {"channel": "sms", "phone_number": "090-0000-0000"}}
)
# → SmsNotification として検証される

各メンバーに共通の Literal 判別フィールドを持たせ、Field(discriminator=...) で指定する。メンバーの判別キーの名前が違う場合や、型で振り分けたい場合は、Discriminator に判別関数を渡し、各メンバーを Tag で標識します(パフォーマンス最適化ガイド の第3章に実例があります)。

なぜこれが優れているのか? 判別共用体は、**「不正な状態を表現不能にする」**設計の典型です。SmsNotificationsubject(メール用)を渡そうとすればエラーになり、channel="sms" なのに phone_number が欠けていればその欠落だけが指摘される。if data["channel"] == "email": ... のような手続き的な分岐をコードから追い出し、型の構造そのものに業務ルールを語らせる。第7章で触れるジェネリックや model_validator と並ぶ、ドメインモデリングの中核技法です。性能上も「総当たり」を避けられる利点があります。


5. RootModel:ルートが list / dict のデータを型にする

外部 API が、オブジェクトではなくトップレベルで配列や辞書を返すことはよくあります(["a", "b", "c"]{"alice": 30, "bob": 25})。こうした「ルートがコレクション」のデータは RootModel で型にします(v1 の __root__ の後継)。

from pydantic import RootModel


Tags = RootModel[list[str]]
ScoreByName = RootModel[dict[str, int]]

tags = Tags(["python", "rust"])
print(tags.root)               # ['python', 'rust']  ← 中身は .root でアクセス
print(tags.model_dump_json())  # ["python","rust"]

ルート要素がモデルでない検証には、RootModel のほか TypeAdapter(list[User]) も使えます。再利用するなら RootModel(名前が付き、メソッドを足せる)、その場限りなら TypeAdapter と使い分けると整理できます。

💡 .root でアクセスするRootModel の中身は属性 .root から取り出します。tags[0] のように添字で引きたい場合は、サブクラス化して __getitem__ / __iter__ を自分で定義します。ルート型は 1 つだけで、通常のフィールドと混在はできません。


6. ジェネリックモデル:再利用可能なラッパを型安全に作る

「成功フラグ+データ+メタ情報」のような汎用レスポンス封筒を、中身の型を変えながら使い回したい——Generic[T] の出番です。

from typing import Generic, TypeVar
from pydantic import BaseModel

DataT = TypeVar("DataT")


class ApiResponse(BaseModel, Generic[DataT]):
    success: bool
    data: DataT
    request_id: str


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


# パラメータ化した時点で、data の検証が効くようになる
resp = ApiResponse[User].model_validate(
    {"success": True, "data": {"id": 1, "name": "alice"}, "request_id": "req_x"}
)
print(resp.data.name)  # "alice" ← data は User として検証済み

⚠️ パラメータ化して初めて効く:検証が働くのは ApiResponse[User] のように型引数を与えた形だけです。素の ApiResponse のままだと DataT は実質 Any 扱いになり、data は検証されません。レスポンス封筒・ページネーション・Result[T, E] 風の型など、「形は同じで中身が変わる」構造を DRY に表現できます(Python 3.12 以降なら class ApiResponse[DataT](BaseModel) の PEP 695 構文も使えます)。


7. どれをいつ使うか——意思決定の早見表

ここまでの技法は競合しません。「検証したい対象の粒度」で選ぶのが整理のコツです。

やりたいこと使う仕組み理由
1 つの値・型の検証を再利用したいAnnotatedAfterValidator型に焼き込めば DRY。複数モデルで共有できる
単純な制約(長さ・範囲・正規表現)Field(...) / annotated_types / StringConstraints宣言的。constr は使わない
特定モデルの1 フィールド検証@field_validatorモデル固有のロジックはデコレータが素直
複数フィールドにまたがる検証@model_validator不変条件をモデルに集約(パスワード一致など)
str/int 継承の独自型に検証を内蔵__get_pydantic_core_schema__ライブラリ的な再利用。マーカークラス化も可
種類で形が変わる多態データ判別共用体(discriminator不正な状態を表現不能にする+高速
ルートが list/dict のデータRootModel / TypeAdapterトップレベルがモデルでない検証
中身が変わる汎用ラッパGeneric[T]レスポンス封筒等を型安全に再利用

最後に、@model_validator(mode="wrap") を使えばモデル全体の検証を包んで、失敗時のロギングやフォールバックを挟めます。クロスフィールド検証と「壊れた入力の観測」を両立したいときに有効です。

import logging
from typing import Any
from typing import Self
from pydantic import BaseModel, ModelWrapValidatorHandler, ValidationError, model_validator


class Order(BaseModel):
    item_id: int
    quantity: int

    @model_validator(mode="wrap")
    @classmethod
    def log_on_failure(cls, data: Any, handler: ModelWrapValidatorHandler[Self]) -> Self:
        try:
            return handler(data)
        except ValidationError:
            logging.warning("Order の検証に失敗: %r", data)  # PII に注意
            raise

結論:検証を「型」に昇格させ、不正を表現不能にする

高度なバリデーションの目的は、技巧を凝らすことではありません。検証ロジックを再利用可能な型へ昇格させ、不正な状態をそもそも作れなくすること——これに尽きます。本記事の要点を再掲します。

  1. Annotated パターンAfterValidator 等)で検証を型に焼き込み、Username のような再利用可能なドメイン型を作る。実行順は before/wrap が右→左、after が左→右。
  2. 制約は AnnotatedStringConstraints / annotated_types で書く。constr / conlist は使わない(3.0 で非推奨予定)。
  3. __get_pydantic_core_schema__ で独自型に検証を内蔵し、InstanceOf / SkipValidation で検証の強弱を制御する。
  4. 判別共用体で多態データを、RootModel でルートコレクションを、Generic[T] で汎用ラッパを、それぞれ型安全に表現する。

これらは「動くコード」を「10 年運用できるコード」に変える設計力そのものです。検証が型に集約されていれば、仕様変更は一点で済み、誤った使い方はコンパイル前・検証時に弾かれます。

公式の一次情報として、以下を本記事の観点で再読することをお勧めします。


型安全なドメインモデリングのご相談

筆者は、経済産業大臣賞を受賞した B2B SaaS のバックエンドを Python で設計・実装し、複雑な商流・価格・在庫のドメインを型で表現してきました。「不正な状態を表現不能にする」モデリングは、バグの発生源を構造的に断ち、保守コストを長期にわたって下げます。Pydantic v2 を用いたドメイン型の設計・既存モデルの型安全化・検証ロジックの集約といった、事業の信頼性に直結する基盤づくりを、生成 AI を活用して高速かつ高品質に進めます。お気軽にご相談ください。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通業界のDXを実現したB2BサブスクリプションSaaS

ケーススタディを見る