When a client sends a POST or PUT request, the data arrives in the HTTP request body as JSON. FastAPI reads this JSON, parses it, and validates it against a Pydantic model — all automatically. If the data is invalid (missing a required field, wrong type, value out of range), FastAPI returns a structured 422 Unprocessable Entity response with field-level details before your handler function is even called. This is one of FastAPI’s most valuable features: the guarantee that by the time your function runs, the input data is already validated and type-safe.
Declaring Request Bodies
from fastapi import FastAPI, status
from pydantic import BaseModel, Field
from typing import Literal
app = FastAPI()
class PostCreate(BaseModel):
title: str = Field(..., min_length=3, max_length=200,
description="The post title")
body: str = Field(..., min_length=10,
description="Post content in Markdown")
status: Literal["draft", "published"] = Field(
default="draft",
description="Initial publication status")
tags: list[str] = Field(default=[], max_length=10)
class PostResponse(BaseModel):
id: int
title: str
body: str
status: str
model_config = {"from_attributes": True}
@app.post(
"/posts",
response_model = PostResponse,
status_code = status.HTTP_201_CREATED,
summary = "Create a new blog post",
description = "Creates a post in draft status by default.",
)
def create_post(post: PostCreate):
# By the time we get here, post is fully validated:
# - title: str between 3 and 200 chars (not empty, not too long)
# - body: str with at least 10 chars
# - status: exactly "draft" or "published"
# - tags: list of at most 10 strings
new_post = save_to_db(post) # returns dict or ORM object
return new_post
BaseModel subclass. If it’s a simple Python type (int, str, bool), FastAPI treats it as a path or query parameter. If it’s a Pydantic model, FastAPI reads it from the request body. This convention is how FastAPI knows what to do with each parameter without explicit annotations.PostCreate model has all required fields. A PostUpdate model has all optional fields (for PATCH). A PostResponse model has computed fields like id and created_at that the client cannot set. This pattern, sometimes called “schema separation”, prevents clients from setting fields they should not (like id) and ensures responses include fields that were not in the request.@field_validator from Chapter 12) to transform the input before it reaches your function.Optional vs Required Fields
from pydantic import BaseModel, Field
from typing import Optional
# ── Required fields: no default value ─────────────────────────────────────────
class PostCreate(BaseModel):
title: str # required — must be in JSON body
body: str = Field(...) # ... means required
# ── Optional fields: have a default value ─────────────────────────────────────
class PostCreate(BaseModel):
title: str
body: str
excerpt: str | None = None # optional — defaults to None if not provided
tags: list[str] = [] # optional — defaults to empty list
published: bool = False # optional — defaults to False
# ── Update schema: all fields optional (for PATCH) ────────────────────────────
class PostUpdate(BaseModel):
title: str | None = None # client can send just title, just body, or both
body: str | None = None
status: Literal["draft", "published"] | None = None
# In the handler: skip None fields when updating
def to_update_dict(self) -> dict:
return self.model_dump(exclude_none=True) # only sends fields that were provided
@app.patch("/posts/{post_id}", response_model=PostResponse)
def update_post(post_id: int, update: PostUpdate):
changes = update.to_update_dict() # {"title": "New"} if only title sent
updated = db_update(post_id, changes)
return updated
Multiple Body Parameters
from fastapi import FastAPI, Body
from pydantic import BaseModel
app = FastAPI()
class Post(BaseModel):
title: str
body: str
class User(BaseModel):
name: str
email: str
# Multiple body models — FastAPI expects:
# {"post": {"title": "...", "body": "..."}, "user": {"name": "...", "email": "..."}}
@app.post("/posts/draft")
def create_draft(post: Post, user: User):
return {"post": post, "user": user}
# Extra body key that's not a model (using Body())
@app.post("/posts/{post_id}/publish")
def publish_post(post_id: int, note: str = Body(default="")):
return {"id": post_id, "note": note}
Common Mistakes
Mistake 1 — Using the same model for create and response (exposes internal fields)
❌ Wrong — one model for both input and output:
class Post(BaseModel):
id: int # client should not set this
title: str
password_hash: str # should never be in response
@app.post("/posts")
def create_post(post: Post) -> Post: # password_hash in response!
✅ Correct — separate create, update, and response models.
Mistake 2 — Optional fields in PostCreate when they should be required
❌ Wrong — body can be created without a body:
class PostCreate(BaseModel):
title: str
body: str | None = None # body is optional — creates empty posts!
✅ Correct — required for creation, optional only in update schema:
class PostCreate(BaseModel):
title: str
body: str # ✓ required — no default
Mistake 3 — Forgetting model_config from_attributes for ORM responses
❌ Wrong — Pydantic cannot read ORM object attributes:
class PostResponse(BaseModel):
id: int; title: str
# PostResponse.model_validate(sqlalchemy_obj) → ValidationError
✅ Correct:
class PostResponse(BaseModel):
model_config = {"from_attributes": True}
id: int; title: str # ✓ reads ORM attributes
Quick Reference
| Pattern | Code |
|---|---|
| Required field | title: str or title: str = Field(...) |
| Optional with None | excerpt: str | None = None |
| Optional with default | status: str = "draft" |
| Field with constraints | title: str = Field(min_length=3, max_length=200) |
| Filter response fields | @app.post(..., response_model=PostResponse) |
| PATCH update dict | update.model_dump(exclude_none=True) |
| ORM → Pydantic | model_config = {"from_attributes": True} |