導入:ValidationError は「開発者向け」のまま返してはいけない
Pydantic が検証に失敗すると ValidationError を投げます。その既定の表示は、実はよくできています。
2 validation errors for SignupForm
email
value is not a valid email address [type=value_error, input_value='not-an-email', input_type=str]
age
Input should be greater than or equal to 18 [type=greater_than_equal, input_value=15, input_type=int]
しかし、これは開発者向けの表示です。これをそのまま API レスポンスとしてクライアントに返すと、3 つの問題が起きます。
- UX が悪い:英語の技術的なメッセージは、エンドユーザー向けのフォームには不適切。日本語化・文言調整ができない。
- セキュリティリスク:
input_value='...'にユーザーが送った生の値が含まれる。パスワードやクレジットカード番号がエラーレスポンスやログに漏れる。 - 契約が不安定:人間可読の
msg文字列にフロントが依存すると、Pydantic のバージョンアップで文言が変わった瞬間にクライアントが壊れる。
本記事は、ValidationError を**「使いやすく・安全で・安定した」API エラーへ変換する設計を、公式ドキュメント に忠実に解説します。LLM 出力の検証エラーを再プロンプトに使う話は LLM 構造化出力ガイド で扱いました。本記事は人間のクライアント(フォーム UI・モバイルアプリ・外部 API 利用者)**に向けたエラー設計です。
1. ValidationError の構造を読む:安定した契約は type と loc
ValidationError は、見つかった全エラーをまとめて保持します(フィールドごとに例外が飛ぶのではない)。.errors() で構造化リストとして取り出せます。
from pydantic import BaseModel, Field, ValidationError
class SignupForm(BaseModel):
email: str
age: int = Field(ge=18)
try:
SignupForm(email="x", age=15)
except ValidationError as e:
print(e.error_count()) # 1
for err in e.errors():
print(err)
# {
# 'type': 'greater_than_equal',
# 'loc': ('age',),
# 'msg': 'Input should be greater than or equal to 18',
# 'input': 15,
# 'ctx': {'ge': 18},
# 'url': 'https://errors.pydantic.dev/2/v/greater_than_equal',
# }
ErrorDetails(各エラーの中身)のキーは次のとおりです。
| キー | 意味 | API 契約での役割 |
|---|---|---|
type | エラー種別の機械可読 ID(int_parsing 等) | エラーコードとして使う(安定) |
loc | エラー箇所(タプル。ネストは ('items', 0, 'price')) | フォーム項目への対応づけ |
msg | 人間可読の説明 | 表示用。言語・UI で差し替える |
input | 検証に渡された生の値 | デバッグ用。外部には出さない(第5章) |
ctx | メッセージ補間に使う値({'ge': 18}) | 文言の動的生成に使う |
url | エラー解説ページの URL | 通常クライアントには不要 |
💡 契約にするのは
typeとloc:クライアントが分岐に使うべきは、バージョン間で安定した **type(エラーコード)とloc(場所)**です。msgは人間向けの表示文字列にすぎず、Pydantic の更新で変わりえます。if err["msg"] == "..."で分岐するコードは将来必ず壊れます。.json()で JSON 文字列に、.error_count()で件数も取れます。
2. バリデータでのエラーの上げ方:3 つの例外を使い分ける
カスタム検証で「不正だ」と伝えるには、ValidationError 自身を投げてはいけません。代わりに次の 3 つのいずれかを投げると、Pydantic が捕捉して最終的な ValidationError に集約します。
from pydantic import BaseModel, field_validator
from pydantic_core import PydanticCustomError
class Order(BaseModel):
quantity: int
coupon: str
@field_validator("quantity")
@classmethod
def positive(cls, v: int) -> int:
if v <= 0:
raise ValueError("数量は1以上") # → type='value_error'(msgに"Value error, "が付く)
return v
@field_validator("coupon")
@classmethod
def valid_coupon(cls, v: str) -> str:
if not v.startswith("CP"):
# → 独自の type と テンプレート文を保持できる(第3章)
raise PydanticCustomError(
"invalid_coupon", "クーポン {code} は無効です", {"code": v}
)
return v
| 投げる例外 | 結果の type | 特徴 |
|---|---|---|
ValueError | value_error | 最も一般的。msg に "Value error, " が前置される |
AssertionError(assert) | assertion_error | 手軽だが**python -O で消える** |
PydanticCustomError | 独自の文字列 | type と テンプレート文を完全制御できる |
⚠️
assertを本番検証に使わない:assert v > 0は読みやすいですが、Python を-O(最適化)フラグ付きで起動するとassert文がすべてスキップされます。本番で最適化フラグを使うと検証が丸ごと無効化される——これは重大なセキュリティホールになりえます。本番の検証ロジックにはValueErrorかPydanticCustomErrorを使ってください。
3. カスタムメッセージ:PydanticCustomError でコードと文言を制御する
ValueError は手軽ですが、type が常に value_error になり、エラーコードとして使えません。安定したエラーコードと、補間可能なメッセージが欲しいときは PydanticCustomError を使います。
from pydantic import BaseModel, ValidationError, field_validator
from pydantic_core import PydanticCustomError
class Booking(BaseModel):
seats: int
@field_validator("seats")
@classmethod
def within_capacity(cls, v: int) -> int:
capacity = 10
if v > capacity:
raise PydanticCustomError(
"over_capacity", # 安定したエラーコード(type)
"予約席数 {requested} は上限 {limit} を超えています", # テンプレート文
{"requested": v, "limit": capacity}, # 補間に使う context
)
return v
try:
Booking(seats=15)
except ValidationError as e:
err = e.errors()[0]
print(err["type"]) # 'over_capacity' ← 自分で決めた安定コード
print(err["msg"]) # '予約席数 15 は上限 10 を超えています'
print(err["ctx"]) # {'requested': 15, 'limit': 10}
PydanticCustomError(error_type, message_template, context) の message_template は {key} プレースホルダを持ち、context 辞書の値で補間されます。type を自分のドメインのエラーコード体系(over_capacity、invalid_coupon…)に揃えれば、クライアントが安定コードで分岐でき、ctx を使えばフロント側で独自に文言を組み立てることもできます。
4. ユーザー向けに整形・多言語化する:type を鍵にマップする
Pydantic には i18n(多言語化)の機能は内蔵されていません。しかし公式が示す定石があります——error["type"] を鍵にした辞書で、メッセージを差し替えるだけです。
from pydantic_core import ErrorDetails
from pydantic import ValidationError
# エラーコード → 表示文言(言語ごとに辞書を切り替えれば多言語化になる)
MESSAGES_JA = {
"int_parsing": "整数を入力してください",
"greater_than_equal": "{ge} 以上の値を入力してください",
"string_too_short": "{min_length} 文字以上で入力してください",
"missing": "必須項目です",
"over_capacity": "上限 {limit} 席を超えています",
}
def localize(e: ValidationError, messages: dict[str, str]) -> list[ErrorDetails]:
result: list[ErrorDetails] = []
for err in e.errors(include_url=False, include_input=False): # 第5章:機密と冗長を落とす
template = messages.get(err["type"])
if template:
ctx = err.get("ctx")
err["msg"] = template.format(**ctx) if ctx else template
result.append(err)
return result
なぜこれが優れているのか?
鍵を人間可読の msg ではなく機械可読の type にするのが要点です。type はバージョン間で安定しているので、Pydantic を上げても翻訳辞書は壊れません。ctx({'ge': 18} 等)を template.format(**ctx) で再補間すれば、「18 以上」のような動的な数値を含む文言も言語ごとに自然に組み立てられます。言語の選択(Accept-Language ヘッダ等)はアプリ側の責務——辞書を差し替えるだけで MESSAGES_EN / MESSAGES_JA を切り替えられます。
5. セキュリティ:エラーに混じる「機密入力」を漏らさない
第1章で触れたとおり、ErrorDetails の input にはユーザーが送信した生の値が入ります。これは諸刃の剣です。
# ❌ 危険:パスワードやカード番号が input_value としてレスポンス・ログに露出する
{'type': 'string_too_short', 'loc': ('password',), 'input': 'hunter2', ...}
対策は 2 段階です。
① モデル単位で入力値を隠す(ConfigDict(hide_input_in_errors=True)):
from pydantic import BaseModel, ConfigDict
class Credentials(BaseModel):
model_config = ConfigDict(hide_input_in_errors=True)
username: str
password: str
これで str(e) の表示から input_value=... が消えます。
② 外部に返す際は include_input=False:
# クライアントへ返す/ログに出す前に input を必ず落とす
safe_errors = e.errors(include_input=False, include_url=False)
⚠️ これはセキュリティ設計である:検証エラーは「ユーザーが送った不正データ」を含むため、エラーレスポンスとログは機密漏洩の経路になりえます。
include_input=False(またはhide_input_in_errors)を外部境界の既定にし、生の入力値はサーバー内の安全なデバッグログにのみ、しかも PII をマスクして残す。CLAUDE.md の「PII をログに残さない」「外部入力を信頼しない」原則の、エラー処理版です。シークレットそのものは SecretStr で型レベルでも守れます。
6. WrapValidator:フォールバックとフレンドリーなメッセージ
「検証に失敗したら、エラーにせず気を利かせて補正したい」「Pydantic の標準エラーを、もっと親切なメッセージに置き換えたい」——mode="wrap" のバリデータが、標準検証を包んでこの制御を可能にします。
from typing import Annotated, Any
from pydantic import (
BaseModel, Field, ValidationError, ValidatorFunctionWrapHandler, field_validator,
)
class Article(BaseModel):
title: Annotated[str, Field(max_length=5)]
@field_validator("title", mode="wrap")
@classmethod
def truncate(cls, value: Any, handler: ValidatorFunctionWrapHandler) -> str:
try:
return handler(value) # 標準検証を実行
except ValidationError as err:
# 長すぎる場合だけ切り詰めて回復。それ以外のエラーは握り潰さず再送出する
if err.errors()[0]["type"] == "string_too_long":
return handler(value[:5])
raise
print(Article(title="abcdefg").title) # 'abcde'(切り詰められた)
⚠️ 握り潰しに注意:
WrapValidatorのexceptで何でも飲み込むと、無関係なエラーまで隠蔽してしまいます。必ずerr.errors()[0]["type"]で対象を絞り、扱わないものはraiseで再送出すること。フレンドリーなメッセージに差し替えたいなら、except節でPydanticCustomErrorを投げ直すのが筋です。WrapValidatorの詳細は 高度な型・カスタムバリデータ を参照してください。
7. API エラー封筒に変換する:FastAPI を例に(UX・a11y まで)
最後に、これらを束ねて安定した API エラー封筒を作ります。FastAPI では、リクエストボディの検証失敗は RequestValidationError(pydantic.ValidationError とは別物、.body を持つ)として上がるので、例外ハンドラで整形します。
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
MESSAGES_JA = { # 第4章の辞書を流用
"missing": "必須項目です",
"string_too_short": "{min_length} 文字以上で入力してください",
}
@app.exception_handler(RequestValidationError)
async def on_validation_error(request: Request, exc: RequestValidationError):
fields = []
for err in exc.errors(): # input は封筒に含めない(第5章)
loc = [p for p in err["loc"] if p not in ("body", "query", "path")]
template = MESSAGES_JA.get(err["type"])
ctx = err.get("ctx") or {}
fields.append({
"field": ".".join(map(str, loc)), # "items.0.price"
"code": err["type"], # 安定したエラーコード
"message": template.format(**ctx) if template else err["msg"],
})
return JSONResponse(status_code=422, content={"errors": fields})
返るのは、安定した封筒です。
{ "errors": [
{ "field": "email", "code": "missing", "message": "必須項目です" },
{ "field": "items.0.price", "code": "greater_than", "message": "0 より大きい値を入力してください" }
] }
なぜこの形が優れているのか?
- UX:
messageは日本語化済みで、そのままトースト等に出せる。 - アクセシビリティ(a11y):
field(loc由来)がフォーム項目に 1:1 対応するので、フロントは該当<input>の下にaria-describedbyで結びつけたインラインエラーを表示できる。スクリーンリーダー利用者にも「どの項目が・なぜ駄目か」が正しく伝わる。エラーをページ上部にまとめるだけの実装より、はるかにアクセシブルです。 - 安定性・拡張性:
codeが安定コードなので、クライアントは言語に依存せず分岐できる。fieldとcodeの契約を保てば、messageの文言はいつでも改善できる(ETC)。
この封筒は RFC 7807(Problem Details)に寄せることもできます。重要なのは**「機械可読な code・field と、人間向けの message を分離する」**という原則です。フロント側のフォーム実装は React Hook Form × Zod のフォーム設計 と対称的な発想で、サーバーとクライアントの双方で検証境界を持つのが堅牢です。
結論:エラーを「契約」として設計する
ValidationError を本番品質の API エラーへ変えるのは、文字列をいじることではなく、エラーを契約として設計することです。本記事の要点を再掲します。
- 契約にするのは
typeとloc。msgは表示用で、依存してはいけない。 - バリデータでは
ValueError/PydanticCustomErrorを使う。assertは-Oで消えるので本番検証に使わない。 PydanticCustomErrorで安定したエラーコードと補間メッセージを定義する。- 多言語化は
error["type"]を鍵にした辞書マッピングで実現する(Pydantic に i18n は内蔵されない)。 hide_input_in_errors/include_input=Falseで、機密入力の漏洩を防ぐ(セキュリティ設計)。WrapValidatorでフォールバック・親切なメッセージへ。ただし握り潰しに注意。- 安定した API エラー封筒(
field+code+message)に整形し、UX・a11y・拡張性を同時に満たす。
エラー設計は、後回しにされがちでありながら、プロダクトの信頼性とユーザー体験を最も雄弁に語る部分です。Pydantic の構造化エラーを土台にすれば、「使いやすく・安全で・壊れにくい」エラー契約を、宣言的に組み上げられます。
公式の一次情報として、以下を本記事の観点で再読することをお勧めします。
堅牢な API エラー設計・入力検証のご相談
筆者は、経済産業大臣賞を受賞した B2B SaaS の基幹 API を設計・運用し、フォーム・明細・承認といった複雑な入力の検証とエラー体験を作り込んできました。エラー設計は、UX・アクセシビリティ・セキュリティ・クライアント契約の安定性が交差する、地味だが事業価値の高い領域です。Pydantic / FastAPI を用いた 検証境界の設計・多言語対応のエラー封筒・機密漏洩を防ぐエラー処理・アクセシブルなフォーム連携を、生成 AI を活用して高速かつ高品質に実装します。お気軽にご相談ください。