Validating a Request Body
Check a body's shape and types before acting on it, and answer 422 with one entry per bad field.
class OrderIn(BaseModel): sku: str; qty: Annotated[int, Field(ge=1)]A request body is input from a stranger. Validate it at the edge, once, and the rest of your code gets to assume the shape is right. Skip it and the checks spread into business logic, into the database layer, and into a 500 raised halfway through a half-written transaction.
400 or 422
These answer different questions. The body was not JSON at all, or was truncated — that is 400, because you could not even read it. The body parsed into a perfectly good object whose values are wrong — a negative quantity, a missing sku, a string where a number belongs — that is 422 Unprocessable Content. In Python 3.13 the enum member was renamed to HTTPStatus.UNPROCESSABLE_CONTENT, with UNPROCESSABLE_ENTITY kept as an alias.
Report every field, with a code
Return all the problems, not the first one, so a client can fix the form in one round trip. Each entry needs the field, a stable machine-readable code, and a human message. FastAPI does this out of the box: a Pydantic model as the body annotation produces a 422 whose detail is a list of entries carrying loc, msg and type. Validating by hand, BaseModel.model_validate(data) raises ValidationError and .errors() gives you the same list.
What trips people up
Presence is not validity: "", " " and None are all present. JSON numbers decode as int or float depending on how they were written, so json.loads("1.0") is a float. And bool is a subclass of int in Python, so a JSON true satisfies a naive integer check and arrives as a quantity of 1.
Example
When to use it
- Accepting a signup form and returning every problem at once instead of one at a time.
- Rejecting a malformed webhook payload before it reaches your database.
- Replacing a pile of if-statements at the top of a handler with a declared model.
- Tracking down why a quantity of true became a quantity of 1 in production.
More examples
The declarative version, with the 422 it produces
What you reach for on a real service: the model is the documentation, the validation and the type the handler receives, all at once. Both failures come back in one response, which is the behaviour a form on the other end needs.
from typing import Annotated
from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator
app = FastAPI()
class OrderIn(BaseModel):
sku: str
qty: Annotated[int, Field(ge=1)] # ge: greater than or equal
note: str | None = None # optional, defaults to None
@field_validator("sku")
@classmethod
def sku_not_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("must not be blank") # becomes one 422 entry
return v.strip()
@app.post("/orders", status_code=201)
async def create_order(payload: OrderIn): # invalid body never gets here
return {"id": 1041, "sku": payload.sku, "qty": payload.qty}
# POST {"sku": "", "qty": 0} answers 422 with a body shaped like:
# {"detail": [{"type": "value_error", "loc": ["body", "sku"], "msg": "..."},
# {"type": "greater_than_equal", "loc": ["body", "qty"], "msg": "..."}]}Validating by hand, outside a framework
For a queue consumer, a CLI, or a cron job — anywhere a payload arrives without an HTTP framework around it. The note about `input` matters once your error responses end up in a log aggregator.
from pydantic import BaseModel, ValidationError
class OrderIn(BaseModel):
sku: str
qty: int
def parse(payload: dict):
try:
return OrderIn.model_validate(payload), None # pydantic v2 spelling
except ValidationError as exc:
# .errors() is the same list FastAPI puts in `detail`. Trim the fields
# you do not want to expose: `input` echoes the client's value back,
# which can leak whatever they posted into your logs or responses.
detail = [{"field": ".".join(str(p) for p in e["loc"]),
"code": e["type"],
"message": e["msg"]} for e in exc.errors()]
return None, detail
print(parse({"sku": "A-1", "qty": "nope"})[1])The 400 case: it was not JSON
The step before validation, and the reason 400 and 422 both exist. Note that json.loads happily returns a list, a string or a number — valid JSON is not the same as the object your endpoint documents.
import json
from http import HTTPStatus
def read_body(raw: bytes):
try:
data = json.loads(raw) # json.loads(s, *, cls=None, object_hook=None, ...)
except json.JSONDecodeError as exc:
# A ValueError subclass carrying msg, pos, lineno and colno. Report the
# position -- it is genuinely useful -- but never the whole body back.
return HTTPStatus.BAD_REQUEST, {
"error": {"code": "invalid_json",
"message": f"{exc.msg} at line {exc.lineno} column {exc.colno}"}}
if not isinstance(data, dict): # a valid JSON array is still the wrong shape
return HTTPStatus.BAD_REQUEST, {
"error": {"code": "invalid_body", "message": "expected a JSON object"}}
return HTTPStatus.OK, data
print(read_body(b'{"sku": "A-1",}'))
Discussion