Free Ebook cover FastAPI for Beginners: Build a Production-Ready REST API

FastAPI for Beginners: Build a Production-Ready REST API

New course

14 pages

Request and Response Models with Pydantic in FastAPI

Capítulo 3

Estimated reading time: 7 minutes

+ Exercise

Why Pydantic models matter: clear API contracts

In FastAPI, Pydantic models define the shape of data your API accepts and returns. This gives you a clear contract: clients know exactly what to send, and you control exactly what leaves your service. Pydantic also validates and coerces input data (for example, turning JSON strings into datetime objects), producing consistent, predictable Python objects inside your endpoint code.

Think of request models as “what the client is allowed to send” and response models as “what the client is allowed to see.” Keeping these explicit and separate helps prevent accidental exposure of internal fields (like password hashes, internal IDs, or audit metadata).

Defining request body models with BaseModel

Required vs optional fields

A field is required when it has no default value. A field is optional when it has a default (often None) or is wrapped in Optional[T] (or T | None in Python 3.10+).

from pydantic import BaseModel, EmailStr, Field
from typing import Optional

class UserCreate(BaseModel):
    email: EmailStr  # required
    password: str = Field(min_length=8)  # required (no default)
    full_name: Optional[str] = None  # optional
    is_marketing_opt_in: bool = False  # optional with default
  • email is required because it has no default.
  • full_name is optional because it defaults to None.
  • is_marketing_opt_in is optional because it has a default value.

Default values and Field(...) constraints

Use Field to add validation constraints and metadata. Constraints help enforce business rules early, before your logic runs.

from pydantic import BaseModel, Field

class ProductCreate(BaseModel):
    name: str = Field(min_length=2, max_length=100)
    price_cents: int = Field(ge=0)
    sku: str = Field(pattern=r"^[A-Z0-9_-]{6,20}$")

Common constraints include min_length, max_length, ge, le, and pattern.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

Nested objects: modeling structured JSON

Real APIs often accept nested JSON. Pydantic supports nested models directly, which keeps validation and typing clean.

from pydantic import BaseModel, Field

class Address(BaseModel):
    line1: str
    city: str
    country: str = Field(min_length=2, max_length=2)  # ISO country code

class UserProfileCreate(BaseModel):
    full_name: str
    address: Address

A client can send:

{
  "full_name": "Ada Lovelace",
  "address": {
    "line1": "12 Example Street",
    "city": "London",
    "country": "GB"
  }
}

Inside your endpoint, profile.address is an Address instance, not a raw dict.

Separate schemas for create, update, and read

A common pattern is to define multiple schemas for the same resource. This avoids mixing concerns like “what the client can set” vs “what the server returns.”

Create schema

Used for POST requests. Typically includes required fields needed to create the resource.

from pydantic import BaseModel, Field

class ItemCreate(BaseModel):
    name: str = Field(min_length=1)
    description: str | None = None

Update schema (PATCH-like behavior)

For updates, fields are often optional so the client can send only what changes. This is usually a different model than create.

from pydantic import BaseModel, Field

class ItemUpdate(BaseModel):
    name: str | None = Field(default=None, min_length=1)
    description: str | None = None

When applying updates, you typically merge only provided fields. With Pydantic v2, use model_dump(exclude_unset=True) to get only fields the client sent.

# item_update is an ItemUpdate
patch_data = item_update.model_dump(exclude_unset=True)
# patch_data contains only keys provided by the client

Read schema (response shape)

Read schemas represent what your API returns. They often include server-generated fields like id and timestamps, but exclude sensitive/internal fields.

from pydantic import BaseModel
from datetime import datetime

class ItemRead(BaseModel):
    id: int
    name: str
    description: str | None
    created_at: datetime

Using response_model to control outbound data

FastAPI’s response_model parameter lets you declare the response schema for an endpoint. FastAPI will validate and serialize the returned value according to that schema, filtering out extra fields. This is a key tool for preventing overexposure.

Example: filtering internal fields

Imagine your internal representation includes a secret field that must never be returned.

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()

class UserRead(BaseModel):
    id: int
    email: EmailStr

# Internal data (could be from a DB)
FAKE_USER_ROW = {
    "id": 1,
    "email": "user@example.com",
    "password_hash": "pbkdf2:...",
    "is_admin": True
}

@app.get("/users/me", response_model=UserRead)
def read_me():
    # Even if we return extra keys, response_model filters them out
    return FAKE_USER_ROW

The response will include only id and email. This helps you safely return “rich” internal objects without leaking fields.

