Validating a Request Body

Check a body's shape and types before acting on it, and answer 422 with one entry per bad field.

Syntaxclass 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · python
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.

Example · python
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.

Example · python
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

  • Be the first to comment on this lesson.