Request Body & Pydantic Models¶
For POST, PUT, PATCH requests, the client sends data in the body (usually JSON).
To accept and validate it, define a Pydantic model.
A first POST endpoint¶
# main.py
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class User(BaseModel):
name: str
age: int
email: str
@app.post("/users")
def create_user(user: User):
return {"created": user, "message": f"Welcome, {user.name}!"}
Run it. Then test in the docs:
- Open http://127.0.0.1:8000/docs
- Expand POST /users
- Click Try it out, paste this body, Execute:
Response: {"created": {...}, "message": "Welcome, Alice!"}
How it executes¶
When the client sends POST /users with a JSON body:
- uvicorn receives the bytes.
- FastAPI sees the parameter
user: User. - It parses the body as JSON → a Python dict.
- It validates the dict against the
Usermodel (right types, all required fields present). - If valid → builds a
Userinstance and passes it to your function. - If invalid → returns HTTP 422 with a clear list of errors.
You only handle valid data — invalid requests are rejected before your code runs.
Pydantic validation in action¶
Send bad data:
Response:
{
"detail": [
{
"type": "int_parsing",
"loc": ["body", "age"],
"msg": "Input should be a valid integer..."
}
]
}
Send a missing field:
Response:
No code you write to handle that — FastAPI + Pydantic do it for free.
Optional fields with defaults¶
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
email: str
role: str = "user" # default — optional in the request
bio: str | None = None # default None — also optional
Field constraints¶
For more rules — length, range, regex — use Field:
from pydantic import BaseModel, Field
class User(BaseModel):
name: str = Field(..., min_length=2, max_length=50)
age: int = Field(..., ge=0, le=150)
email: str = Field(..., pattern=r"^\S+@\S+\.\S+$")
bio: str = Field("", max_length=500)
... is "no default — required".
Nested models¶
Models can contain other models:
class Address(BaseModel):
street: str
city: str
pin: str
class User(BaseModel):
name: str
age: int
address: Address # nested
hobbies: list[str] = [] # list of strings
@app.post("/users")
def create_user(user: User):
return {"city": user.address.city, "hobby_count": len(user.hobbies)}
Client sends:
{
"name": "Alice",
"age": 25,
"address": {
"street": "MG Road",
"city": "Bangalore",
"pin": "560001"
},
"hobbies": ["coding", "chess", "music"]
}
Lists of items¶
class Order(BaseModel):
user_id: int
items: list[str]
quantity: list[int]
@app.post("/orders")
def create_order(order: Order):
if len(order.items) != len(order.quantity):
return {"error": "items and quantity length must match"}
return {"order": order}
Body + Path + Query together¶
You can mix all three:
@app.put("/users/{user_id}")
def update_user(
user_id: int, # path
user: User, # body — Pydantic model
notify: bool = False, # query
):
return {
"user_id": user_id,
"updated_data": user,
"notify": notify,
}
PUT /users/42?notify=true with a JSON body → all three are populated.
Returning the model¶
Pydantic models can be returned too — FastAPI converts them to JSON:
How to test without the Swagger UI¶
Using curl from the terminal:
curl -X POST http://127.0.0.1:8000/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice","age":25,"email":"alice@example.com"}'
Using Python:
import requests
response = requests.post(
"http://127.0.0.1:8000/users",
json={"name": "Alice", "age": 25, "email": "alice@example.com"},
)
print(response.status_code)
print(response.json())
Using httpie (nicer CLI):
Common pitfalls¶
- ❗ Forgetting
BaseModel— your class won't be a Pydantic model. Alwaysclass X(BaseModel):. - ❗ Wrong Content-Type — clients must send
Content-Type: application/json. Most HTTP libraries do this automatically. - ❗ Defining body params as separate kwargs —
def x(a: int, b: int):treats them as query params. To make them body params, group into a Pydantic model. - ❗
intfield receives"123"— Pydantic v2 is strict:"123"(string) does NOT auto-coerce to int. The client must send a real number.
What's next¶
Practice¶
What does this print?
Expected: Alice
Add a Pydantic validation to require age >= 0
Expected: True
Quiz — Quick check¶
What you remember
Q1. What's the easiest way to validate a JSON request body in FastAPI?
- Define a Pydantic
BaseModeland use it as a function arg type - Manually parse JSON in the handler
- Use a JSON schema library
- Disable validation
Why: FastAPI sees the Pydantic model in the signature and: (1) parses the JSON body, (2) validates against the model, (3) returns 422 with details on failure, (4) gives you a typed object in the handler.
Q2. What happens if the request body has an extra field not in the model?
- By default, the extra field is ignored (silently accepted)
- Returns 422
- Crashes the server
- Adds it to the model
Why: Pydantic's default is to ignore unknown fields. To reject them, set
model_config = ConfigDict(extra="forbid")in your model. Useful for strict APIs.
Q3. How do you add a constraint like "age must be 0–120"?
-
age: int = Field(ge=0, le=120) -
age: int(0, 120) - In a separate validator
- Custom middleware
Why:
Field(ge=N, le=N)(orgt/ltfor strict) declares constraints inline. Pydantic enforces them and includes them in OpenAPI docs — users see the valid range in Swagger UI.
Common doubts¶
Should I use BaseModel for query params too?
Use Annotated[QueryModel, Query()] (FastAPI 0.95+). Lets you group related query params into one Pydantic model — cleaner for endpoints with many filters.
What's the difference between Pydantic v1 and v2?
v2 (current) is a major rewrite — faster, stricter type coercion, slightly different API (@validator → @field_validator, Config → ConfigDict). FastAPI ≥0.100 uses v2 by default. Migration is mostly mechanical; check the Pydantic migration guide.
How do I add custom validation logic?
@field_validator("field_name") decorator: define a classmethod that takes the value, validates it, and either returns the value or raises ValueError. For cross-field validation, use @model_validator(mode="after").