# Pydantic エラーハンドリング実践ガイド：ValidationErrorを使いやすい・安全なAPIエラーに変える

> Pydantic v2公式ドキュメントに忠実に、ValidationError.errors()とErrorDetailsの構造、ValueError/AssertionError/PydanticCustomErrorの使い分け、テンプレート付きカスタムメッセージ、error['type']を鍵にした多言語化、hide_input_in_errorsによる機密漏洩防止、FastAPIでのAPIエラー封筒への変換までを、UX・a11y・セキュリティの観点で実コード解説します。

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: Python, Pydantic, バリデーション, 型安全, FastAPI, セキュリティ
- URL: https://tomodahinata.com/blog/pydantic-validation-error-handling-custom-messages-api-guide

## 要点

- ValidationErrorの既定の表示は開発者向け。クライアントAPIには『安定したtype＋loc』を契約として返し、msgはUI/言語に応じて差し替える設計が要る
- バリデータ内ではValueError/AssertionError/PydanticCustomErrorを使い分ける。assertは python -O で消えるため本番検証には使わない
- PydanticCustomErrorはtypeとテンプレート文（{key}補間）を保持でき、メッセージの多言語化はerror['type']を鍵にした辞書マッピングで実現する（Pydanticにi18nは内蔵されない）
- hide_input_in_errorsとerrors(include_input=False)で、エラーに混入する機密入力値の漏洩を防ぐ。これはセキュリティ設計の一部
- locはフォーム項目に対応づき、フィールド単位のアクセシブルなインラインエラー表示に使える。FastAPIではRequestValidationErrorを安定した封筒に整形する

---

## **導入：`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 つの問題が起きます。

1. **UX が悪い**：英語の技術的なメッセージは、エンドユーザー向けのフォームには不適切。日本語化・文言調整ができない。
2. **セキュリティリスク**：`input_value='...'` に**ユーザーが送った生の値**が含まれる。パスワードやクレジットカード番号がエラーレスポンスやログに漏れる。
3. **契約が不安定**：人間可読の `msg` 文字列にフロントが依存すると、Pydantic のバージョンアップで文言が変わった瞬間にクライアントが壊れる。

本記事は、`ValidationError` を**「使いやすく・安全で・安定した」API エラー**へ変換する設計を、[公式ドキュメント](https://pydantic.dev/docs/validation/latest/errors/errors/) に忠実に解説します。LLM 出力の検証エラーを再プロンプトに使う話は [LLM 構造化出力ガイド](/blog/pydantic-llm-structured-output-json-schema-validation-guide) で扱いました。本記事は**人間のクライアント（フォーム UI・モバイルアプリ・外部 API 利用者）**に向けたエラー設計です。

---

## **1. `ValidationError` の構造を読む：安定した契約は `type` と `loc`**

`ValidationError` は、見つかった**全エラーをまとめて**保持します（フィールドごとに例外が飛ぶのではない）。`.errors()` で構造化リストとして取り出せます。

```python
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` に集約します。

```python
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` を使います。

```python
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"]` を鍵にした辞書で、メッセージを差し替える**だけです。

```python
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` には**ユーザーが送信した生の値**が入ります。これは諸刃の剣です。

```python
# ❌ 危険：パスワードやカード番号が input_value としてレスポンス・ログに露出する
{'type': 'string_too_short', 'loc': ('password',), 'input': 'hunter2', ...}
```

対策は 2 段階です。

**① モデル単位で入力値を隠す**（`ConfigDict(hide_input_in_errors=True)`）：

```python
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`**：

```python
# クライアントへ返す／ログに出す前に input を必ず落とす
safe_errors = e.errors(include_input=False, include_url=False)
```

> ⚠️ **これはセキュリティ設計である**：検証エラーは「ユーザーが送った不正データ」を含むため、エラーレスポンスとログは**機密漏洩の経路**になりえます。`include_input=False`（または `hide_input_in_errors`）を**外部境界の既定**にし、生の入力値はサーバー内の安全なデバッグログにのみ、しかも PII をマスクして残す。CLAUDE.md の「PII をログに残さない」「外部入力を信頼しない」原則の、エラー処理版です。シークレットそのものは [SecretStr](/blog/pydantic-settings-configuration-management-secrets-guide) で型レベルでも守れます。

---

## **6. `WrapValidator`：フォールバックとフレンドリーなメッセージ**

「検証に失敗したら、エラーにせず気を利かせて補正したい」「Pydantic の標準エラーを、もっと親切なメッセージに置き換えたい」——`mode="wrap"` のバリデータが、標準検証を**包んで**この制御を可能にします。

```python
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` の詳細は [高度な型・カスタムバリデータ](/blog/pydantic-custom-types-annotated-validators-advanced-guide) を参照してください。

---

## **7. API エラー封筒に変換する：FastAPI を例に（UX・a11y まで）**

最後に、これらを束ねて**安定した API エラー封筒**を作ります。FastAPI では、リクエストボディの検証失敗は `RequestValidationError`（`pydantic.ValidationError` とは別物、`.body` を持つ）として上がるので、例外ハンドラで整形します。

```python
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})
```

返るのは、安定した封筒です。

```json
{ "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 のフォーム設計](/blog/react-hook-form) と対称的な発想で、サーバーとクライアントの双方で検証境界を持つのが堅牢です。

---

## **結論：エラーを「契約」として設計する**

`ValidationError` を本番品質の API エラーへ変えるのは、文字列をいじることではなく、**エラーを契約として設計する**ことです。本記事の要点を再掲します。

1. **契約にするのは `type` と `loc`**。`msg` は表示用で、依存してはいけない。
2. バリデータでは **`ValueError` / `PydanticCustomError`** を使う。**`assert` は `-O` で消える**ので本番検証に使わない。
3. **`PydanticCustomError`** で安定したエラーコードと補間メッセージを定義する。
4. 多言語化は **`error["type"]` を鍵にした辞書マッピング**で実現する（Pydantic に i18n は内蔵されない）。
5. **`hide_input_in_errors` / `include_input=False`** で、機密入力の漏洩を防ぐ（セキュリティ設計）。
6. **`WrapValidator`** でフォールバック・親切なメッセージへ。ただし握り潰しに注意。
7. **安定した API エラー封筒**（`field` ＋ `code` ＋ `message`）に整形し、UX・a11y・拡張性を同時に満たす。

エラー設計は、後回しにされがちでありながら、プロダクトの信頼性とユーザー体験を最も雄弁に語る部分です。Pydantic の構造化エラーを土台にすれば、「使いやすく・安全で・壊れにくい」エラー契約を、宣言的に組み上げられます。

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

- [Error Handling](https://pydantic.dev/docs/validation/latest/errors/errors/)
- [Validation Errors（`type` 一覧）](https://pydantic.dev/docs/validation/latest/errors/validation_errors/)
- [Validators](https://pydantic.dev/docs/validation/latest/concepts/validators/)

---

### **堅牢な API エラー設計・入力検証のご相談**

筆者は、経済産業大臣賞を受賞した B2B SaaS の基幹 API を設計・運用し、フォーム・明細・承認といった複雑な入力の検証とエラー体験を作り込んできました。エラー設計は、UX・アクセシビリティ・セキュリティ・クライアント契約の安定性が交差する、地味だが事業価値の高い領域です。Pydantic / FastAPI を用いた **検証境界の設計・多言語対応のエラー封筒・機密漏洩を防ぐエラー処理・アクセシブルなフォーム連携**を、生成 AI を活用して高速かつ高品質に実装します。お気軽にご相談ください。
