</>CodeWithKarani

FastAPI 422 Unprocessable Entity: Read the Error Body Instead of Guessing

Karani GeoffreyKarani Geoffrey8 min read

You send a POST to a FastAPI endpoint, you get back a 422 Unprocessable Entity, and the top answer on Stack Overflow has no accepted solution and forty comments all guessing. So you start flailing: you change the model, you switch the parameter to Request and parse the body by hand, you wrap everything in Dict[str, Any] to make the error go away. The error goes away. Then a real bug ships, because you disabled the thing that was trying to help you.

Stop. FastAPI is not being cryptic. It already told you exactly what is wrong, in the response body, in a structured format designed to be read by a machine or a human. Almost nobody reads it. They see the status code, panic, and start changing server code to fix a client problem.

The thesis of this article: a 422 is FastAPI doing its job correctly. The bug is in the request you sent, and the response body names the field. Ninety percent of the time you fix a 422 without touching your endpoint at all.

read the response body. It contains the exact field and reason.

  • The body is {"detail": [{"loc": [...], "msg": "...", "type": "..."}]}. loc is the path to the bad field, e.g. ["body", "price"].
  • Cause #1: your client sent form data, not JSON. Set Content-Type: application/json and JSON-stringify the body.
  • Cause #2: you mixed File()/Form() params with a Pydantic Body() model in one route. Unsupported. Split them.
  • Cause #3: you sent a list where the model expects an object (a classic with pandas to_json(orient="records")).
  • To debug the raw payload, override the validation handler to log exc.errors() and exc.body. Do not disable validation.

The exact error, and what is actually in it

The status line you see:

HTTP/1.1 422 Unprocessable Entity
content-type: application/json

The body, which is the part that matters and the part people ignore:

{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "price"],
      "msg": "Field required",
      "input": {"name": "widget"}
    }
  ]
}

Read loc from left to right. "body" means the problem is in the request body (it could say "query", "path", "header" or "cookie"). "price" is the field. msg is the human reason and type is the machine code. This one says: the body was parsed as JSON fine, but the required field price was not in it. That is not ambiguous. You do not need to guess and you certainly do not need to rewrite the endpoint.

If loc is ["body"] with nothing after it and a message like "Input should be a valid dictionary or object to extract fields from", that is the tell for the big one: FastAPI could not parse the body into an object at all, which almost always means it did not arrive as JSON.

Why this happens: FastAPI validates before your code runs

FastAPI builds a validation layer from your type hints and Pydantic models. When a request arrives, that layer runs before your function body. It reads the raw body, decides how to parse it based on the parameter types you declared, coerces and validates each field, and only then calls your function with clean typed arguments. If any of that fails, your function never runs and FastAPI returns 422 with the detail list above.

The critical implication: a 422 means the request never reached your logic. So changing your logic cannot fix it. The mismatch is between what the client sent on the wire and what your parameters declared. There are only a few ways that mismatch happens, and they are all diagnosable from loc.

client request headers + body validation layer parse by Content-Type, coerce, check the model valid JSON -> your route function runs wrong shape -> 422, function never runs The detail body points at the exact break: loc[0] = "body" -> body could not be parsed or a field is wrong loc[0] = "query" -> a query parameter is missing or the wrong type loc = ["body"] only -> not JSON at all, check Content-Type first
The 422 is thrown at the validation layer, before your function. That is why editing the function does nothing.

The three payload mistakes behind almost every 422

Cause #1: the client is sending form data, not JSON

This is the big one and it hides well, because your endpoint code and your test payload both look right. The problem is the wire format. A route that declares a Pydantic model expects a JSON body with Content-Type: application/json.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    price: float

@app.post("/items")
def create_item(item: Item):
    return item

With axios, sending a plain object usually sets JSON correctly, but sending a URLSearchParams or FormData body, or setting a custom header that overrides the default, makes it arrive as form-encoded. With fetch, if you forget to stringify and set the header, you get the same 422. The correct call:

// fetch: both lines matter
await fetch("/items", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "widget", price: 9.99 }),
});

Confirm it from the server side: if loc is just ["body"] and the message is about expecting a dictionary or object, the body did not arrive as JSON. Check the request's Content-Type header in your browser network tab. If it says application/x-www-form-urlencoded or multipart/form-data, that is your bug, in the client.

Cause #2: mixing Form/File and a Body model in one route

You cannot put a JSON Pydantic Body() parameter and a Form() or File() parameter in the same route. A single HTTP request has one body, and it is either JSON or it is multipart form data; it cannot be both. FastAPI will make the whole thing form-parsed the moment a Form/File appears, and then your JSON model fails to validate.

# This does not work the way people expect:
@app.post("/upload")
def upload(meta: Item, file: UploadFile):   # Item as JSON + a file -> 422
    ...

If you need to send structured data alongside a file, either accept the metadata fields as individual Form() parameters, or accept the metadata as a JSON string in a form field and parse it yourself:

from fastapi import Form, UploadFile
import json

@app.post("/upload")
def upload(meta: str = Form(...), file: UploadFile = None):
    item = Item.model_validate_json(meta)   # parse the JSON string form field
    return {"name": item.name, "filename": file.filename}

Cause #3: a list where the model expects an object

