Creating and updating resources means accepting structured JSON safely, and models make that declarative.
To accept JSON in a POST, PUT or PATCH, describe its shape as a Pydantic model: a class inheriting from BaseModel whose annotated attributes are its fields. Use the model as a parameter type and FastAPI will read the body as JSON, validate it (types, required fields, nested models), hand your function a model instance with attributes like item.price, and describe it as a JSON Schema in the docs.
So FastAPI decides where each parameter comes from like this: names in the path are path parameters, simple types are query parameters, Pydantic models are the body. Model fields follow the familiar default rules: no default means required, str | None = None means optional.
Pydantic's default lax mode converts compatible input, so the JSON string "3" for an int field becomes 3, and rejects what can't be converted, such as "abc" for a float. Failures produce a 422 whose detail is a list of errors; each has a loc such as ["body", "price"], a msg and a type, so clients can point at the exact field. Extra keys the model doesn't define are ignored by default; set model_config = ConfigDict(extra="forbid") to reject them instead.
Models nest naturally: a field can be another model, list[Tag] or dict[str, int]. To get plain data back, use the Pydantic v2 methods item.model_dump() (a dict) or item.model_dump_json(). The v1 names .dict() and .json() are deprecated. For partial updates, item.model_dump(exclude_unset=True) returns only the fields the client actually sent.
If a function has two model parameters, FastAPI expects a JSON object with one key per parameter name: {"item": {...}, "user": {...}}.
Example
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
class Tag(BaseModel):
name: str
class Item(BaseModel):
name: str
price: float
description: str | None = None
tags: list[Tag] = []
app = FastAPI()
@app.post("/shops/{shop_id}/items")
def create_item(shop_id: int, item: Item, notify: bool = False):
return {"shop_id": shop_id, "notify": notify, **item.model_dump()}
c = TestClient(app)
r = c.post("/shops/7/items", json={"name": "Pen", "price": "1.5", "tags": [{"name": "office"}]})
print(r.json())
# {'shop_id': 7, 'notify': False, 'name': 'Pen', 'price': 1.5, 'description': None, 'tags': [{'name': 'office'}]}
r = c.post("/shops/7/items", json={"name": "Pen"})
print(r.status_code, r.json()["detail"][0]["loc"]) # 422 ['body', 'price']Remember
- A BaseModel parameter = JSON request body, validated and parsed
- Path names are path params, simple types are query params, models are the body
- Validation errors are 422s with a precise loc for each failing field
- Use model_dump() / model_dump_json() in Pydantic v2, not .dict() / .json()
Watch out for
- Sending form data or a query string when the endpoint expects JSON: the body must be application/json.