Shaping lists and nested responses

response_model can be a list type or a nested model.

from typing import List

@app.get("/items", response_model=List[ItemRead])
def list_items():
    return [
        {"id": 1, "name": "A", "description": None, "created_at": "2026-01-01T10:00:00Z", "internal_note": "x"},
        {"id": 2, "name": "B", "description": "...", "created_at": "2026-01-02T10:00:00Z", "internal_note": "y"},
    ]

Extra keys like internal_note are removed from the output.

Model reuse across endpoints

To avoid duplication, factor shared fields into base schemas and inherit. This keeps your API consistent and reduces maintenance.

from pydantic import BaseModel, Field

class ItemBase(BaseModel):
    name: str = Field(min_length=1)
    description: str | None = None

class ItemCreate(ItemBase):
    pass

class ItemUpdate(BaseModel):
    name: str | None = Field(default=None, min_length=1)
    description: str | None = None

class ItemRead(ItemBase):
    id: int

Here, ItemRead reuses the same fields as ItemBase and adds id. ItemCreate reuses the base fields as-is.

Step-by-step: implement endpoints that enforce request and response schemas

This exercise-like implementation focuses on schema enforcement. It uses an in-memory store to keep the focus on Pydantic models and response_model behavior.

1) Define schemas

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from datetime import datetime

app = FastAPI()

class ItemBase(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    description: str | None = Field(default=None, max_length=500)

class ItemCreate(ItemBase):
    pass

class ItemUpdate(BaseModel):
    name: str | None = Field(default=None, min_length=1, max_length=100)
    description: str | None = Field(default=None, max_length=500)

class ItemRead(ItemBase):
    id: int
    created_at: datetime

2) Create an internal storage shape (with extra fields)

We’ll store extra internal fields to demonstrate how response_model prevents overexposure.

_items: dict[int, dict] = {}
_next_id = 1

3) Implement create endpoint (request model: ItemCreate, response model: ItemRead)

@app.post("/items", response_model=ItemRead, status_code=201)
def create_item(payload: ItemCreate):
    global _next_id

    item_id = _next_id
    _next_id += 1

    now = datetime.utcnow()

    # Internal representation includes fields not meant for clients
    row = {
        "id": item_id,
        "name": payload.name,
        "description": payload.description,
        "created_at": now,
        "internal_cost_cents": 1234,
        "internal_note": "do not expose"
    }

    _items[item_id] = row
    return row

Even though row contains internal keys, the client receives only ItemRead fields.

4) Implement read endpoint (response model: ItemRead)

@app.get("/items/{item_id}", response_model=ItemRead)
def get_item(item_id: int):
    row = _items.get(item_id)
    if not row:
        raise HTTPException(status_code=404, detail="Item not found")
    return row

5) Implement update endpoint (request model: ItemUpdate, response model: ItemRead)

This endpoint applies only the fields the client provided.

@app.patch("/items/{item_id}", response_model=ItemRead)
def update_item(item_id: int, payload: ItemUpdate):
    row = _items.get(item_id)
    if not row:
        raise HTTPException(status_code=404, detail="Item not found")

    patch_data = payload.model_dump(exclude_unset=True)
    # Apply only provided fields
    row.update(patch_data)

    _items[item_id] = row
    return row

6) Implement list endpoint (response model: list of ItemRead)

@app.get("/items", response_model=list[ItemRead])
def list_items():
    return list(_items.values())

Try it: payloads to validate behavior

ActionExample JSONWhat to observe
Create
{"name":"Notebook","description":"A5 size"}
Returns id and created_at; does not return internal fields.
Create (invalid)
{"name":""}
Validation error because name must have at least 1 character.
Patch
{"description":"Updated"}
Only description changes; name remains unchanged.

Extension tasks (optional)

  • Add a nested object field (for example, manufacturer with name and country) and ensure both create and read schemas include it.
  • Create a separate ItemReadPublic schema that intentionally hides description and use it on the list endpoint to return a slimmer payload.
  • Add a tags: list[str] field with a default empty list and validate that it always serializes as an array.

Now answer the exercise about the content:

In a PATCH-style update endpoint using a Pydantic update model where fields default to None, how can you ensure only the fields actually sent by the client are applied to the stored item?

You are right! Congratulations, now go to the next page

You missed! Try again.

For partial updates, optional fields may be omitted. Using model_dump(exclude_unset=True) returns only the keys the client provided, so you can merge just those into the existing item.

Next chapter

Validation Rules and Data Parsing for API Inputs

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.