Customising Validation Error Responses in FastAPI
Key takeaways:
- Register
@app.exception_handler(RequestValidationError)and return your ownJSONResponse— it replaces FastAPI's built-in handler application-wide. exc.errors()gives you the full Pydantic error list:type,loc,msg,inputand oftenctx.loc[0]is the location (body,query,path,header); the rest is the field path. Drop the first element for a client-friendly name.- Keep the raw errors — in a
debugblock or in your logs — or you lose the most useful diagnostic you have. - Changing the status code from 422 leaves your OpenAPI schema documenting 422 unless you fix that too.
This page builds on global exception handlers for the one exception every API hits constantly: a request that failed validation.
The Problem This Solves
Your API already has an error contract. Every failure returns {"error": {"code": ..., "message": ..., "fields": [...]}}, your clients parse it, your mobile app maps code to a localised string. Then a request fails validation and FastAPI returns {"detail": [...]} — a completely different shape, with Pydantic's internal vocabulary in it, produced before any of your code ran.
Now you have two error contracts, and the one you did not design is the one that fires most often.
Why It Happens: the Default Handler
FastAPI raises RequestValidationError when it cannot build the endpoint's declared parameters from the incoming request — a body field of the wrong type, a missing query parameter, a path segment that will not parse as an int. Note the plural: it collects all failures across body, query, path and headers into one list rather than stopping at the first.
At application construction, FastAPI installs a default handler for that exception which returns 422 with {"detail": exc.errors()}. It is registered in the same exception_handlers mapping that @app.exception_handler writes to, so your registration simply replaces it. There is nothing to disable or opt out of.
The handler runs inside Starlette's ExceptionMiddleware, which — as middleware execution order shows — sits between your middleware stack and the router. Your envelope is therefore a normal response by the time any middleware sees it, and a correlation ID stamped by tracing middleware can be read inside the handler from the request.
The Default Body, for Real
Before replacing something, look at what it produces. This model has three constrained fields and an int path parameter:
class Order(BaseModel):
sku: str = Field(min_length=3)
quantity: int = Field(gt=0)
channel: Literal["web", "store"]
@app.post("/orders/{tenant_id}")
async def create_order(
order: Order,
tenant_id: Annotated[int, Path()],
dry_run: Annotated[bool, Query()] = False,
):
return {"created": order.model_dump(), "tenant_id": tenant_id}
Posting {"sku": "ab", "quantity": 0, "channel": "kiosk"} to /orders/7 produces this — real output from the verification run on FastAPI 0.139.2 with Pydantic 2.13.4:
$ POST /default/orders/7 {"sku": "ab", "quantity": 0, "channel": "kiosk"}
422 Unprocessable Entity
{
"detail": [
{
"type": "string_too_short",
"loc": [
"body",
"sku"
],
"msg": "String should have at least 3 characters",
"input": "ab",
"ctx": {
"min_length": 3
}
},
{
"type": "greater_than",
"loc": [
"body",
"quantity"
],
"msg": "Input should be greater than 0",
"input": 0,
"ctx": {
"gt": 0
}
},
{
"type": "literal_error",
"loc": [
"body",
"channel"
],
"msg": "Input should be 'web' or 'store'",
"input": "kiosk",
"ctx": {
"expected": "'web' or 'store'"
}
}
]
}
Every field you need is already there. type is a stable machine-readable code. loc is the path. ctx carries the constraint values — min_length: 3, gt: 0 — which is what lets you write a message template rather than hard-coding numbers.
A bad path parameter and a missing body produce one combined list:
$ POST /default/orders/not-an-int {}
422 Unprocessable Entity
{
"detail": [
{
"type": "int_parsing",
"loc": [
"path",
"tenant_id"
],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "not-an-int"
},
{
"type": "missing",
"loc": [
"body",
"sku"
],
"msg": "Field required",
"input": {}
},
{
"type": "missing",
"loc": [
"body",
"quantity"
],
"msg": "Field required",
"input": {}
},
{
"type": "missing",
"loc": [
"body",
"channel"
],
"msg": "Field required",
"input": {}
}
]
}
Note that loc[0] differs per entry — path for one, body for the rest. Any handler that assumes everything came from the body will mislabel the first error.
The Fix: Your Own Envelope
from fastapi import FastAPI, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
FIELD_MESSAGES = {
"missing": "This field is required.",
"greater_than": "Must be greater than {gt}.",
"string_too_short": "Must be at least {min_length} characters.",
"literal_error": "Must be one of: {expected}.",
"int_parsing": "Must be a whole number.",
"bool_parsing": "Must be true or false.",
}
def field_path(loc: tuple) -> str:
"""Drop the body/query/path prefix and dot-join the rest, so clients see 'sku'."""
parts = [str(p) for p in loc[1:]] or [str(p) for p in loc]
return ".".join(parts)
def humanise(error: dict) -> str:
template = FIELD_MESSAGES.get(error["type"])
if template is None:
return error["msg"] # Fall back to Pydantic's wording.
ctx = {k: str(v) for k, v in (error.get("ctx") or {}).items()}
try:
return template.format(**ctx)
except KeyError:
return error["msg"]
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
errors = exc.errors()
return JSONResponse(
status_code=422,
content=jsonable_encoder(
{
"error": {
"code": "validation_failed",
"message": "The request body failed validation.",
"fields": [
{
"field": field_path(e["loc"]),
"in": e["loc"][0] if e["loc"] else "body",
"reason": e["type"],
"message": humanise(e),
}
for e in errors
],
# Keep the raw Pydantic detail for debugging / log correlation.
"debug": {"pydantic_errors": errors},
},
"request_id": request.headers.get("x-request-id", "-"),
}
),
)
Four decisions in there are worth calling out.
jsonable_encoder is not optional. exc.errors() embeds the offending input, which can be a Decimal, a datetime, a set, or an arbitrary object the client sent. Passing the raw list to JSONResponse raises a serialisation error inside your exception handler — an unhandled 500 in the code that exists to prevent unhandled errors.
type becomes reason, unchanged. Resist rewriting Pydantic's codes into your own taxonomy. They are stable, documented and searchable; a client that wants to distinguish "too short" from "missing" gets a reliable key for free.
ctx drives the message template. Must be at least {min_length} characters renders correctly for a field with min_length=3 and for one with min_length=64, with no per-field configuration.
Unknown types fall through to msg. Pydantic has dozens of error types and you will not enumerate them all. Falling back to the original wording means an unmapped validator degrades to something slightly technical rather than to a blank string.
The Result, for Real
Same two requests, same models, through the customised application:
$ POST /custom/orders/7 {"sku": "ab", "quantity": 0, "channel": "kiosk"}
422 Unprocessable Entity
{
"error": {
"code": "validation_failed",
"message": "The request body failed validation.",
"fields": [
{
"field": "sku",
"in": "body",
"reason": "string_too_short",
"message": "Must be at least 3 characters."
},
{
"field": "quantity",
"in": "body",
"reason": "greater_than",
"message": "Must be greater than 0."
},
{
"field": "channel",
"in": "body",
"reason": "literal_error",
"message": "Must be one of: 'web' or 'store'."
}
],
"debug": {
"pydantic_errors": [
{
"type": "string_too_short",
"loc": [
"body",
"sku"
],
"msg": "String should have at least 3 characters",
"input": "ab",
"ctx": {
"min_length": 3
}
},
{
"type": "greater_than",
"loc": [
"body",
"quantity"
],
"msg": "Input should be greater than 0",
"input": 0,
"ctx": {
"gt": 0
}
},
{
"type": "literal_error",
"loc": [
"body",
"channel"
],
"msg": "Input should be 'web' or 'store'",
"input": "kiosk",
"ctx": {
"expected": "'web' or 'store'"
}
}
]
}
},
"request_id": "-"
}
Every friendly message came out of the ctx values in the block below it — nothing about "3 characters" or "greater than 0" is hard-coded per field.
And the mixed path-plus-body case, where in correctly distinguishes the two sources:
$ POST /custom/orders/not-an-int {}
422 Unprocessable Entity
{
"error": {
"code": "validation_failed",
"message": "The request body failed validation.",
"fields": [
{
"field": "tenant_id",
"in": "path",
"reason": "int_parsing",
"message": "Must be a whole number."
},
{
"field": "sku",
"in": "body",
"reason": "missing",
"message": "This field is required."
},
{
"field": "quantity",
"in": "body",
"reason": "missing",
"message": "This field is required."
},
{
"field": "channel",
"in": "body",
"reason": "missing",
"message": "This field is required."
}
],
"debug": {
"pydantic_errors": [
{
"type": "int_parsing",
"loc": [
"path",
"tenant_id"
],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "not-an-int"
},
{
"type": "missing",
"loc": [
"body",
"sku"
],
"msg": "Field required",
"input": {}
},
{
"type": "missing",
"loc": [
"body",
"quantity"
],
"msg": "Field required",
"input": {}
},
{
"type": "missing",
"loc": [
"body",
"channel"
],
"msg": "Field required",
"input": {}
}
]
}
},
"request_id": "-"
}
A valid request is unaffected — the handler only fires on failure:
$ POST /custom/orders/7 {"sku": "abc-123", "quantity": 2, "channel": "web"}
200 OK
{
"created": {
"sku": "abc-123",
"quantity": 2,
"channel": "web"
},
"tenant_id": 7
}
Matching an Existing Error Contract
The point of all this is usually consistency with errors your API already returns. Make the validation handler and your other handlers share one builder:
def error_response(status: int, code: str, message: str, *, fields=None, debug=None, request=None):
body = {"error": {"code": code, "message": message}}
if fields:
body["error"]["fields"] = fields
if debug and settings.expose_error_detail:
body["error"]["debug"] = debug
body["request_id"] = request_id_ctx.get() if request else "-"
return JSONResponse(status_code=status, content=jsonable_encoder(body))
Every handler — RequestValidationError, HTTPException, your domain exceptions from HTTPException vs custom exception classes — calls this. One shape, one place to change it. The settings.expose_error_detail gate is what lets you keep debug in staging and drop it in production without a second code path.
If your contract requires 400 rather than 422, change the status in the handler — but also fix the schema, or every generated client will expect a body shape at a status code you never return:
app = FastAPI(responses={400: {"model": ErrorEnvelope}})
Verification
def test_validation_error_matches_the_contract(client):
r = client.post("/orders/7", json={"sku": "ab", "quantity": 0, "channel": "kiosk"})
assert r.status_code == 422
body = r.json()
assert body["error"]["code"] == "validation_failed"
fields = {f["field"]: f for f in body["error"]["fields"]}
assert fields["quantity"]["reason"] == "greater_than" # stable machine code
assert "0" in fields["quantity"]["message"] # ctx rendered in
def test_handler_survives_unserialisable_input(client):
# A NaN float reaches exc.errors() as `input`; jsonable_encoder must cope.
r = client.post("/orders/7", json={"sku": "ok!", "quantity": 1e999, "channel": "web"})
assert r.status_code == 422 # not a 500 from inside the handler
The second test is the one worth keeping. Serialisation failures inside an error handler are the classic way this pattern breaks in production, and they only show up with unusual input.
Trade-Offs and When Not To
Your OpenAPI schema still says 422 with HTTPValidationError. FastAPI generates that from the route definitions, not from your handler, so consumers generating clients from the schema get the wrong shape. Fixing it means declaring your envelope model in responses at the app or router level — worth doing if clients are generated, skippable if they are not.
Error messages are a contract too. Once a mobile client displays your message strings, changing them is a client-visible change. If localisation matters, send reason and ctx and let the client render the text; sending prose is a decision to own the wording forever.
Do not leak input to untrusted clients. The raw error list echoes what the caller sent — including a mistyped password in a login body — straight into your response and, if you log it, into your logs. Gate the debug block on environment, and consider stripping input entirely.
This handler covers inbound validation only. A response that fails its response_model raises ResponseValidationError, which is a bug in your code and correctly produces a 500. Wrapping it in a friendly envelope would hide a real fault; the ordering of that check is covered in response model and serialization order.
A very large error list is a denial-of-service surface. A deeply nested model posted with a thousand bad entries produces a thousand error dicts, each embedding its input. Truncate fields to a sane maximum and note the total.
FAQ
How do I change the default 422 response body in FastAPI?
Register an exception handler for RequestValidationError with app.exception_handler and return your own JSONResponse. FastAPI installs a default handler for that exception at startup and yours replaces it for the whole application.
Should I change the 422 status code to 400? You can, by setting the status code in your handler, and some API contracts require it. The cost is that your OpenAPI schema still documents 422 for every operation unless you also override the generated responses, so clients generated from the schema will be wrong.
How do I know which field failed when loc has several entries?
The first element of loc is the location, such as body, query or path, and the rest is the path to the field. Dropping the first element and joining the remainder with dots gives a client-friendly field name like items.0.price.
Can I keep the original Pydantic errors for debugging?
Yes. exc.errors() returns the full list of dictionaries and you can embed it in a debug section of your envelope, or log it and return only the friendly messages. Run it through jsonable_encoder first because inputs may not be JSON-serialisable.
Does this handler also catch validation errors in my response model?
No. A response that fails its response_model raises ResponseValidationError, which is a server-side fault and produces a 500. Only inbound request validation raises RequestValidationError.
Why does my handler return a 500 instead of my envelope?
Almost always a serialisation failure inside the handler itself, because exc.errors() contains an input value that is not JSON-serialisable. Wrap the content in jsonable_encoder and add a test that posts an exotic value.
Related Reading
- Up to the section: Error Handling and Global Exceptions.
- One envelope for every failure: Global Exception Handlers for Consistent API Responses and HTTPException vs Custom Exception Classes.
- Where the handler sits: Middleware Execution Order.
- What produces these errors: Query, Path and Body Parameter Validation.
- The outbound counterpart: Response Model and Serialization Order.