Skip to main content

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.

Learning Focus

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:

app/routers/items.py
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 422 with per-field errors on failure
  • Passes a fully typed ItemCreate instance to your handler

Combining Body, Path, and Query Parameters

All three parameter types can coexist in a single route:

app/routers/items.py
@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:

app/routers/orders.py
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:

app/routers/items.py
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:

app/models/items.py
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 follow the same Annotated pattern:

app/routers/items.py
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}
note

FastAPI converts header names from x_token (Python) to X-Token (HTTP) automatically. Use underscores in your parameter names.

Common Pitfalls

PitfallCause / SymptomFix
422 on valid JSONPydantic model field mismatchRead the detail list — it shows the exact path and error
Body param treated as queryScalar type without Body()Wrap in Annotated[float, Body()]
Two models collide in bodyBoth models at the same key levelFastAPI will embed them by model name; verify the expected JSON structure
Content-Type missingClient sends body without application/json headerCurl: add -H "Content-Type: application/json"
embed=True not working for dictUsing wrong Pydantic version APIUse model_dump() not .dict() in Pydantic v2

Hands-On Practice

app/routers/blog.py
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)
test-blog.sh
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

What's Next