# Practical Pydantic error-handling guide: turn ValidationError into usable, safe API errors

> Faithful to the Pydantic v2 official documentation, with real code from the viewpoints of UX, a11y, and security it explains the structure of ValidationError.errors() and ErrorDetails, the use of ValueError/AssertionError/PydanticCustomError, templated custom messages, localization keyed on error['type'], preventing confidentiality leaks with hide_input_in_errors, and converting to an API error envelope in FastAPI.

- Published: 2026-06-26
- Author: 友田 陽大
- Tags: Python, Pydantic, バリデーション, 型安全, FastAPI, セキュリティ
- URL: https://tomodahinata.com/en/blog/pydantic-validation-error-handling-custom-messages-api-guide
- Category: Pydantic & type-safe validation
- Pillar guide: https://tomodahinata.com/en/blog/pydantic-v2-production-validation-type-safety

## Key points

- ValidationError's default display is for developers. A client API needs a design that returns a 'stable type + loc' as a contract and swaps msg per UI/language.
- Inside a validator, use ValueError/AssertionError/PydanticCustomError. Since assert is removed by python -O, don't use it for production validation.
- PydanticCustomError can hold a type and a template string ({key} interpolation), and message localization is realized by a dictionary mapping keyed on error['type'] (Pydantic has no built-in i18n).
- With hide_input_in_errors and errors(include_input=False), prevent the leak of confidential input values mixed into errors. This is part of security design.
- loc corresponds to form fields and can be used for accessible per-field inline error display. In FastAPI, format RequestValidationError into a stable envelope.

---

## **Introduction: don't return `ValidationError` to the client as-is, "for developers"**

When Pydantic fails validation, it throws a `ValidationError`. Its default display is actually well-made.

```
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]
```

But this is a **developer-facing** display. Return this as-is as an API response to the client and 3 problems arise.

1. **Bad UX**: the English technical message is inappropriate for an end-user-facing form. You can't localize or adjust the wording.
2. **Security risk**: `input_value='...'` contains **the raw value the user sent.** Passwords and credit-card numbers leak into the error response and logs.
3. **Unstable contract**: if the front-end depends on the human-readable `msg` string, the client breaks the moment the wording changes on a Pydantic version-up.

