Request Body & Pydantic

intermediate fastapi pydantic request-body validation

A request body is the JSON (or form, or file) that a client sends along with a POST/PUT/PATCH. In FastAPI we describe the expected shape with a Pydantic model — that gives us validation, serialization, and OpenAPI schema all in one go.

The basic model

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    description: str | None = None
    price: float
    in_stock: bool = True

@app.post("/items/")
async def create_item(item: Item):
    return {"created": item, "total_with_tax": item.price * 1.18}

Three things happen automatically:

  1. FastAPI reads the request body as JSON.
  2. Validates it against Item — wrong types or missing required fields → 422.
  3. Hands us a real Item instance with attribute access (item.price, not item["price"]).

Send this:

{
  "name": "Notebook",
  "price": 4.50
}

And we get back:

{
  "created": {"name": "Notebook", "description": null, "price": 4.5, "in_stock": true},
  "total_with_tax": 5.31
}

How FastAPI decides “is this a body param?”

The rule of thumb in simple language:

  • Param name in {} → path param.
  • Simple type (int, str, bool, etc.) → query param.
  • Pydantic model → request body.
Validation flow
Raw JSON bytes
↓ parse
Python dict
↓ Pydantic validate
Valid → Item instance → handler
Invalid → 422 with details

Nested models

Pydantic composes nicely. Models can hold other models:

class Address(BaseModel):
    street: str
    city: str
    zip_code: str

class User(BaseModel):
    name: str
    email: str
    addresses: list[Address] = []

@app.post("/users/")
async def create_user(user: User):
    return user

The OpenAPI schema in /docs will show the full nested shape.

Multiple body params

We can declare more than one Pydantic param and FastAPI merges them into a single JSON body, keyed by the param names:

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

class User(BaseModel):
    username: str

@app.post("/orders/")
async def place_order(item: Item, user: User):
    return {"item": item, "user": user}

Expected body:

{
  "item": {"name": "Pen", "price": 2.5},
  "user": {"username": "manish"}
}

Note the nesting — item and user become top-level keys. This is different from Flask where we’d flatten everything.

Body() for embedding singletons

What if we want a single scalar in the body — not a path or query param? Use Body():

from fastapi import Body

@app.post("/orders/{order_id}/notes/")
async def add_note(order_id: int, note: str = Body(...)):
    return {"order_id": order_id, "note": note}

Without Body(), FastAPI would treat note: str as a query param. With it, note lives in the JSON body. Expected:

{"note": "Customer prefers afternoon delivery"}

Embedding a single Pydantic model

Normally one Pydantic param becomes the entire body. If we want to wrap it under its name (for API consistency, or to add fields later), pass embed=True:

@app.put("/items/{item_id}")
async def update_item(item_id: int, item: Item = Body(..., embed=True)):
    return {"item_id": item_id, "item": item}

Now the body has to look like:

{
  "item": {"name": "Pen", "price": 2.5}
}

Instead of just the raw item fields. Useful when our API standardizes on {"resource_name": {...}} envelopes.

Field-level validation

For per-field rules, use Field inside the model — same vibe as Query and Path:

from pydantic import BaseModel, Field

class Product(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    price: float = Field(gt=0, description="Must be positive")
    sku: str = Field(pattern=r"^SKU-\d{6}$")

Pydantic handles all of these before our handler sees the request.

Interview takeaway

Pydantic is the heart of FastAPI’s developer experience. We declare data shapes once as a BaseModel and get parsing, validation, serialization, type hints, and OpenAPI schema in one shot. When asked “how does FastAPI validate request bodies?” — the answer is “it delegates to Pydantic at the framework boundary.”