Turn FastAPI Validation Errors Into Clean Per-Field Messages
A user fills in your signup form, leaves the email field blank, and hits submit. Your React app POSTs to a FastAPI backend, the request fails validation, and the frontend needs to show a red message under the email box. So the frontend developer opens the network tab and finds this waiting for them:
{"detail":[{"type":"missing","loc":["body","user","email"],"msg":"Field required","input":{"user":{"name":"Ada"}}}]}
Now they have to write code to dig detail[0].loc[2] out of that, decide which array index maps to which form field, and pray the shape does not change. It will change. This is the moment every FastAPI team discovers there is no first-party helper for turning validation errors into something a human can read, and each one hand-rolls its own, badly.
The fix is a single custom exception handler for RequestValidationError that reshapes the payload into {field: message} once, in one place, for your whole API. Here is how to write one that does not silently break when you upgrade Pydantic.
Register a handler for RequestValidationError and flatten each error yourself. Build the field name by dropping the leading body/query/path segment from loc and joining the rest with dots. Key your translations off the machine-readable type field (missing, string_type, value_error), never off the English msg string, which Pydantic v2 reworded from v1.
from fastapi import Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
fields = {}
for err in exc.errors():
loc = [p for p in err["loc"] if p not in ("body", "query", "path")]
field = ".".join(str(p) for p in loc) or "__root__"
fields.setdefault(field, err["msg"])
return JSONResponse(status_code=422, content={"errors": fields})
What the default 422 body actually looks like
FastAPI validates request bodies with Pydantic, and when validation fails it returns HTTP 422 with a body whose detail is a list of raw Pydantic error objects. Each object has the same shape:
{
"detail": [
{
"type": "missing",
"loc": ["body", "user", "email"],
"msg": "Field required",
"input": {"user": {"name": "Ada"}}
},
{
"type": "string_type",
"loc": ["body", "user", "name"],
"msg": "Input should be a valid string",
"input": 42
}
]
}
Every field is there for a reason, and every one of them is designed for a program, not a person:
typeis a stable machine identifier for the kind of error (missing,string_type,greater_than, and so on). This is the field you build logic on.locis a tuple describing where the bad value is. For a JSON body it starts with the literal stringbody, then walks down through nested models:("body", "user", "email").msgis an English sentence meant for developers reading a stack trace. It is not a UI string and Pydantic does not promise its wording is stable.inputis the value that failed. Useful for debugging, dangerous to echo back to a browser because it can contain data the user typed into other fields.
The reason there is no built-in flattener is that FastAPI cannot know what your frontend wants. A form wants {email: "Required"}. A GraphQL-style client wants an array with paths. A mobile app wants localised strings. So FastAPI hands you the structured data and gets out of the way. Your job is to reshape it once.
Why the leading segment of loc is there
The first element of loc tells you where in the request the value came from: body, query, path, header, or cookie. That is genuinely useful information for the server, but a browser form does not have a field literally called body.user.email. It has a field called email, or maybe user.email.
So the transformation you want is: drop the source segment, keep the rest, and join it into a path your frontend recognises. For a top-level field ("body", "email") becomes email. For a nested model ("body", "user", "email") becomes user.email. For a list item ("body", "items", 0, "price") becomes items.0.price, because loc uses integer indices for array positions and you must str() them before joining.
The fix, step by step
Step 1: Register the exception handler
FastAPI already installs a default handler for RequestValidationError. You override it by registering your own with the same decorator. Put this next to your app = FastAPI() line:
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi import status
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = {}
for err in exc.errors():
loc = [p for p in err["loc"] if p not in ("body", "query", "path", "header", "cookie")]
field = ".".join(str(p) for p in loc) or "__root__"
# first error per field wins; keep it simple for the UI
errors.setdefault(field, err["msg"])
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"errors": errors},
)
Send a bad request and you now get a flat object instead of a nested list:
{"errors": {"user.email": "Field required", "user.name": "Input should be a valid string"}}
That alone solves the frontend developer's problem. The rest is polish.
Step 2: Replace raw msg with your own copy, keyed on type
The msg strings are fine for an internal tool but "Input should be a valid string" is not what you want under a form field. Map the type to your own wording. Crucially, key on type, not on the English text:
MESSAGES = {
"missing": "This field is required.",
"string_type": "Must be text.",
"int_parsing": "Must be a whole number.",
"value_error": "Not a valid value.",
"string_too_short": "Too short.",
"greater_than_equal": "Too small.",
}
def friendly(err: dict) -> str:
return MESSAGES.get(err["type"], err["msg"]) # fall back to Pydantic's text
Falling back to err["msg"] for unmapped types means an error you have not seen yet still produces something readable rather than a KeyError. Add mappings as you meet them.
Step 3: Never echo input back to the client
The raw error contains input, the value that failed. If a user typos their password into the email field, that value is now in your validation payload. Do not include it in the response. The handler above never reads input, which is the correct default. Only add it back behind an explicit debug flag that is off in production.
The Pydantic v2 trap that breaks handlers silently
Here is the failure that catches teams months after they ship. In Pydantic v1, a missing field produced msg: "field required", lowercase. In Pydantic v2 the same error reads msg: "Field required", capitalised, and the type changed from value_error.missing to plain missing. If your v1 handler did anything like this:
# v1-era code that breaks silently on v2
if err["msg"] == "field required":
message = "This field is required."
then after upgrading to Pydantic v2 that branch never matches. No exception is raised. Your users just start seeing the raw fallback message instead of your nice copy, and nobody notices until a support ticket arrives. This is why you match on type: missing is a stable identifier, the sentence is not. If you are doing a broader v1 to v2 move, the Pydantic v1 to v2 migration gotchas that are not in the official guide will save you a few more of these.
| Aspect | Pydantic v1 | Pydantic v2 |
|---|---|---|
Missing field type | value_error.missing | missing |
Missing field msg | field required | Field required |
Wrong type type | type_error.str | string_type |
| Safe to match on | type | type |
Localising the messages
The same rule scales to translation. To show errors in French or Swahili, do not parse the English msg string, because its wording is not guaranteed stable across Pydantic releases and translating free text at runtime is a dead end. Instead map type to a translation key:
TRANSLATIONS = {
"sw": {"missing": "Sehemu hii inahitajika.", "string_type": "Lazima iwe maandishi."},
"fr": {"missing": "Ce champ est obligatoire.", "string_type": "Doit etre du texte."},
}
def localise(err: dict, lang: str) -> str:
table = TRANSLATIONS.get(lang, {})
return table.get(err["type"], err["msg"])
Some error types carry a ctx dict with values you need for a good message, for example ctx: {"limit_value": 3} on a "too short" error. Read those from err.get("ctx", {}) and interpolate them into your translated template rather than shipping the number in English prose.
Verification
Prove the handler works with a deliberately broken request. Assuming an endpoint expecting a user object with email and name:
curl -s -X POST http://localhost:8000/signup \
-H 'Content-Type: application/json' \
-d '{"user": {"name": 42}}' | python3 -m json.tool
Expected output, flat and UI-ready, with no body prefix and no input leak:
{
"errors": {
"user.email": "This field is required.",
"user.name": "Must be text."
}
}
Add a quick test so an upgrade cannot regress it:
def test_validation_shape(client):
r = client.post("/signup", json={"user": {"name": 42}})
assert r.status_code == 422
body = r.json()
assert "errors" in body
assert body["errors"]["user.email"] == "This field is required."
assert "input" not in str(body) # no raw value leaked
What people get wrong
Matching on the English msg string. Covered above, and it is the single most common way these handlers rot. The sentence is documentation, not an API contract.
Returning a list instead of a map. A list of {loc, msg} objects is barely better than the default because the frontend still has to search it to find the field it cares about. A {field: message} object lets the UI do errors["email"] directly. Use a list only if a single field can have several simultaneous errors you must show at once.
Swallowing the status code. Some handlers return 400 or even 200 with an error body. Keep it 422 for validation failures. That is the semantically correct code, it is what the default does, and monitoring that counts 4xx by class depends on it. If you return 200, your error rate dashboards go blind. This is the same principle as reading the real cause behind a FastAPI 422 unprocessable entity before you start guessing.
Echoing input back. Convenient in development, a data leak in production. Gate it behind a setting.
When it is still broken
- Your handler never fires. You probably raised a plain
ValidationErrorfrom inside your own code rather than letting FastAPI raiseRequestValidationErrorat the request boundary. The handler only catches request validation. For model validation you trigger manually, catchpydantic.ValidationErrorseparately. - Query and path errors look wrong. Their
locstarts withqueryorpath, notbody. The filter list in Step 1 already strips all of them; if you only strippedbody, a bad query param shows up asquery.page. - Nested list indices confuse the frontend.
items.0.priceis correct but some form libraries wantitems[0].price. That is a formatting choice in yourjoin, not a FastAPI limitation. Pick one convention and document it. - A third-party dependency validates internally. Some libraries run their own Pydantic models and raise their own errors. Those will not pass through your request handler. Wrap the call and translate at that boundary too.
Frequently asked questions
- How do I customise FastAPI's 422 validation error response?
- Register a handler with @app.exception_handler(RequestValidationError). Inside it, loop over exc.errors(), build a clean field path from each error's loc tuple, and return a JSONResponse with status 422 and your own shape. This overrides FastAPI's default handler for the whole app.
- Why does my validation error handler break after upgrading Pydantic?
- Because it matched on the English msg string, and Pydantic v2 reworded those messages (for example 'field required' became 'Field required') and changed some type codes. Match on the stable type field like 'missing' or 'string_type' instead, and your handler survives upgrades.
- What does the loc field in a FastAPI validation error mean?
- loc is a tuple locating the bad value in the request. It starts with the source ('body', 'query', 'path') then walks down through nested models, using integer indices for list positions. Drop the source segment and join the rest to get a field path your frontend recognises.
- How do I translate FastAPI validation messages?
- Map the error's type field to a translation key rather than parsing the English msg text, which is not guaranteed stable. For messages that need numbers like a minimum length, read the ctx dict on the error and interpolate its values into your translated template.