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.
- Bad UX: the English technical message is inappropriate for an end-user-facing form. You can't localize or adjust the wording.
- Security risk:
input_value='...'contains the raw value the user sent. Passwords and credit-card numbers leak into the error response and logs. - Unstable contract: if the front-end depends on the human-readable
msgstring, 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.
| 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
typeandlocthe contract: what the client should branch on is thetype(error code) andloc(location) that are stable across versions.msgis merely a human-facing display string and can change on a Pydantic update. Code that branches withif 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 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
assertfor production validation:assert v > 0is readable, but launch Python with the-O(optimize) flag and allassertstatements are skipped. Use the optimize flag in production and validation is wholesale disabled — this can become a serious security hole. UseValueErrororPydanticCustomErrorfor 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(orhide_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'sexceptand you conceal even unrelated errors. Always narrow the target witherr.errors()[0]["type"]and re-throw what you don't handle withraise. To replace with a friendly message, re-throwing aPydanticCustomErrorin theexceptclause is the right way. For details ofWrapValidator, 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:
messageis localized and can be put directly into a toast, etc. - Accessibility (a11y):
field(derived fromloc) corresponds 1:1 to a form field, so the front-end can display an inline error tied witharia-describedbybelow 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
codeis a stable code, the client can branch language-independently. Keep the contract offieldandcodeand you can improve the wording ofmessageanytime (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.
- Make
typeandlocthe contract.msgis for display; don't depend on it. - In validators, use
ValueError/PydanticCustomError.assertis removed by-O, so don't use it for production validation. - Define stable error codes and interpolated messages with
PydanticCustomError. - Realize localization with a dictionary mapping keyed on
error["type"](Pydantic has no built-in i18n). - Prevent confidential-input leaks with
hide_input_in_errors/include_input=False(security design). - Use
WrapValidatorfor fallback / kinder messages. But beware swallowing. - 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.