Lesson 3 · FastAPI deep dive
FastAPI Tutorial #3: Request Bodies & Pydantic Models
Video three of the eighteen-part series: real, validated, self-documenting request bodies. BaseModel, nested models, and combining a body with path and…
- CourseFastAPI deep dive
- Lesson3 of 18
- Video15 min
- FormatJupyter notebook · 10 code cells
What you'll learn
Data
No separate download needed — the notebook creates or downloads everything it uses.
📓 Full notebook
Download .ipynbFastAPI Deep-Dive, Video 3: Request Body and Pydantic Models#
- Video three of the eighteen-part series: real, validated, self-documenting request bodies.
- BaseModel, nested models, and combining a body with path and query parameters.
- Let's get into it.
Part 1: Why Request Bodies Need Models#
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.post('/raw-echo')
def raw_echo(payload: dict):
return payload
client = TestClient(app)
print(client.post('/raw-echo', json={'anything': 'goes', 'no': 'validation'}).json())
Part 2: Defining a BaseModel and Using It as a Body#
from pydantic import BaseModel
class Product(BaseModel):
name: str
price: float
in_stock: bool
@app.post('/products')
def create_product(product: Product):
return {'received': product.model_dump()}
print(client.post('/products', json={'name': 'Mug', 'price': 9.99, 'in_stock': True}).json())
Part 3: Automatic Validation on the Model#
bad_response = client.post('/products', json={'name': 'Mug', 'price': 'not-a-number'})
print(bad_response.status_code)
for err in bad_response.json()['detail']:
print(err['loc'], err['msg'])
Part 4: Optional Fields with Defaults#
class ProductV2(BaseModel):
name: str
price: float
in_stock: bool = True
description: str | None = None
@app.post('/products-v2')
def create_product_v2(product: ProductV2):
return product.model_dump()
print(client.post('/products-v2', json={'name': 'Pen', 'price': 1.5}).json())
Part 5: Nested Models#
class Address(BaseModel):
city: str
zip_code: str
class Customer(BaseModel):
name: str
address: Address
@app.post('/customers')
def create_customer(customer: Customer):
return customer.model_dump()
payload = {'name': 'Alex', 'address': {'city': 'Denver', 'zip_code': '80202'}}
print(client.post('/customers', json=payload).json())
Part 6: A List of Nested Models#
class LineItem(BaseModel):
sku: str
quantity: int
class Cart(BaseModel):
customer_name: str
items: list[LineItem]
@app.post('/carts')
def create_cart(cart: Cart):
return {'item_count': len(cart.items), 'items': cart.model_dump()['items']}
payload = {'customer_name': 'Sam', 'items': [{'sku': 'A1', 'quantity': 2}, {'sku': 'B2', 'quantity': 1}]}
print(client.post('/carts', json=payload).json())
Part 7: Combining Body with a Path Parameter#
@app.put('/products/{product_id}')
def update_product(product_id: int, product: ProductV2):
return {'product_id': product_id, 'updated': product.model_dump()}
response = client.put('/products/5', json={'name': 'Updated Pen', 'price': 2.0})
print(response.json())
Part 8: Combining Body with Query Parameters Too#
@app.put('/products/{product_id}/full')
def update_product_full(product_id: int, product: ProductV2, notify: bool = False):
return {'product_id': product_id, 'notify': notify, 'updated': product.model_dump()}
response = client.put(
'/products/9/full?notify=true',
json={'name': 'Notebook', 'price': 4.5}
)
print(response.json())
Part 9: model_dump() - Converting a Validated Model Back to a Dict#
product = ProductV2(name='Lamp', price=25.0)
print(product.model_dump())
print(product.model_dump(exclude={'description'}))
print(product.model_dump(include={'name'}))
Part 10: A Real Pattern - a Create-Order Endpoint with Nested Items#
class OrderItem(BaseModel):
sku: str
quantity: int
unit_price: float
class Order(BaseModel):
customer_id: int
items: list[OrderItem]
note: str | None = None
@app.post('/orders')
def create_order(order: Order):
total = sum(item.quantity * item.unit_price for item in order.items)
return {'customer_id': order.customer_id, 'total': round(total, 2), 'note': order.note}
payload = {
'customer_id': 12,
'items': [
{'sku': 'X1', 'quantity': 2, 'unit_price': 10.0},
{'sku': 'Y2', 'quantity': 1, 'unit_price': 5.5}
]
}
print(client.post('/orders', json=payload).json())
Wrap-Up: What You Learned#
- Accepting a raw dict gives up validation, documentation, and editor support; a Pydantic model fixes all three.
- A class inheriting from BaseModel with typed attributes becomes the request body's schema and validator.
- An invalid body triggers the same structured 422 error as path/query parameters, reporting every problem at once.
- A model field with a default value becomes optional, falling back to that default when omitted.
- A model field can itself be typed as another BaseModel, and FastAPI validates the nested structure recursively.
- list[SomeModel] validates every item in a list individually against that model.
- Path parameters, a body model, and query parameters all coexist cleanly in the same endpoint.
- model_dump() converts a validated model back to a plain dict; exclude/include control which fields are kept.
- Combining nested models, lists, and optional fields models a realistic order or cart payload directly.
- That wraps up request bodies and Pydantic models. Next up: Response Models and Status Codes.
Found this useful?
All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.



