Pydantic v3を半年使って気づいたこと——本番導入で本当に効いた知見
「v2で十分では?」と思っていたチームがPydantic v3を半年運用して考えが変わった話。PartialModelや型ヒント活用など、実務でハマった点と地味に効いた知見を実コードで。
うちのチームで昨年末からPydantic v3を本格導入して、ちょうど半年経った。正直、最初は「v2で十分じゃないか」という声が多かったけど、今となっては導入してよかったと全員が言っている。実際に運用してわかったこと、ハマりどころ、そして「これは地味に効いた」という知見を共有したい。
型ヒントの話になると「どうせ実行時には何も変わらない」という意見をよく聞く。確かにPython標準の型アノテーションだけならそうなんだけど、Pydanticと組み合わせることで話は全然違ってくる。実行時バリデーション、シリアライゼーション、スキーマ生成まで一気通貫でできるようになる。FastAPIを使っているプロジェクトならPython 3.13 FastAPI非同期処理実装ガイドも参考にしてほしいけど、今回はPydantic自体の深掘りに集中する。
Pydantic v3で何が変わったか
Pydantic v2がリリースされたのが2023年で、Rust製のコアエンジンに置き換えてパフォーマンスが劇的に上がった。そしてv3は2025年末にリリースされ、さらにいくつかの重要な変更が入っている。
一番大きいのはPartialModelのネイティブサポートだ。PATCHリクエストで「フィールドが来たら更新、来なければ現在値を維持」という挙動を実装するのに、これまではOptionalを使ってNoneチェックを書いたり、自前でPartialを作るライブラリを使ったりしていた。v3ではこれが組み込みになった。
from pydantic import BaseModel
from pydantic.partial import partial
class UserProfile(BaseModel):
name: str
email: str
age: int
bio: str | None = None
# PartialUserProfileは全フィールドがOptionalになる
PartialUserProfile = partial(UserProfile)
# PATCHエンドポイントで使う
def update_user(user_id: int, data: PartialUserProfile) -> UserProfile:
current = get_user_from_db(user_id) # 現在のデータを取得
updated_data = current.model_dump()
# Noneでないフィールドだけ更新
patch_data = data.model_dump(exclude_unset=True)
updated_data.update(patch_data)
return UserProfile(**updated_data)
これ、地味に便利。今まで毎回手書きしてたパターンがそのままなくなった。
もう一つはバリデーションのモード切替がより細かくなったこと。model_validate()にstrict=Trueを渡すと、以前は型強制が走っていたものがエラーになる。
from pydantic import BaseModel, ValidationError
class Order(BaseModel):
quantity: int
price: float
# デフォルト: 文字列"10"がintの10に強制変換される
order_lenient = Order.model_validate({"quantity": "10", "price": "99.9"})
print(order_lenient.quantity) # 10 (int)
# strictモード: 型が合わないとエラー
try:
order_strict = Order.model_validate(
{"quantity": "10", "price": "99.9"},
strict=True
)
except ValidationError as e:
print(e)
# quantity: Input should be a valid integer [type=int_type]
# price: Input should be a valid number [type=float_type]
APIの境界ではlenientで(フロントからの文字列を受け取る)、内部処理ではstrictで(データの整合性を保証する)という使い分けができるようになった。バグの混入経路が明確になるのがいい。個人的には、この設計判断がコードレビューの会話をかなり楽にしてくれたと思っている。
バリデーターの書き方を全面的に見直した
v3でバリデーターの書き方がさらに洗練されている。@field_validatorと@model_validatorの使い分けを整理したのが良かった。
from pydantic import BaseModel, field_validator, model_validator, Field
from datetime import date
from typing import Self
class Reservation(BaseModel):
check_in: date
check_out: date
guest_count: int = Field(gt=0, le=20)
room_type: str
@field_validator('room_type')
@classmethod
def validate_room_type(cls, v: str) -> str:
allowed = {'single', 'double', 'suite', 'family'}
if v.lower() not in allowed:
raise ValueError(f'room_type must be one of {allowed}')
return v.lower()
@model_validator(mode='after')
def validate_dates(self) -> Self:
if self.check_out <= self.check_in:
raise ValueError('check_out must be after check_in')
max_days = 30
stay_duration = (self.check_out - self.check_in).days
if stay_duration > max_days:
raise ValueError(f'Stay duration cannot exceed {max_days} days')
# ファミリールームは最低2名
if self.room_type == 'family' and self.guest_count < 2:
raise ValueError('Family room requires at least 2 guests')
return self
mode='after'のmodel_validatorはフィールドバリデーション後に動くので、複数フィールドの相関チェックに使える。以前は__root_validator__だったのがこちらに統一されてすっきりした。
チームで一番「助かった」と言われたのがカスタムアノテーションの仕組みだ。
from pydantic import BeforeValidator, AfterValidator, PlainValidator
from typing import Annotated
def strip_whitespace(v: str) -> str:
return v.strip()
def to_lowercase(v: str) -> str:
return v.lower()
def validate_email_domain(v: str) -> str:
allowed_domains = ['company.com', 'partner.org']
domain = v.split('@')[-1]
if domain not in allowed_domains:
raise ValueError(f'Email must be from: {allowed_domains}')
return v
# 型エイリアスとしてAnnotatedを使う
CleanEmail = Annotated[
str,
BeforeValidator(strip_whitespace),
AfterValidator(to_lowercase),
AfterValidator(validate_email_domain),
]
class Employee(BaseModel):
name: str
email: CleanEmail # これだけで前処理+バリデーションが入る
manager_email: CleanEmail | None = None
CleanEmailという型エイリアスを作っておけば、複数のモデルで再利用できる。ドメイン知識をコードに埋め込む場所が明確になって、レビューのときに「このフィールドはどんなバリデーションがかかってるの?」という質問が激減した。これは地味だけど、積み重なるとレビュー時間にじわじわ効いてくる。
パフォーマンス計測してみた結果
「Pydanticは遅い」という印象を持っている人、まだいませんか?v2以降は本当に変わった。実際に自分たちのプロジェクトで計測した結果を共有する。
import timeit
import json
from pydantic import BaseModel
from dataclasses import dataclass
class PydanticUser(BaseModel):
id: int
name: str
email: str
age: int
@dataclass
class DataclassUser:
id: int
name: str
email: str
age: int
test_data = {"id": 1, "name": "Taro", "email": "taro@example.com", "age": 30}
json_str = json.dumps(test_data)
iterations = 100_000
pydantic_time = timeit.timeit(
lambda: PydanticUser.model_validate(test_data),
number=iterations
)
pydantic_json_time = timeit.timeit(
lambda: PydanticUser.model_validate_json(json_str),
number=iterations
)
dataclass_time = timeit.timeit(
lambda: DataclassUser(**test_data),
number=iterations
)
print(f"Pydantic v3 (dict): {pydantic_time:.3f}s")
print(f"Pydantic v3 (JSON): {pydantic_json_time:.3f}s")
print(f"Dataclass (no val): {dataclass_time:.3f}s")
実行結果(M3 MacBook Pro、Python 3.13.2):
Pydantic v3 (dict): 0.847s
Pydantic v3 (JSON): 0.612s
Dataclass (no val): 0.198s
xychart-beta
title "100,000回処理の実行時間比較(秒、低いほど良い)"
x-axis ["Pydantic v3 (dict)", "Pydantic v3 (JSON)", "Dataclass (検証なし)"]
y-axis "実行時間 (秒)" 0 --> 1.0
bar [0.847, 0.612, 0.198]
Dataclassと比べるとまだ差はあるけど、バリデーションゼロのDataclassと比較するのはフェアじゃない。注目してほしいのはmodel_validate_json()で、内部でRustがJSONパースとバリデーションを一度にやるから特に速い。うちのAPIはほぼJSONを受け取るので、これを使うようにしたらレイテンシが体感できるレベルで改善した。
バージョン間の主な違いをまとめるとこうなる。
| 比較項目 | v1 | v2 | v3 |
|---|---|---|---|
| バリデーション速度 | 基準 | 5〜50倍速 | v2比+15%程度 |
| シリアライズ速度 | 基準 | 数倍速 | v2比+20%程度 |
| PartialModel | 非公式対応 | 手動実装が必要 | ネイティブ対応 |
| strictモード | モデル単位 | モデル単位 | フィールド単位も可能 |
| Python最低バージョン | 3.7 | 3.8 | 3.10 |
| カスタムアノテーション | 制限あり | Annotated対応 | 完全対応 |
v1からv2の跳び幅が一番大きくて、v3はそこからさらに使い勝手を磨いてきた、という印象だ。
チーム運用で学んだこと
半年間チームで使ってきて、一番効いたのはスキーマ共有の仕組みだった。
Pydanticモデルはmodel_json_schema()でJSONスキーマを吐き出せる。これをOpenAPI定義に変換してフロントエンドチームと共有するようにしたら、「APIの仕様が変わったのに聞いてなかった」という事故がほぼなくなった。
from pydantic import BaseModel, Field
from typing import Literal
import json
class CreateOrderRequest(BaseModel):
"""注文作成リクエスト"""
product_id: int = Field(description="商品ID")
quantity: int = Field(gt=0, le=100, description="数量(1〜100)")
delivery_type: Literal['standard', 'express'] = Field(
default='standard',
description="配送タイプ"
)
coupon_code: str | None = Field(
default=None,
pattern=r'^[A-Z0-9]{8}$',
description="クーポンコード(8桁英数字大文字)"
)
schema = CreateOrderRequest.model_json_schema()
print(json.dumps(schema, indent=2, ensure_ascii=False))
出力:
{
"description": "注文作成リクエスト",
"properties": {
"product_id": {
"description": "商品ID",
"title": "Product Id",
"type": "integer"
},
"quantity": {
"description": "数量(1〜100)",
"exclusiveMinimum": 0,
"maximum": 100,
"title": "Quantity",
"type": "integer"
},
"delivery_type": {
"default": "standard",
"description": "配送タイプ",
"enum": ["standard", "express"],
"title": "Delivery Type",
"type": "string"
},
"coupon_code": {
"anyOf": [{"pattern": "^[A-Z0-9]{8}$", "type": "string"}, {"type": "null"}],
"default": null,
"description": "クーポンコード(8桁英数字大文字)"
}
},
"required": ["product_id", "quantity"],
"title": "CreateOrderRequest",
"type": "object"
}
これをCI/CDパイプラインに組み込んで、スキーマが変わったらPRに自動でコメントが入るようにした。Feature Flag導入で本番バグ対応が1時間から5分に短縮した話でも書いたけど、「仕組みで防ぐ」アプローチがチームの信頼性を地道に上げていく。
次に地味だけど効いたのが設定クラスとしての使い方だ。
from pydantic_settings import BaseSettings
from pydantic import Field, SecretStr
from functools import lru_cache
class AppSettings(BaseSettings):
# アプリ設定
app_name: str = "MyApp"
debug: bool = False
log_level: str = Field(default="INFO", pattern=r'^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$')
# データベース
db_host: str
db_port: int = Field(default=5432, gt=0, lt=65536)
db_name: str
db_password: SecretStr # ログに出力されない
db_pool_size: int = Field(default=10, gt=0, le=100)
# 外部API
payment_api_key: SecretStr
payment_api_timeout: float = Field(default=30.0, gt=0)
# キャッシュ
redis_url: str = "redis://localhost:6379"
cache_ttl_seconds: int = Field(default=300, gt=0)
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
"case_sensitive": False,
}
@lru_cache
def get_settings() -> AppSettings:
return AppSettings()
# 使い方
settings = get_settings()
print(settings.db_password.get_secret_value()) # これで取得
print(settings.db_password) # SecretStr('**********') と表示される
pydantic-settingsライブラリと組み合わせることで、環境変数のバリデーションまでできる。「本番環境に間違った設定値でデプロイしてしまった」という事故が、起動時に弾かれるようになった。
SecretStr型はとくに重要で、ログやデバッグ出力に秘密情報が混入するのを型レベルで防いでくれる。OWASP Top 10 2024対策の記事でも触れているけど、シークレット情報の漏洩対策を「気をつける」じゃなくて「型で強制する」に変えられるのは本当に助かる。
Pydantic v3がシステム全体のどこで働くかを図にするとこうなる。
flowchart TB
subgraph Input["入力レイヤー"]
API["API リクエスト\n(JSON)"]
ENV["環境変数\n(.env)"]
DB_RESP["DB レスポンス\n(dict)"]
end
subgraph Validation["Pydantic v3 バリデーション"]
REQ_MODEL["Request Model\nmodel_validate_json()"]
SETTINGS["AppSettings\nBaseSettings"]
RESP_MODEL["Response Model\nmodel_validate()"]
end
subgraph Business["ビジネスロジック"]
SERVICE["Service Layer\n型安全なオブジェクト"]
end
subgraph Output["出力レイヤー"]
JSON_OUT["JSON レスポンス\nmodel_dump_json()"]
SCHEMA["JSON Schema\nmodel_json_schema()"]
end
API -->|"raw JSON"| REQ_MODEL
ENV -->|"env vars"| SETTINGS
DB_RESP -->|"dict"| RESP_MODEL
REQ_MODEL -->|"validated model"| SERVICE
SETTINGS -->|"config model"| SERVICE
RESP_MODEL -->|"validated model"| SERVICE
SERVICE --> JSON_OUT
REQ_MODEL --> SCHEMA
style Validation fill:#e8f4f8,stroke:#2196F3
style Business fill:#f3e8f8,stroke:#9C27B0
入力の種類がAPIリクエスト・環境変数・DBレスポンスとバラバラでも、Pydantic層を通すことでビジネスロジックが常に型保証されたオブジェクトだけを見ればよくなる。この構造にしてからコードの見通しがかなり良くなった。
正直まだ悩んでいるところ
半年運用してきて、まだ完全に答えが出ていない部分もある。正直に書いておく。
継承の設計は今でも迷う。Pydanticモデルの継承は動くけど、深くなると挙動が複雑になる。例えばこういうケース:
from pydantic import BaseModel
from typing import Literal, Annotated, Union
from pydantic import Discriminator, Tag, TypeAdapter
class BaseEvent(BaseModel):
event_type: str
timestamp: float
user_id: int
class PurchaseEvent(BaseEvent):
event_type: Literal['purchase']
amount: float
currency: str
product_ids: list[int]
class RefundEvent(BaseEvent):
event_type: Literal['refund']
original_purchase_id: int
refund_amount: float
reason: str
# Discriminated Unionで型判別
Event = Annotated[
Union[
Annotated[PurchaseEvent, Tag('purchase')],
Annotated[RefundEvent, Tag('refund')],
],
Discriminator('event_type')
]
event_adapter = TypeAdapter(Event)
raw = {"event_type": "purchase", "timestamp": 1234567890.0,
"user_id": 42, "amount": 9800.0, "currency": "JPY",
"product_ids": [1, 2, 3]}
parsed = event_adapter.validate_python(raw)
print(type(parsed)) # <class 'PurchaseEvent'>
Discriminated Unionは便利なんだけど、イベントの種類が20〜30を超えてくると管理が大変になってくる。イベント駆動アーキテクチャの実装ガイドでも触れているが、イベントスキーマの管理は別途Schema Registryを検討したほうがいいケースもある。
もう一つ、パフォーマンスクリティカルな箇所でどこまでPydanticを使うか。バリデーション済みのデータをループ処理する場合、モデルをそのまま使うより一度model_dump()してdictで処理したほうが速いケースがある。ここは正直まだ検証中で、チームで基準が統一できていない。
# パフォーマンス重視のシリアライズ
# include/excludeで不要なフィールドを除いてAPI返却
response_data = user.model_dump(
include={'id', 'name', 'email'}, # 必要なフィールドだけ
exclude_none=True, # Noneのフィールドを除く
mode='json', # datetimeなどをJSONシリアライズ可能な型に変換
)
includeで返すフィールドを絞る習慣はいいと思っている。全フィールドを返してしまうと内部の実装詳細が漏れてしまうし、余計なデータを乗せてしまうことにもなる。
皆さんのチームではPydanticのモデル設計、どんな基準で分けてますか?特に大規模プロジェクトでのモデル分割の粒度、気になる。
まとめ
半年間Pydantic v3を本番で使い続けた感想を正直に書いた。要点を絞るとこうなる。
- PartialModelのネイティブサポートでPATCH処理が劇的に書きやすくなった。今まで手書きしてたパターンを全部置き換えた
model_validate_json()を積極的に使うと、JSONパースとバリデーションをRustが一気にやってくれてパフォーマンスが上がる- Annotatedカスタム型でバリデーションロジックを型として定義すると、再利用性が上がってレビューコストが減る
pydantic-settingsで設定バリデーションすると、間違った環境変数で起動できなくなって本番事故が減る- JSONスキーマ自動生成をCIに組み込むと、フロントとの仕様ズレが激減する
既存プロジェクトでPydanticを使っているなら、まずmodel_validate_json()に切り替えられる箇所を探してみてほしい。あとカスタムAnnotated型、一度作ってみると「なんで今まで毎回書いてたんだ」と思うはず。Pydantic v3のPartialModelは特に、REST APIを書いているなら確実に恩恵がある機能なので、次のPATCHエンドポイント実装のタイミングで試してみてください。