FastAPI 422 Unprocessable Entity (tested)

FastAPI returns 422 Unprocessable Entity when the request does not validate against the endpoint's parameters. The response's detail[].loc tells you where it looked (body, query, path) and which field failed. In our tests the usual causes were form data instead of JSON, a missing Content-Type, and plain parameters that FastAPI reads from the query string.

The app used by blocks 1 and 2 (app.py):

from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
    name: str
    price: float
@app.post("/items")
def create(item: Item):
    return item
@app.post("/rename")
def rename(new_name: str):          # plain str -> FastAPI expects a QUERY param
    return {"new_name": new_name}
@app.get("/items/{item_id}")
def read(item_id: int):
    return {"item_id": item_id}

1. Read the detail: loc says where, msg says what

import json
from fastapi.testclient import TestClient
from app import app
c = TestClient(app)
def show(label, r):
    print(f"--- {label}: {r.status_code}")
    for e in r.json().get("detail", []): print(json.dumps(e))
show("ok", c.post("/items", json={"name": "pen", "price": 1.5}))
show("missing field", c.post("/items", json={"name": "pen"}))
show("wrong type", c.post("/items", json={"name": "pen", "price": "cheap"}))
show("path param not an int", c.get("/items/abc"))

Output:

--- ok: 200
--- missing field: 422
{"type": "missing", "loc": ["body", "price"], "msg": "Field required", "input": {"name": "pen"}}
--- wrong type: 422
{"type": "float_parsing", "loc": ["body", "price"], "msg": "Input should be a valid number, unable to parse string as a number", "input": "cheap"}
--- path param not an int: 422
{"type": "int_parsing", "loc": ["path", "item_id"], "msg": "Input should be a valid integer, unable to parse string as an integer", "input": "abc"}

Every 422 from FastAPI has a detail list. loc starts with where FastAPI looked (body, query, path, header) followed by the field name; msg and type are the Pydantic validation error; input is what it actually received. Print the response body before guessing.

2. The request is not what the endpoint expects

import json
from fastapi.testclient import TestClient
from app import app
c = TestClient(app)
def show(label, r):
    print(f"--- {label}: {r.status_code}")
    print(json.dumps(r.json()))
show("form data instead of JSON", c.post("/items", data={"name": "pen", "price": "1.5"}))
show("JSON string with no Content-Type", c.post("/items", content='{"name": "pen", "price": 1.5}'))
show("plain str param sent as JSON body", c.post("/rename", json={"new_name": "x"}))
show("plain str param sent as query", c.post("/rename", params={"new_name": "x"}))

Output:

--- form data instead of JSON: 422
{"detail": [{"type": "model_attributes_type", "loc": ["body"], "msg": "Input should be a valid dictionary or object to extract fields from", "input": "name=pen&price=1.5"}]}
--- JSON string with no Content-Type: 422
{"detail": [{"type": "model_attributes_type", "loc": ["body"], "msg": "Input should be a valid dictionary or object to extract fields from", "input": "{\"name\": \"pen\", \"price\": 1.5}"}]}
--- plain str param sent as JSON body: 422
{"detail": [{"type": "missing", "loc": ["query", "new_name"], "msg": "Field required", "input": null}]}
--- plain str param sent as query: 200
{"new_name": "x"}

Three common mismatches, all 422:

3. Read a single value from the body, and log every 422

app3.py:

from typing import Annotated
from fastapi import FastAPI, Body, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
@app.post("/rename")
def rename(new_name: Annotated[str, Body(embed=True)]):   # now read from the JSON body
    return {"new_name": new_name}
@app.exception_handler(RequestValidationError)
async def log_422(request: Request, exc: RequestValidationError):
    print("422 on", request.url.path, "body:", exc.body, "errors:", [(e["loc"], e["msg"]) for e in exc.errors()])
    return JSONResponse(status_code=422, content={"detail": exc.errors()})
from fastapi.testclient import TestClient
from app3 import app
c = TestClient(app)
r = c.post("/rename", json={"new_name": "x"}); print(r.status_code, r.json())
r = c.post("/rename", json={"name": "x"}); print(r.status_code)

Output:

200 {'new_name': 'x'}
422 on /rename body: {'name': 'x'} errors: [(('body', 'new_name'), 'Field required')]
422

Body(embed=True) makes FastAPI read {"new_name": ...} from the JSON body (or use a Pydantic model). A RequestValidationError handler that logs exc.body and exc.errors() shows exactly what clients send, which helps when the 422 comes from a frontend you do not control.

Related: 422 Unprocessable Entity (HTTP status).

Frequently asked questions

Why does FastAPI return 422 Unprocessable Entity?

The request failed validation: a missing or wrongly typed field, form data or a string body where JSON was expected, or a value sent in the body for a parameter FastAPI reads from the query string. Check detail[].loc.

Why is my POST parameter expected in the query?

A plain str/int function parameter is a query parameter in FastAPI. Use a Pydantic model or Annotated[str, Body(embed=True)] to read it from the JSON body.

How do I log FastAPI 422 errors?

Add @app.exception_handler(RequestValidationError) and log exc.body and exc.errors(), then return JSONResponse(status_code=422, content={'detail': exc.errors()}).

Written with AI assistance. Measured on 2026-10-23 with FastAPI 0.161.1, Pydantic 2.14.1 and Python 3.14.7 (TestClient, python:3-alpine in Docker). Each block was run verbatim and the output pasted unedited. PlainHeaders is our extension; Python and FastAPI are free.