If your endpoint declares item: Item (a single object) and the client sends a JSON array, you get 422 with a message about expecting a dictionary. The pandas trap is the classic: df.to_json(orient="records") produces a JSON array of objects, so if your endpoint expects one object, it fails. Either send one record, or declare the parameter as a list:

from typing import List

@app.post("/items/bulk")
def create_items(items: List[Item]):   # now a JSON array is correct
    return {"count": len(items)}

Make it self-diagnosing: log the raw body on 422

Instead of guessing in the dark, override FastAPI's validation exception handler so every 422 logs the errors and the raw body it choked on. This keeps full validation and gives you the exact payload.

import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse

log = logging.getLogger("validation")
app = FastAPI()

@app.exception_handler(RequestValidationError)
async def log_validation_error(request: Request, exc: RequestValidationError):
    log.warning("422 on %s %s: %s | body=%r",
                request.method, request.url.path, exc.errors(), exc.body)
    return JSONResponse(status_code=422, content={"detail": exc.errors()})

exc.errors() is the same detail list the client receives, and exc.body is the raw body FastAPI tried to parse. With that in your logs you can see immediately whether the body was JSON, form data, an array, or empty. This is the tool; not disabling anything, just making the existing information visible.

Verification: reproduce and confirm with curl

Take the failing case out of the client entirely and hit the endpoint with an explicit, known-good request.

# Correct: JSON body, JSON content-type -> 200
curl -i -X POST http://localhost:8000/items \
  -H "Content-Type: application/json" \
  -d '{"name":"widget","price":9.99}'

# Wrong on purpose: form encoding -> 422, and the body tells you why
curl -i -X POST http://localhost:8000/items \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d 'name=widget&price=9.99'

The first returns 200. The second returns 422 with a detail whose loc is ["body"], proving the format, not your model, was the problem. If curl with correct JSON works but your front-end does not, the bug is definitively in the client's request, and the network tab's Content-Type header is where you will find it.

What people get wrong

Switching the parameter to request: Request and parsing by hand. This makes the 422 disappear by removing validation, then you reimplement worse validation inside the function, or you skip it and ship a bug. The 422 was correct. Fix the payload.

Loosening the model to Dict[str, Any]. Same mistake in a different hat. You now accept any shape, which means the malformed request that FastAPI caught for free now flows into your business logic and fails somewhere less obvious.

Assuming 422 is a server error. It is a 4xx. The client sent something the contract does not accept. Treat it like a 400, because that is what it is, semantically. If you are getting 422 from your own front-end, your front-end is the broken client.

Ignoring loc and re-reading the model line by line. The response already computed the answer. ["body", "items", 0, "price"] means the first element of the items array is missing price. Reading that is faster than reading your code.

When it is still broken

  • 422 with a correct-looking JSON body and Content-Type: application/json. Check for a stray wrapping: some clients wrap the payload in a data field. If loc is ["body", "name"] "field required" but you did send name, you probably sent {"data": {...}} and the model expects the fields at the top level.
  • Works in Swagger UI but not from the front-end. Swagger sends correct JSON, so this proves the endpoint is fine and the front-end request differs. Diff the two requests in the network tab, header for header.
  • Numbers rejected as strings or vice versa. A field typed float receiving "9.99" as a quoted string may fail in strict mode. Fix the client to send a number, or make the field accept coercion, but be deliberate about it.
  • 422 only on some requests. An optional field with no default, or a field that is sometimes null, will trip validation intermittently. Give optional fields an explicit Optional[...] = None and re-check loc on the failing request to see which one.

The whole discipline here is one sentence: the 422 body is the answer, so read it before you change anything. FastAPI's error handling docs describe the exact structure, and once you have read loc two or three times it becomes the first thing you look at, not the last.

Frequently asked questions

What does a 422 Unprocessable Entity mean in FastAPI?
It means your request reached the route but the body, query or path parameters did not match the declared Pydantic model or type hints. FastAPI validated the input, found it wrong, and rejected it before your function ran. It is not a server bug and not an auth problem; it is a payload-shape mismatch, and the exact field and reason are in the JSON response body under the detail key.
Why does my FastAPI POST return 422 when the JSON looks correct?
The most common cause is that the client is not actually sending JSON. axios and fetch send different content types depending on how you call them, and if the body arrives as form data or with the wrong Content-Type header, FastAPI cannot parse it into your Pydantic model and returns 422. Set Content-Type: application/json and send a JSON-stringified body. Read the detail[].loc field to confirm which parameter FastAPI thought was missing or malformed.
How do I see which field caused a FastAPI 422 error?
Read the response body, not just the status code. FastAPI returns {"detail": [{"loc": [...], "msg": "...", "type": "..."}]}, where loc is the path to the offending field, for example ["body", "items", 0, "price"]. The first element tells you whether it was the body, a query param or a path param, and the rest pinpoints the exact key. That structure is the answer; you almost never need to guess.
Should I disable validation to get rid of 422 errors?
No. Switching the parameter to a raw Request and parsing the body yourself, or loosening the model to Dict[str, Any], throws away the exact protection that is telling you the client is broken. The 422 is correct; the client payload is wrong. Fix the Content-Type, the JSON shape, or the model, and keep the validation. Relaxing it just moves the failure deeper into your code where it is harder to diagnose.
#FastAPI#Pydantic#Python#Validation#REST APIs
Keep reading

Related articles