Mathew K Analytics

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…

⬇ Download notebookOpen in Colab ↗

What you'll learn

Data

No separate download needed — the notebook creates or downloads everything it uses.

📓 Full notebook

Download .ipynb

FastAPI 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())
{'anything': 'goes', 'no': 'validation'}

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())
{'received': {'name': 'Mug', 'price': 9.99, 'in_stock': True}}

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'])
422
['body', 'price'] Input should be a valid number, unable to parse string as a number
['body', 'in_stock'] Field required

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())
{'name': 'Pen', 'price': 1.5, 'in_stock': True, 'description': None}

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())
{'name': 'Alex', 'address': {'city': 'Denver', 'zip_code': '80202'}}

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())
{'item_count': 2, 'items': [{'sku': 'A1', 'quantity': 2}, {'sku': 'B2', 'quantity': 1}]}

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())
{'product_id': 5, 'updated': {'name': 'Updated Pen', 'price': 2.0, 'in_stock': True, 'description': None}}

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())
{'product_id': 9, 'notify': True, 'updated': {'name': 'Notebook', 'price': 4.5, 'in_stock': True, 'description': None}}

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'}))
{'name': 'Lamp', 'price': 25.0, 'in_stock': True, 'description': None}
{'name': 'Lamp', 'price': 25.0, 'in_stock': True}
{'name': 'Lamp'}

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())
{'customer_id': 12, 'total': 25.5, 'note': None}

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.