Python Pydantic V2データバリデーション実践:モデル設計からカスタムバリデータまで7つのプロダクションパターン

编程语言

Pydantic V2:まだif-elseでデータバリデーションしてるの?

APIパラメータのバリデーション漏れ、データベースへのダーティデータ書き込み、設定ファイルのパースでNoneが返る——これらのプロダクション事故の根本原因はすべてデータバリデーションの不備です。手書きのif-elseは長くて間違いやすい。V1の@validatorを使ってもV2に移行するとエラーだらけ。model_configを設定してもシリアライズ結果が期待通りにならない。2026年、Pydantic V2はV1を完全に置き換え、5-50倍のパフォーマンス向上を実現しましたが、APIの変更は大きく、移行の罠が多いのです。

本記事では7つのプロダクションパターンから出発し、基本モデル→フィールドバリデーション→カスタムバリデータ→シリアライズ→JSON Schema→パフォーマンス最適化→FastAPI統合のフルパイプラインを実践します。


Pydantic V2コア概念

概念 説明
BaseModel Pydanticのコアクラス、データモデルを定義し自動バリデーション
Field フィールド設定、デフォルト値、説明、制約条件をサポート
field_validator V2の新フィールドバリデータ、V1の@validatorを代替
model_validator モデルレベルバリデータ、クロスフィールドバリデーション
model_config モデル設定、シリアライズ、Strictモードなどの動作を制御
TypeAdapter BaseModel以外の型のバリデーションアダプタ
JSON Schema モデルから自動生成されるJSON Schema、APIドキュメントに使用
Serialize シリアライズ制御、exclude、alias、カスタムシリアライズをサポート

問題分析:データバリデーションの5つのペインポイント

  1. 手書きバリデーションは冗長でエラーが起きやすい:各エンドポイントにif-elseを書き、フィールドの漏れでバグが発生、メンテナンスコストが高い
  2. V1からV2へのAPI非互換@validator@field_validatorに、Configクラスがmodel_configに変更、大量のコード修正が必要
  3. ネストモデルのシリアライズが制御不能:ORMオブジェクトのJSON変換時の循環参照、機密フィールドの漏洩、フィールド名がフロントエンドの規約に合わない
  4. クロスフィールドバリデーションの実装が困難:パスワード確認、日付範囲、条件付き必須フィールドなど複数フィールドの同時バリデーションが必要
  5. パフォーマンスのボトルネック:V1は大データ量で遅い、V2は速いが設定ミスで逆に遅くなる

ステップバイステップ:7つのPydantic V2プロダクションパターン

パターン1:基本モデル設計とフィールド制約

from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from datetime import datetime
from enum import Enum

class UserStatus(str, Enum):
    ACTIVE = "active"
    INACTIVE = "inactive"
    SUSPENDED = "suspended"

class UserCreate(BaseModel):
    model_config = {"str_strip_whitespace": True, "str_min_length": 1}

    username: str = Field(
        min_length=3,
        max_length=20,
        pattern=r"^[a-zA-Z0-9_]+$",
        description="ユーザー名、3-20文字の英数字とアンダースコア"
    )
    email: EmailStr = Field(description="メールアドレス")
    password: str = Field(
        min_length=8,
        max_length=128,
        description="パスワード、8-128文字"
    )
    age: Optional[int] = Field(
        default=None,
        ge=0,
        le=150,
        description="年齢、0-150"
    )
    status: UserStatus = Field(default=UserStatus.ACTIVE)
    created_at: datetime = Field(default_factory=datetime.now)

class UserResponse(BaseModel):
    id: int = Field(gt=0)
    username: str
    email: EmailStr
    status: UserStatus
    created_at: datetime

user = UserCreate(
    username="zhang_san",
    email="zhang@example.com",
    password="secureP@ss123",
    age=28
)
print(user.model_dump())

パターン2:フィールドレベルバリデータ field_validator

from pydantic import BaseModel, Field, field_validator
import re

