Request Body and Validation
Without a request body, POST and PUT endpoints cannot accept structured data. FastAPI uses Pydantic models as the type hint for body parameters, combining automatic parsing, validation, serialization, and documentation in one declaration.
By the end of this lesson you can: accept and validate a JSON request body, combine body with path and query parameters, embed multiple bodies, and use Body for extra field-level control.
Declaring a Request Body
Annotate a function parameter with a Pydantic model to make it a body parameter:
from fastapi import APIRouter
from pydantic import BaseModel, Field
class ItemCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: str | None = None
price: float = Field(..., gt=0)
tax: float = 0.0
router = APIRouter(prefix="/items", tags=["items"])
@router.post("/", status_code=201)
async def create_item(item: ItemCreate) -> ItemCreate:
return item
FastAPI automatically:
- Reads the request body as JSON
- Validates every field against the model
- Returns
422with per-field errors on failure - Passes a fully typed
ItemCreateinstance to your handler
Combining Body, Path, and Query Parameters
All three parameter types can coexist in a single route:
@router.put("/{item_id}")
async def update_item(
item_id: int, # path
item: ItemCreate, # body (Pydantic model)
apply_tax: bool = True, # query
) -> dict:
result = item.model_dump()
if apply_tax:
result["total"] = item.price + item.tax
return {"item_id": item_id, **result}
FastAPI infers the source of each parameter from its type:
- In the path string → path parameter
- Pydantic model → request body
- Everything else with a default or
Optional→ query parameter
Multiple Body Parameters
To accept two different model bodies in one request:
from fastapi import APIRouter, Body
from pydantic import BaseModel
from typing import Annotated
class Item(BaseModel):
name: str
price: float
class Shipping(BaseModel):
address: str
express: bool = False
router = APIRouter(prefix="/orders", tags=["orders"])
@router.post("/")
async def create_order(
item: Item,
shipping: Shipping,
) -> dict:
return {"item": item.model_dump(), "shipping": shipping.model_dump()}
Expected JSON body:
{
"item": {"name": "Widget", "price": 9.99},
"shipping": {"address": "123 Main St", "express": true}
}
Using Body for a Single Value in the Body
Normally, a scalar like int becomes a query parameter. Use Body to put it in the request body:
from typing import Annotated
from fastapi import Body
@router.patch("/{item_id}/price")
async def update_price(
item_id: int,
price: Annotated[float, Body(gt=0, embed=True)],
) -> dict:
return {"item_id": item_id, "new_price": price}
With embed=True, the JSON body must be {"price": 19.99} instead of just 19.99.
Adding Examples to Bodies
Improve OpenAPI docs by adding examples:
from pydantic import BaseModel, Field
class ItemCreate(BaseModel):
model_config = {"json_schema_extra": {
"examples": [
{"name": "Widget Pro", "price": 49.99, "tax": 5.0}
]
}}
name: str = Field(..., min_length=1, examples=["Widget Pro"])
price: float = Field(..., gt=0, examples=[49.99])
tax: float = Field(0.0, ge=0)
Header and Cookie Parameters
Header and cookie parameters follow the same Annotated pattern:
from typing import Annotated
from fastapi import Header, Cookie
@router.get("/")
async def list_with_auth(
x_token: Annotated[str | None, Header()] = None,
session_id: Annotated[str | None, Cookie()] = None,
) -> dict:
return {"token_present": x_token is not None, "session": session_id}
FastAPI converts header names from x_token (Python) to X-Token (HTTP) automatically. Use underscores in your parameter names.
Common Pitfalls
| Pitfall | Cause / Symptom | Fix |
|---|---|---|
422 on valid JSON | Pydantic model field mismatch | Read the detail list — it shows the exact path and error |
| Body param treated as query | Scalar type without Body() | Wrap in Annotated[float, Body()] |
| Two models collide in body | Both models at the same key level | FastAPI will embed them by model name; verify the expected JSON structure |
Content-Type missing | Client sends body without application/json header | Curl: add -H "Content-Type: application/json" |
embed=True not working for dict | Using wrong Pydantic version API | Use model_dump() not .dict() in Pydantic v2 |
Hands-On Practice
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
class PostCreate(BaseModel):
title: str = Field(..., min_length=5, max_length=200)
content: str = Field(..., min_length=10)
tags: list[str] = []
published: bool = False
class PostResponse(BaseModel):
id: int
title: str
content: str
tags: list[str]
published: bool
router = APIRouter(prefix="/posts", tags=["blog"])
_posts: dict[int, dict] = {}
_counter = 0
@router.post("/", response_model=PostResponse, status_code=201)
async def create_post(post: PostCreate) -> PostResponse:
global _counter
_counter += 1
record = {"id": _counter, **post.model_dump()}
_posts[_counter] = record
return PostResponse(**record)
@router.put("/{post_id}", response_model=PostResponse)
async def update_post(post_id: int, post: PostCreate) -> PostResponse:
if post_id not in _posts:
raise HTTPException(404, "Post not found")
record = {"id": post_id, **post.model_dump()}
_posts[post_id] = record
return PostResponse(**record)
uvicorn app.routers.blog:router --reload --port 8005
curl -s -X POST http://localhost:8005/posts/ \
-H "Content-Type: application/json" \
-d '{"title": "Hello FastAPI", "content": "This is my first post.", "tags": ["python", "api"]}'
# Send invalid data
curl -s -X POST http://localhost:8005/posts/ \
-H "Content-Type: application/json" \
-d '{"title": "Hi", "content": "Short"}' \
| python -m json.tool