This article explains, faithful to the [official documentation](https://pydantic.dev/docs/validation/latest/errors/errors/), the design to convert `ValidationError` into **"usable, safe, and stable" API errors.** Using LLM-output validation errors for re-prompting was handled in the [LLM structured-output guide](/blog/pydantic-llm-structured-output-json-schema-validation-guide). This article is error design for **human clients (form UIs, mobile apps, external API users).**

---

## **1. Read `ValidationError`'s structure: the stable contract is `type` and `loc`**

`ValidationError` holds **all errors found together** (an exception doesn't fly per field). Extract it as a structured list with `.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',
    # }
```

The keys of `ErrorDetails` (the contents of each error) are as follows.

| Key | Meaning | Role in the API contract |
| --- | --- | --- |
| `type` | the **machine-readable ID** of the error kind (`int_parsing`, etc.) | **use as the error code (stable)** |
| `loc` | the error location (a tuple; nesting is `('items', 0, 'price')`) | **correspond to a form field** |
| `msg` | the human-readable explanation | for display. **swap per language/UI** |
| `input` | the raw value passed to validation | for debug. **don't expose externally** (chapter 5) |
| `ctx` | the values used for message interpolation (`{'ge': 18}`) | use for dynamic wording generation |
| `url` | the URL of the error-explanation page | usually unneeded by the client |

> 💡 **Make `type` and `loc` the contract**: what the client should branch on is the **`type` (error code) and `loc` (location)** that are stable across versions. `msg` is merely a human-facing display string and can change on a Pydantic update. Code that branches with `if err["msg"] == "..."` will surely break in the future. You can get a JSON string with `.json()` and the count with `.error_count()`.

---

## **2. How to raise errors in a validator: use 3 exceptions**

To convey "it's invalid" in custom validation, **you must not throw `ValidationError` itself.** Instead, throw one of the following 3, and Pydantic catches it and aggregates it into the final `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
```

| Exception thrown | Resulting `type` | Characteristic |
| --- | --- | --- |
| `ValueError` | `value_error` | the most common. `msg` is prefixed with `"Value error, "` |
| `AssertionError` (`assert`) | `assertion_error` | easy but **removed by `python -O`** |
| `PydanticCustomError` | **your own string** | fully control `type` and the template string |

> ⚠️ **Don't use `assert` for production validation**: `assert v > 0` is readable, but launch Python with the `-O` (optimize) flag and **all `assert` statements are skipped.** Use the optimize flag in production and validation is wholesale disabled — this can become a serious security hole. Use `ValueError` or `PydanticCustomError` for production validation logic.

---

## **3. Custom messages: control the code and wording with `PydanticCustomError`**

`ValueError` is easy, but its `type` is always `value_error` and can't be used as an error code. When you want **a stable error code and an interpolatable message**, use `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)`'s `message_template` has **`{key}` placeholders** interpolated with the values of the `context` dict. Align `type` to your own domain's error-code system (`over_capacity`, `invalid_coupon`…) and **the client can branch by a stable code**, and using `ctx` you can also **build the wording independently on the front-end side.**

---

## **4. Format and localize for users: map keyed on `type`**

Pydantic has **no built-in i18n (localization) feature.** But there's a standard the official shows — just **swap the message with a dictionary keyed on `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
```

**Why is this superior?**
The point is to make the key **the machine-readable `type`, not the human-readable `msg`.** Since `type` is stable across versions, the translation dictionary doesn't break even when you bump Pydantic. Re-interpolate `ctx` (`{'ge': 18}`, etc.) with `template.format(**ctx)` and **wording containing dynamic numbers** like "18 or more" can also be assembled naturally per language. Language selection (the `Accept-Language` header, etc.) is the app side's responsibility — just swap the dictionary to switch `MESSAGES_EN` / `MESSAGES_JA`.

---

## **5. Security: don't leak the "confidential input" mixed into errors**

As touched on in chapter 1, `ErrorDetails`'s `input` contains **the raw value the user submitted.** This is a double-edged sword.

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

The remedy is 2-stage.

**① Hide the input value per model** (`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
```

This makes `input_value=...` disappear from the `str(e)` display.

**② Use `include_input=False` when returning externally**:

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

> ⚠️ **This is security design**: because a validation error includes "the invalid data the user sent," the error response and logs can become **a path of confidentiality leakage.** Make `include_input=False` (or `hide_input_in_errors`) **the default of the external boundary**, and leave the raw input value only in safe debug logs inside the server, and even then with PII masked. It's the error-handling version of CLAUDE.md's "don't leave PII in logs" and "don't trust external input." The secret itself can also be protected at the type level with [SecretStr](/blog/pydantic-settings-configuration-management-secrets-guide).

---

## **6. `WrapValidator`: fallback and friendly messages**

"On validation failure, don't error but cleverly correct," "replace Pydantic's standard error with a kinder message" — a `mode="wrap"` validator **wraps** the standard validation and enables this control.

```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'（切り詰められた）
```

> ⚠️ **Beware swallowing**: swallow anything in `WrapValidator`'s `except` and you **conceal even unrelated errors.** Always narrow the target with `err.errors()[0]["type"]` and re-throw what you don't handle with `raise`. To replace with a friendly message, re-throwing a `PydanticCustomError` in the `except` clause is the right way. For details of `WrapValidator`, see [advanced types / custom validators](/blog/pydantic-custom-types-annotated-validators-advanced-guide).

---

## **7. Convert to an API error envelope: FastAPI as an example (through UX and a11y)**

Finally, bundle these to make a **stable API error envelope.** In FastAPI, a request-body validation failure rises as `RequestValidationError` (a different thing from `pydantic.ValidationError`, having `.body`), so format it in an exception handler.

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

What returns is a stable envelope.

```json
{ "errors": [
  { "field": "email", "code": "missing", "message": "必須項目です" },
  { "field": "items.0.price", "code": "greater_than", "message": "0 より大きい値を入力してください" }
] }
```

**Why is this form superior?**

- **UX**: `message` is localized and can be put directly into a toast, etc.
- **Accessibility (a11y)**: `field` (derived from `loc`) **corresponds 1:1 to a form field**, so the front-end can display an inline error tied with `aria-describedby` below the relevant `<input>`. It correctly conveys to screen-reader users "which field, and why it's wrong." It's far more accessible than an implementation that only collects errors at the top of the page.
- **Stability/extensibility**: since `code` is a stable code, the client can branch language-independently. Keep the contract of `field` and `code` and you can improve the wording of `message` anytime (ETC).

This envelope can also be leaned toward RFC 7807 (Problem Details). What matters is the principle of **"separating the machine-readable `code`, `field` and the human-facing `message`."** The front-end form implementation, with the same idea as [React Hook Form × Zod form design](/blog/react-hook-form), is robust by having a validation boundary on both the server and the client.

---

## **Conclusion: design errors as a "contract"**

Turning `ValidationError` into a production-quality API error is not tweaking strings but **designing errors as a contract.** Restating the key points of this article.

1. **Make `type` and `loc` the contract.** `msg` is for display; don't depend on it.
2. In validators, use **`ValueError` / `PydanticCustomError`.** **`assert` is removed by `-O`**, so don't use it for production validation.
3. Define stable error codes and interpolated messages with **`PydanticCustomError`.**
4. Realize localization with a **dictionary mapping keyed on `error["type"]`** (Pydantic has no built-in i18n).
5. Prevent confidential-input leaks with **`hide_input_in_errors` / `include_input=False`** (security design).
6. Use **`WrapValidator`** for fallback / kinder messages. But beware swallowing.
7. Format into a **stable API error envelope** (`field` + `code` + `message`) and satisfy UX, a11y, and extensibility at once.

Error design, while tending to be postponed, is the part that most eloquently speaks of a product's reliability and user experience. Build on Pydantic's structured errors and you can declaratively assemble a "usable, safe, hard-to-break" error contract.

As official primary sources, I recommend re-reading the following from this article's viewpoint.

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

---

### **Consulting on robust API error design / input validation**

The author has designed and operated the core API of a B2B SaaS that won the Minister of Economy, Trade and Industry Award, building the validation and error experience of complex inputs like forms, line items, and approvals. Error design is a plain but business-value-high area where UX, accessibility, security, and the stability of the client contract intersect. I implement **the design of validation boundaries, localized error envelopes, error handling that prevents confidentiality leaks, and accessible form integration** using Pydantic / FastAPI, quickly and at high quality with generative AI. Feel free to reach out.
