Customising Validation Error Responses in FastAPI

Key takeaways:

  • Register @app.exception_handler(RequestValidationError) and return your own JSONResponse — it replaces FastAPI's built-in handler application-wide.
  • exc.errors() gives you the full Pydantic error list: type, loc, msg, input and often ctx.
  • 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 debug block 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.

Where a validation error is turned into a response Request parsing raises RequestValidationError before the endpoint runs. The registered handler receives the error list and builds either the default detail body or a custom envelope. Request body Pydantic validation endpoint has not run RequestValidationError carries exc.errors() default handler 422 with a detail array Pydantic vocabulary your handler your envelope and codes raw detail kept for debugging Registering a handler replaces the default for the whole application.
Validation fails before your endpoint exists in the picture. The only place to shape the response is the handler.

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.