class RegisterRequest(BaseModel):
    username: str = Field(min_length=3, max_length=20)
    password: str = Field(min_length=8)
    confirm_password: str

    @field_validator("username")
    @classmethod
    def username_must_be_valid(cls, v: str) -> str:
        if not re.match(r"^[a-zA-Z0-9_]+$", v):
            raise ValueError("ユーザー名は英数字とアンダースコアのみ使用可能")
        if v.startswith("_"):
            raise ValueError("ユーザー名はアンダースコアで始められません")
        return v.lower()

    @field_validator("password")
    @classmethod
    def password_strength_check(cls, v: str) -> str:
        if not re.search(r"[A-Z]", v):
            raise ValueError("パスワードには大文字を1つ以上含めてください")
        if not re.search(r"[a-z]", v):
            raise ValueError("パスワードには小文字を1つ以上含めてください")
        if not re.search(r"\d", v):
            raise ValueError("パスワードには数字を1つ以上含めてください")
        if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", v):
            raise ValueError("パスワードには特殊文字を1つ以上含めてください")
        return v

class ProductCreate(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    price: float = Field(gt=0)
    tags: list[str] = Field(default_factory=list)

    @field_validator("tags")
    @classmethod
    def tags_deduplicate(cls, v: list[str]) -> list[str]:
        seen = set()
        result = []
        for tag in v:
            tag_lower = tag.lower().strip()
            if tag_lower and tag_lower not in seen:
                seen.add(tag_lower)
                result.append(tag_lower)
        return result

    @field_validator("price")
    @classmethod
    def price_round_to_cents(cls, v: float) -> float:
        return round(v, 2)

パターン3:モデルレベルバリデータ model_validator

from pydantic import BaseModel, Field, model_validator
from datetime import date, timedelta
from typing import Optional

class DateRangeQuery(BaseModel):
    start_date: date
    end_date: date

    @model_validator(mode="after")
    def validate_date_range(self) -> "DateRangeQuery":
        if self.start_date > self.end_date:
            raise ValueError("開始日は終了日より後にはできません")
        if (self.end_date - self.start_date).days > 365:
            raise ValueError("検索範囲は365日を超えることはできません")
        return self

class EventCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    event_type: str
    start_time: datetime
    end_time: Optional[datetime] = None
    location: Optional[str] = None
    online_url: Optional[str] = None

    @model_validator(mode="after")
    def validate_event(self) -> "EventCreate":
        if self.event_type == "offline" and not self.location:
            raise ValueError("オフラインイベントには場所が必要です")
        if self.event_type == "online" and not self.online_url:
            raise ValueError("オンラインイベントにはURLが必要です")
        if self.event_type == "hybrid":
            if not self.location:
                raise ValueError("ハイブリッドイベントにはオフライン場所が必要です")
            if not self.online_url:
                raise ValueError("ハイブリッドイベントにはオンラインURLが必要です")
        if self.end_time and self.start_time >= self.end_time:
            raise ValueError("終了時間は開始時間より後である必要があります")
        return self

class PasswordChange(BaseModel):
    old_password: str = Field(min_length=1)
    new_password: str = Field(min_length=8)
    confirm_password: str

    @model_validator(mode="after")
    def passwords_match(self) -> "PasswordChange":
        if self.new_password != self.confirm_password:
            raise ValueError("新しいパスワードが一致しません")
        if self.old_password == self.new_password:
            raise ValueError("新しいパスワードは古いパスワードと同じにできません")
        return self

パターン4:シリアライズ制御とエイリアス

from pydantic import BaseModel, Field, ConfigDict
from typing import Optional

class UserORM(BaseModel):
    model_config = ConfigDict(
        from_attributes=True,
        populate_by_name=True,
    )

    id: int
    username: str = Field(alias="user_name")
    email: str = Field(alias="email_address")
    hashed_password: str = Field(exclude=True)
    phone: Optional[str] = Field(default=None, exclude=True)
    avatar_url: Optional[str] = Field(default=None, serialization_alias="avatar")
    created_at: datetime
    updated_at: Optional[datetime] = None

class ArticleResponse(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    id: int
    title: str
    content: str = Field(exclude=True)
    summary: Optional[str] = None
    author_id: int = Field(serialization_alias="authorId")
    tags: list[str] = Field(default_factory=list)
    view_count: int = Field(default=0, serialization_alias="viewCount")
    created_at: datetime = Field(serialization_alias="createdAt")
    updated_at: Optional[datetime] = Field(default=None, serialization_alias="updatedAt")

    def get_summary(self) -> str:
        if self.summary:
            return self.summary
        return self.content[:200] + "..." if len(self.content) > 200 else self.content

article = ArticleResponse(
    id=1,
    title="Pydantic V2実践ガイド",
    content="これは非常に長い記事の内容です..." * 50,
    author_id=42,
    tags=["Python", "Pydantic"],
    view_count=1024,
    created_at=datetime.now()
)
print(article.model_dump(by_alias=True))

パターン5:JSON Schema生成とAPIドキュメント

from pydantic import BaseModel, Field
import json

class APIRequest(BaseModel):
    """注文作成リクエスト"""
    product_id: int = Field(gt=0, description="商品ID")
    quantity: int = Field(ge=1, le=999, description="購入数量")
    coupon_code: Optional[str] = Field(default=None, pattern=r"^[A-Z0-9]{6,12}$", description="クーポンコード")
    shipping_address: str = Field(min_length=5, max_length=500, description="配送先住所")
    remark: Optional[str] = Field(default=None, max_length=200, description="注文備考")

class APIResponse(BaseModel):
    """注文作成レスポンス"""
    order_id: str = Field(description="注文ID")
    total_amount: float = Field(description="注文合計金額")
    discount_amount: float = Field(default=0.0, description="割引金額")
    final_amount: float = Field(description="支払金額")
    status: str = Field(description="注文ステータス")

schema = APIRequest.model_json_schema()
print(json.dumps(schema, indent=2, ensure_ascii=False))

パターン6:TypeAdapterとジェネリックバリデーション

from pydantic import BaseModel, TypeAdapter, Field
from typing import Generic, TypeVar, Optional

T = TypeVar("T")

class PageResponse(BaseModel, Generic[T]):
    items: list[T]
    total: int = Field(ge=0)
    page: int = Field(ge=1)
    page_size: int = Field(ge=1, le=100)
    has_next: bool

class UserItem(BaseModel):
    id: int
    username: str
    email: str

user_page_type = PageResponse[UserItem]
adapter = TypeAdapter(user_page_type)

json_data = {
    "items": [
        {"id": 1, "username": "alice", "email": "alice@example.com"},
        {"id": 2, "username": "bob", "email": "bob@example.com"},
    ],
    "total": 100,
    "page": 1,
    "page_size": 10,
    "has_next": True
}

page = adapter.validate_python(json_data)
print(page.model_dump())

raw_list_adapter = TypeAdapter(list[int])
result = raw_list_adapter.validate_python(["1", "2", "3"])
print(result)

config_adapter = TypeAdapter(dict[str, int])
config = config_adapter.validate_python({"timeout": "30", "retries": "3"})
print(config)

パターン7:FastAPI統合プロダクション実践

from fastapi import FastAPI, HTTPException, Depends, Query
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Optional

app = FastAPI(title="ユーザー管理API")

class UserCreateRequest(BaseModel):
    username: str = Field(min_length=3, max_length=20, pattern=r"^[a-zA-Z0-9_]+$")
    email: str = Field(pattern=r"^[\w.-]+@[\w.-]+\.\w+$")
    password: str = Field(min_length=8, max_length=128)
    role: str = Field(default="user", pattern=r"^(admin|user|guest)$")

    @field_validator("password")
    @classmethod
    def password_strength(cls, v: str) -> str:
        has_upper = any(c.isupper() for c in v)
        has_lower = any(c.islower() for c in v)
        has_digit = any(c.isdigit() for c in v)
        if not (has_upper and has_lower and has_digit):
            raise ValueError("パスワードには大文字、小文字、数字を含める必要があります")
        return v

class UserUpdateRequest(BaseModel):
    email: Optional[str] = None
    role: Optional[str] = None
    status: Optional[str] = None

    @model_validator(mode="after")
    def at_least_one_field(self) -> "UserUpdateRequest":
        if self.email is None and self.role is None and self.status is None:
            raise ValueError("少なくとも1つのフィールドを更新する必要があります")
        return self

class UserDetailResponse(BaseModel):
    id: int
    username: str
    email: str
    role: str
    status: str
    created_at: datetime

class ErrorResponse(BaseModel):
    error_code: int
    message: str
    detail: Optional[str] = None

@app.post("/users", response_model=UserDetailResponse, responses={400: {"model": ErrorResponse}})
async def create_user(req: UserCreateRequest):
    user_data = req.model_dump()
    user_data["id"] = 1
    user_data["status"] = "active"
    user_data["created_at"] = datetime.now()
    return user_data

@app.patch("/users/{user_id}", response_model=UserDetailResponse)
async def update_user(user_id: int, req: UserUpdateRequest):
    update_data = req.model_dump(exclude_none=True)
    if not update_data:
        raise HTTPException(status_code=400, detail="No fields to update")
    return {"id": user_id, "username": "test", "email": "test@example.com", "role": "user", "status": "active", "created_at": datetime.now()}

@app.get("/users", response_model=PageResponse[UserDetailResponse])
async def list_users(
    page: int = Query(ge=1, default=1),
    page_size: int = Query(ge=1, le=100, default=20),
    role: Optional[str] = Query(default=None, pattern=r"^(admin|user|guest)$"),
):
    return {
        "items": [],
        "total": 0,
        "page": page,
        "page_size": page_size,
        "has_next": False
    }

よくある罠ガイド

罠1:V1の@validatorを@field_validatorに名前変更するだけでは動作しない

# ❌ 間違い:V1スタイルの名前変更のみ、clsとmodeパラメータが不足
from pydantic import field_validator

class Bad(BaseModel):
    name: str

    @field_validator("name")
    def validate_name(v):
        return v.upper()

# ✅ 正しい:V2では@classmethodとmodeパラメータが必要
class Good(BaseModel):
    name: str

    @field_validator("name")
    @classmethod
    def validate_name(cls, v: str) -> str:
        return v.upper()

罠2:model_configを内部クラスとして記述

# ❌ 間違い:V1のConfig内部クラス、V2では非推奨
class OldWay(BaseModel):
    name: str

    class Config:
        orm_mode = True

# ✅ 正しい:V2ではmodel_config辞書を使用
class NewWay(BaseModel):
    model_config = {"from_attributes": True}
    name: str

# ✅ より良い:ConfigDictで型ヒントを取得
from pydantic import ConfigDict

class BestWay(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    name: str

罠3:シリアライズ時にexcludeが機能しない

class User(BaseModel):
    id: int
    name: str
    password: str = Field(exclude=True)

user = User(id=1, name="test", password="secret")

# ❌ 間違い:model_dump()はデフォルトでシリアライズエイリアスを適用しない
print(user.model_dump())
# {'id': 1, 'name': 'test', 'password': 'secret'}  # passwordがまだある!

# ✅ 正しい:modeパラメータが必要
print(user.model_dump(mode="python"))
# {'id': 1, 'name': 'test'}  # passwordが除外される

# ✅ JSONシリアライズ
print(user.model_dump_json())
# {"id":1,"name":"test"}  # passwordが除外される

罠4:from_attributesとORMフィールドの不一致

# ❌ 間違い:ORMフィールド名とモデルフィールド名が一致せず、from_attributesがサイレントにスキップ
class ORMUser:
    def __init__(self):
        self.user_name = "test"  # ORMフィールド名
        self.email_addr = "t@e.com"

class PydanticUser(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    username: str  # user_nameと一致しない
    email: str     # email_addrと一致しない

# ✅ 正しい:Field(alias=...)でORMフィールド名をマッピング
class PydanticUserFixed(BaseModel):
    model_config = ConfigDict(from_attributes=True, populate_by_name=True)
    username: str = Field(alias="user_name")
    email: str = Field(alias="email_addr")

罠5:OptionalフィールドにNoneを渡すとバリデーションがスキップされる

# ❌ 間違い:OptionalフィールドにNoneが渡されるとバリデーションがスキップされる
class Bad(BaseModel):
    age: Optional[int] = Field(None, ge=0, le=150)

Bad(age=None)  # 通過するが、Noneは有効な年齢ではない

# ✅ 正しい:「オプション」と「None許可」を区別する
from typing import Union

class Good(BaseModel):
    age: Union[int, None] = Field(None, ge=0, le=150)

# ✅ より良い:Noneに意味がある場合はカスタムバリデータで処理
class Better(BaseModel):
    age: Optional[int] = Field(None, ge=0, le=150)

    @field_validator("age")
    @classmethod
    def age_not_none_if_provided(cls, v: Optional[int]) -> Optional[int]:
        if v is not None and v < 0:
            raise ValueError("年齢は負の数にできません")
        return v

エラートラブルシューティング

# エラーメッセージ 原因 解決方法
1 ValidationError: field required 必須フィールドが未提供 フィールドにdefaultまたはdefault_factoryがあるか確認
2 ValidationError: string too short 文字列長が不足 min_lengthを調整するか、より長い入力を提供
3 PydanticUserWarning: @validator is deprecated V1の@validatorを使用 @field_validatorに置き換えて@classmethodを追加
4 AttributeError: 'Config' class not supported V2は内部Configクラスをサポートしない model_config辞書またはConfigDictを使用
5 ValidationError: Input should be a valid integer 型変換に失敗 入力が有効な数値文字列か確認
6 ValueError: field_validator missing cls field_validatorに@classmethodが不足 @field_validatorの下に@classmethodを追加
7 ValidationError: Extra inputs are not permitted Strictモードで余分なフィールドが拒否 model_configのextraを"ignore"または"allow"に設定
8 TypeError: Unable to generate pydantic-core schema サポートされない型アノテーション 複雑なジェネリックや未サポートの型を確認
9 RecursionError: maximum recursion depth exceeded ネストモデルの循環参照 Optional前方参照を使用するかモデルを再構築
10 SerializationError: circular reference detected シリアライズ時の循環参照検出 excludeパラメータまたはカスタムシリアライザを使用

高度な最適化

1. StrictモードとLaxモードの切り替え

from pydantic import BaseModel, ConfigDict, StrictInt, StrictStr

class StrictModel(BaseModel):
    model_config = ConfigDict(strict=True)
    id: int
    name: str

class LaxModel(BaseModel):
    model_config = ConfigDict(strict=False)
    id: int
    name: str

strict_result = StrictModel(id=1, name="test")
lax_result = LaxModel(id="1", name="test")

class HybridModel(BaseModel):
    model_config = ConfigDict(strict=False)
    id: StrictInt
    name: str

2. Annotatedによるカスタム型

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

def normalize_phone(v: str) -> str:
    return v.replace("-", "").replace(" ", "").replace("+86", "")

def check_phone_format(v: str) -> str:
    if not v.startswith("1") or len(v) != 11:
        raise ValueError("電話番号の形式が正しくありません")
    return v

PhoneNumber = Annotated[str, BeforeValidator(normalize_phone), AfterValidator(check_phone_format)]

YuanFromCents = Annotated[float, BeforeValidator(lambda v: v / 100 if isinstance(v, int) else v)]

class PaymentRequest(BaseModel):
    phone: PhoneNumber
    amount: YuanFromCents = Field(gt=0, description="金額(元)")

payment = PaymentRequest(phone="+86-138-0013-8000", amount=9900)
print(payment.model_dump())

3. パフォーマンス最適化:キャッシュと事前コンパイル

from pydantic import BaseModel, TypeAdapter
import time

class LargeModel(BaseModel):
    field1: str
    field2: int
    field3: float
    field4: bool
    field5: str
    field6: int
    field7: float
    field8: bool

adapter = TypeAdapter(LargeModel)

data = {"field1": "a", "field2": 1, "field3": 1.0, "field4": True, "field5": "b", "field6": 2, "field7": 2.0, "field8": False}

start = time.perf_counter()
for _ in range(100000):
    LargeModel(**data)
v1_time = time.perf_counter() - start

start = time.perf_counter()
for _ in range(100000):
    adapter.validate_python(data)
adapter_time = time.perf_counter() - start

print(f"Direct: {v1_time:.3f}s, TypeAdapter: {adapter_time:.3f}s")

比較分析

次元 Pydantic V1 Pydantic V2 手書きif-else Marshmallow
バリデーション性能 ⭐⭐遅い ⭐⭐⭐⭐⭐5-50倍高速 ⭐⭐⭐⭐速い ⭐⭐遅い
型ヒント統合 ⚠️部分的 ✅完全 ❌なし ❌なし
エラーメッセージ ⚠️一般的 ✅詳細な位置特定 ❌カスタム ⚠️一般的
JSON Schema ✅サポート ✅充実 ❌なし ✅サポート
シリアライズ制御 ⚠️限定的 ✅柔軟 ❌手書き ✅柔軟
学習曲線 ⭐⭐低い ⭐⭐⭐中程度 ⭐最低 ⭐⭐⭐中程度
FastAPI統合 ✅ネイティブ ✅ネイティブ ❌なし ⚠️アダプタ必要
プロダクション推奨 レガシープロジェクト 第一選択 簡単なスクリプト 複雑な変換

まとめ:Pydantic V2は単なるバージョンアップではなく、「バリデーションライブラリ」から「データエンジニアリングインフラ」への質的飛躍です。3つのコア原則:Field制約で手書きバリデーションを代替、model_validatorでクロスフィールドロジックを処理、model_configでシリアライズ動作を制御。V1からV2への移行は苦痛ですが、5-50倍のパフォーマンス向上とより完全な型システムは投資に値します。FastAPI + Pydantic V2は2026年のPython Web開発の事実上の標準です。


オンラインツール推奨

ブラウザローカルツールを無料で試す →

#Python#Pydantic#数据校验#FastAPI#类型注解#2026#JSON Schema