Skip to main content
友田 陽大
Pydantic & type-safe validation
Python
Pydantic
バリデーション
型安全
FastAPI
セキュリティ

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
Reading time
10 min read
Author
友田 陽大
Share

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, 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. 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().

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.

KeyMeaningRole in the API contract
typethe machine-readable ID of the error kind (int_parsing, etc.)use as the error code (stable)
locthe error location (a tuple; nesting is ('items', 0, 'price'))correspond to a form field
msgthe human-readable explanationfor display. swap per language/UI
inputthe raw value passed to validationfor debug. don't expose externally (chapter 5)
ctxthe values used for message interpolation ({'ge': 18})use for dynamic wording generation
urlthe URL of the error-explanation pageusually 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.

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 thrownResulting typeCharacteristic
ValueErrorvalue_errorthe most common. msg is prefixed with "Value error, "
AssertionError (assert)assertion_erroreasy but removed by python -O
PydanticCustomErroryour own stringfully 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.

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"].

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.

# ❌ 危険:パスワードやカード番号が 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)):

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:

# クライアントへ返す/ログに出す前に 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.


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.

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.


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.

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.

{ "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, 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.


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.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading