Course topics

By WebNest Studio

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

Python
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())
Output
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

Python
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"])
Output
{'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

Python
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())
Output
{'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.