Python Tutorial
Request Bodies and Pydantic Models
When a client creates or updates data it sends a JSON request body. FastAPI reads bodies through Pydantic models: classes that declare the expected fields and their types. Pydantic parses the JSON, converts types (a string "2026-09-27" becomes a date), checks every rule, and hands your function a fully validated Python object — or returns a detailed 422 error without ever calling it.
This lesson covers defining models, field constraints, nested models, custom validators, combining body, path and query parameters, and partial updates with PATCH.
Defining a Model
Subclass pydantic.BaseModel and annotate fields. Fields without defaults are required; str | None = None is optional. Field() adds constraints (gt, ge, min_length, max_length, pattern), defaults, aliases, descriptions and examples. Models can contain lists, dicts, other models, datetime, Decimal, Enum and more. A parameter typed as a model is read from the body; simple types stay path or query parameters.
Validators and Useful Methods
@field_validator("name") checks or transforms one field; @model_validator(mode="after") checks rules involving several fields (for example "end date after start date"). Raise ValueError with a helpful message to reject a value. Useful methods: model_dump() (to a dict), model_dump_json(), model_validate(data) (from a dict), model_copy(update=...), and model_dump(exclude_unset=True), which returns only the fields the client actually sent — the key to PATCH updates. model_config = ConfigDict(extra="forbid") rejects unknown fields.
PUT vs PATCH
PUT replaces the whole resource, so its model has the same required fields as creation. PATCH changes only some fields, so its model makes every field optional; merge model_dump(exclude_unset=True) into the stored data so omitted fields keep their current values.
Examples
Create and update books with Pydantic models
from datetime import date
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field, field_validator
app = FastAPI()
books = {}
class BookIn(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str
price: float = Field(gt=0, description="Price in rupees")
tags: list[str] = []
published: date | None = None
@field_validator("title", "author")
@classmethod
def tidy(cls, value: str) -> str:
return " ".join(value.split()) # trim and collapse spaces
class BookPatch(BaseModel): # every field optional for PATCH
title: str | None = None
price: float | None = Field(default=None, gt=0)
tags: list[str] | None = None
@app.post("/books", status_code=201)
def create_book(book: BookIn):
book_id = len(books) + 1
books[book_id] = book.model_dump()
return {"id": book_id, **books[book_id]}
@app.patch("/books/{book_id}")
def patch_book(book_id: int, changes: BookPatch):
books[book_id].update(changes.model_dump(exclude_unset=True))
return {"id": book_id, **books[book_id]}
client = TestClient(app)
r = client.post("/books", json={"title": " Python Basics ", "author": "Asha",
"price": "450", "published": "2026-01-15"})
print(r.status_code, r.json())
print(client.patch("/books/1", json={"price": 399, "tags": ["sale"]}).json())
201 {'id': 1, 'title': 'Python Basics', 'author': 'Asha', 'price': 450.0, 'tags': [], 'published': '2026-01-15'}
{'id': 1, 'title': 'Python Basics', 'author': 'Asha', 'price': 399.0, 'tags': ['sale'], 'published': '2026-01-15'}
Nested models, model validators and readable validation errors
from datetime import date
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field, model_validator
app = FastAPI()
class Address(BaseModel):
city: str
pincode: str = Field(min_length=6, max_length=6)
class OrderItem(BaseModel):
product: str
quantity: int = Field(ge=1, le=20)
unit_price: float = Field(gt=0)
class Order(BaseModel):
model_config = ConfigDict(extra="forbid") # unknown fields are errors
customer: str
address: Address
items: list[OrderItem] = Field(min_length=1)
order_date: date
delivery_date: date
@model_validator(mode="after")
def delivery_after_order(self):
if self.delivery_date < self.order_date:
raise ValueError("delivery_date must be on or after order_date")
return self
@app.post("/orders")
def place_order(order: Order):
total = sum(item.quantity * item.unit_price for item in order.items)
return {"customer": order.customer, "city": order.address.city, "total": total}
client = TestClient(app)
good = {
"customer": "Ravi", "address": {"city": "Pune", "pincode": "411001"},
"items": [{"product": "Pen", "quantity": 3, "unit_price": 20},
{"product": "Notebook", "quantity": 2, "unit_price": 60}],
"order_date": "2026-09-27", "delivery_date": "2026-09-30",
}
print(client.post("/orders", json=good).json())
bad = {**good, "address": {"city": "Pune", "pincode": "41"},
"items": [{"product": "Pen", "quantity": 0, "unit_price": 20}], "coupon": "FREE"}
response = client.post("/orders", json=bad)
print(response.status_code)
for error in response.json()["detail"]:
print(".".join(str(part) for part in error["loc"]), "->", error["msg"])
# Model validators run only after every field is valid:
dates_only = {**good, "delivery_date": "2026-09-01"}
print(client.post("/orders", json=dates_only).json()["detail"][0]["msg"])
{'customer': 'Ravi', 'city': 'Pune', 'total': 180.0}
422
body.address.pincode -> String should have at least 6 characters
body.items.0.quantity -> Input should be greater than or equal to 1
body.coupon -> Extra inputs are not permitted
Value error, delivery_date must be on or after order_date
Body, path and query parameters together
from typing import Annotated
from fastapi import Body, FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Review(BaseModel):
rating: int
comment: str = ""
@app.put("/books/{book_id}/reviews/{user}")
def upsert_review(
book_id: int, # path
user: str, # path
review: Review, # body (a model)
notify: bool = False, # query
source: Annotated[str, Body()] = "web", # extra single value read from the body
):
return {"book_id": book_id, "user": user, "review": review, "notify": notify, "source": source}
client = TestClient(app)
body = {"review": {"rating": 5, "comment": "Clear and practical"}, "source": "mobile"}
print(client.put("/books/7/reviews/asha?notify=true", json=body).json())
{'book_id': 7, 'user': 'asha', 'review': {'rating': 5, 'comment': 'Clear and practical'}, 'notify': True, 'source': 'mobile'}
Common Mistakes
- Reading request.json() by hand instead of declaring a Pydantic model.
- Using the creation model for PATCH, which forces clients to resend every field.
- Calling model_dump() without exclude_unset=True in a PATCH and overwriting data with defaults.
- Accepting unknown fields silently when typos should be rejected — use extra="forbid" where appropriate.
- Validating cross-field rules in the endpoint instead of in a model_validator.
Key Points to Remember
- A parameter typed as a Pydantic model is read from the JSON body and fully validated.
- Field() adds constraints; field_validator and model_validator add custom rules.
- Nested models and lists of models describe complex JSON precisely.
- Errors come back as 422 with the location and message of every problem.
- PATCH: optional fields + model_dump(exclude_unset=True).
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local project environment for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.