Pydantic Validation

Separate Models for Request and Response

Capsule overview

In the previous capsules you defined Pydantic models with BaseModel, Field(), and @field_validator. All your endpoints use a single model to receive and return data. That works in small examples, but it has a fundamental problem: a book has an id and a created_at — fields that the server generates, not the client. If you use one model with id: int, the client has to invent an ID when creating. If you make it id: int | None = None, the response can return null as the ID. Neither option is right.

The professional solution is to split models by operation: BookCreate defines what the client sends to create, BookUpdate for a full update, BookPatch for a partial update, and BookResponse defines what the API returns. This pattern shows up in every production API — Django REST Framework calls them serializers, Go uses DTOs, FastAPI uses separate Pydantic models.

By the end of this capsule you'll know how to create separate models for each CRUD operation, use response_model to filter sensitive data, apply model_dump(exclude_unset=True) for partial updates with PATCH, and use inheritance to remove duplication between models.


The problem: one model for everything

When one model isn't enough

Imagine this model for a book:

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()

books = []


class Book(BaseModel):
    id: int
    title: str = Field(min_length=1, max_length=200)
    author: str = Field(min_length=1, max_length=100)
    year: int = Field(ge=1000, le=2030)
    genre: str
    available: bool = True


def generate_id():
    if not books:
        return 1
    return max(b["id"] for b in books) + 1


@app.post("/books", status_code=201)
def create_book(book: Book):
    new_book = book.model_dump()
    books.append(new_book)
    return new_book

The client tries to create a book without an id:

curl -X POST http://127.0.0.1:8000/books \
  -H "Content-Type: application/json" \
  -d '{"title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "genre": "Novel"}'

Output — a 422 error:

{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "id"],
      "msg": "Field required",
      "input": {"title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "genre": "Novel"}
    }
  ]
}

FastAPI demands id because the model declares it as required. But the ID is generated by the server.

The wrong "fix": making id optional

class Book(BaseModel):
    id: int | None = None
    title: str = Field(min_length=1, max_length=200)
    # ...

Now the POST works without an id. But the GET returns a model where id can be None — which is false, because every book in the database always has an ID. Your documentation at /docs is lying: it shows id as optional when the response always has it. One model can't correctly represent the input and the output when they have different fields.


The pattern: separate models per operation

from pydantic import BaseModel, Field


class BookCreate(BaseModel):
    """What the client sends to create a book"""
    title: str = Field(min_length=1, max_length=200)
    author: str = Field(min_length=1, max_length=100)
    year: int = Field(ge=1000, le=2030)
    genre: str
    available: bool = True


class BookUpdate(BaseModel):
    """Full update (PUT) — every field required"""
    title: str = Field(min_length=1, max_length=200)
    author: str = Field(min_length=1, max_length=100)
    year: int = Field(ge=1000, le=2030)
    genre: str
    available: bool


class BookPatch(BaseModel):
    """Partial update (PATCH) — everything optional"""
    title: str | None = None
    author: str | None = None
    year: int | None = None
    genre: str | None = None
    available: bool | None = None


class BookResponse(BaseModel):
    """What the API returns"""
    id: int
    title: str
    author: str
    year: int
    genre: str
    available: bool

Each model has a clear responsibility:

  • BookCreate — No id, no created_at. Only the fields the client provides. available defaults to True.
  • BookUpdate — Like Create but without defaults. PUT replaces everything — if you leave out available, that's an error.
  • BookPatchEverything optional. PATCH sends only what you want to change.
  • BookResponse — Includes id (always present). Never includes password_hash.

Using the models in endpoints

POST — create with BookCreate, return BookResponse

Using the models defined above along with a books list and generate_id():

@app.post("/books", response_model=BookResponse, status_code=201)
def create_book(book: BookCreate):
    new_book = {"id": generate_id(), **book.model_dump()}
    books.append(new_book)
    return new_book

The endpoint receives a BookCreate (no id) and returns a BookResponse (with id). response_model=BookResponse documents the structure in /docs.

curl -X POST http://127.0.0.1:8000/books \
  -H "Content-Type: application/json" \
  -d '{"title": "Fictions", "author": "Jorge Luis Borges", "year": 1944, "genre": "Short stories"}'

Expected output:

{"id": 4, "title": "Fictions", "author": "Jorge Luis Borges", "year": 1944, "genre": "Short stories", "available": true}

The client sent neither id nor available — the server generated one and the other took the default True.

GET — returning BookResponse and lists

@app.get("/books/{book_id}", response_model=BookResponse)
def get_book(book_id: int):
    book = next((b for b in books if b["id"] == book_id), None)
    if not book:
        return {"error": "Book not found"}
    return book

@app.get("/books", response_model=list[BookResponse])
def list_books():
    return books

response_model=list[BookResponse] says the response is a list. Each element is validated against BookResponse. In /docs, the schema shows an array with the exact structure.

PATCH — partial updates with BookPatch

@app.patch("/books/{book_id}", response_model=BookResponse)
def patch_book(book_id: int, book: BookPatch):
    existing = next((b for b in books if b["id"] == book_id), None)
    if not existing:
        return {"error": "Book not found"}
    update_data = book.model_dump(exclude_unset=True)
    existing.update(update_data)
    return existing
curl -X PATCH http://127.0.0.1:8000/books/3 \
  -H "Content-Type: application/json" \
  -d '{"available": true}'

Expected output:

{"id": 3, "title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "genre": "Experimental novel", "available": true}

Only available got updated. Nothing else was touched.


model_dump(exclude_unset=True) — the key to PATCH

Using the BookPatch we already defined:

patch = BookPatch(available=True)

print(patch.model_dump())
# {'title': None, 'author': None, 'year': None, 'genre': None, 'available': True}

print(patch.model_dump(exclude_unset=True))
# {'available': True}

With a plain model_dump(), every field shows up — including the Nones. If you run existing.update(patch.model_dump()), you overwrite everything with None. With exclude_unset=True, only available shows up.

"Not sent" vs "sent as None"

patch_clear = BookPatch(genre=None)
print(patch_clear.model_dump(exclude_unset=True))
# {'genre': None}  ← The client sent {"genre": null} — it wants to clear the genre

patch_only_avail = BookPatch(available=True)
print(patch_only_avail.model_dump(exclude_unset=True))
# {'available': True}  ← genre wasn't sent — it doesn't show up

In the first case genre was explicitly sent as nullexclude_unset=True includes it because it was sent. In the second, it wasn't sent — so it doesn't show up. This distinction is fundamental to getting PATCH right.


response_model — security filtering

response_model doesn't just document — it filters data. If your internal dict has fields that aren't in the response model, FastAPI strips them out:

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

users_db = [
    {"id": 1, "username": "maria_dev", "email": "maria@example.com", "password_hash": "$2b$12$abc123..."},
    {"id": 2, "username": "carlos_api", "email": "carlos@example.com", "password_hash": "$2b$12$def456..."},
]

class UserResponse(BaseModel):
    id: int
    username: str
    email: str

@app.get("/users/{user_id}", response_model=UserResponse)
def get_user(user_id: int):
    user = next((u for u in users_db if u["id"] == user_id), None)
    if not user:
        return {"error": "User not found"}
    return user
curl http://127.0.0.1:8000/users/1
# → {"id": 1, "username": "maria_dev", "email": "maria@example.com"}

password_hash exists in users_db but it doesn't show up. UserResponse doesn't have that field, so FastAPI filters it out. This is security: even if your code returns the full dict, response_model acts as a barrier. Without it, the response would include "password_hash": "$2b$12$abc123..." — a mistake that slips by unnoticed because it "works" in development.


Inheritance to cut duplication (DRY)

The problem: repeated code

BookCreate and BookResponse share title, author, year, genre, and available. If you add isbn, you have to add it in both. The solution is a base model:

from pydantic import BaseModel, Field


class BookBase(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    author: str = Field(min_length=1, max_length=100)
    year: int = Field(ge=1000, le=2030)
    genre: str
    available: bool = True


class BookCreate(BookBase):
    pass


class BookUpdate(BookBase):
    available: bool


class BookResponse(BookBase):
    id: int

BookBase holds the shared fields with their validations. BookCreate inherits them unchanged. BookUpdate overrides available to drop the default. BookResponse adds id. If tomorrow you add isbn to BookBase, it shows up automatically in all three.

What about BookPatch?

BookPatch can't inherit from BookBase because all of its fields have to be optional. Inheritance doesn't turn required fields into optional ones:

class BookPatch(BaseModel):
    title: str | None = None
    author: str | None = None
    year: int | None = None
    genre: str | None = None
    available: bool | None = None

BookPatch is defined on its own. It's the exception to the DRY pattern, and that's acceptable.

Combining inheritance with endpoints

The endpoints are used exactly the same way — the only thing that changes is where the models inherit from. With BookBase, BookCreate(BookBase), BookUpdate(BookBase), BookPatch(BaseModel), and BookResponse(BookBase) defined:

@app.post("/books", response_model=BookResponse, status_code=201)
def create_book(book: BookCreate):
    new_book = {"id": generate_id(), **book.model_dump()}
    books.append(new_book)
    return new_book

@app.put("/books/{book_id}", response_model=BookResponse)
def update_book(book_id: int, book: BookUpdate):
    existing = next((b for b in books if b["id"] == book_id), None)
    if not existing:
        return {"error": "Book not found"}
    existing.update(book.model_dump())
    return existing

@app.patch("/books/{book_id}", response_model=BookResponse)
def patch_book(book_id: int, book: BookPatch):
    existing = next((b for b in books if b["id"] == book_id), None)
    if not existing:
        return {"error": "Book not found"}
    existing.update(book.model_dump(exclude_unset=True))
    return existing
curl -X POST http://127.0.0.1:8000/books \
  -H "Content-Type: application/json" \
  -d '{"title": "Fictions", "author": "Jorge Luis Borges", "year": 1944, "genre": "Short stories"}'
# → {"id": 2, "title": "Fictions", ..., "available": true}

curl -X PATCH http://127.0.0.1:8000/books/1 \
  -H "Content-Type: application/json" \
  -d '{"available": false}'
# → {"id": 1, ..., "available": false}

Comparison: one model vs separate models

AspectA single modelSeparate models
Optional fieldsid: int | None = None — ambiguousid only in BookResponse — clear
/docs documentationConfusing schema, optional fields that shouldn't beEach endpoint shows exactly what it expects and returns
SecurityEasy to expose password_hashresponse_model filters automatically
PATCHNo clean way to do partial updatesBookPatch with exclude_unset=True
MaintenanceHard to tell which fields apply whereEach model documents its purpose
ProductionNot recommendedIndustry standard

Connection to the project

The separate models you learned here are the exact pattern you'll use in the Module 6 CRUD project. You'll define TaskCreate, TaskUpdate, TaskPatch, and TaskResponse — each with the right fields for its operation. response_model will protect the output, model_dump(exclude_unset=True) will enable PATCH, and inheritance with TaskBase will remove duplication.


Troubleshooting

Problem 1: PATCH overwrites every field with None

Cause: You're using model_dump() without exclude_unset=True.

# ❌ model_dump() returns every field, including the None ones
update_data = book.model_dump()  # {'title': None, 'author': None, ...}
existing.update(update_data)  # Overwrites everything with None

# ✅ Only includes the fields that were sent
update_data = book.model_dump(exclude_unset=True)
existing.update(update_data)

Problem 2: response_model doesn't filter — password_hash shows up

Cause: You're returning a JSONResponse directly. response_model only filters when FastAPI serializes the return value.

# ❌ JSONResponse bypasses response_model
@app.get("/users/{user_id}", response_model=UserResponse)
def get_user(user_id: int):
    return JSONResponse(content=users_db[user_id])  # Nothing gets filtered

# ✅ Return a dict — FastAPI applies response_model
@app.get("/users/{user_id}", response_model=UserResponse)
def get_user(user_id: int):
    return users_db[user_id]  # FastAPI filters password_hash

Problem 3: BookPatch inherits from BookBase and title is still required

Cause: Inheritance doesn't turn required fields into optional ones.

# ❌ title is still required through inheritance
class BookPatch(BookBase):
    title: str | None = None  # Doesn't properly override the parent's constraints

# ✅ Define BookPatch without inheritance
class BookPatch(BaseModel):
    title: str | None = None
    author: str | None = None
    # ...

Problem 4: PUT accepts partial fields without an error

Cause: You're using BookCreate (which has defaults) instead of BookUpdate (which doesn't).

# ❌ BookCreate has available: bool = True → it doesn't require you to send it
@app.put("/books/{book_id}")
def update_book(book_id: int, book: BookCreate): ...

# ✅ BookUpdate has no defaults → everything is required
@app.put("/books/{book_id}")
def update_book(book_id: int, book: BookUpdate): ...

Problem 5: Error returning a BookCreate where a BookResponse is expected

Cause: The input model has no id — you still need to build the complete dict.

# ❌ BookCreate has no 'id'
@app.post("/books", response_model=BookResponse)
def create_book(book: BookCreate):
    return book

# ✅ Build the dict with the id
@app.post("/books", response_model=BookResponse)
def create_book(book: BookCreate):
    return {"id": generate_id(), **book.model_dump()}

Exercises

Exercise 1: Separate models for products (Easy)

Create ProductCreate (name max 100 chars, price minimum 0.01, category) and ProductResponse (adds id and created_at as a string). Implement a POST endpoint that generates the id and the timestamp on the server.

See solution
from datetime import datetime
from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()
products = []

class ProductCreate(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    price: float = Field(ge=0.01)
    category: str

class ProductResponse(BaseModel):
    id: int
    name: str
    price: float
    category: str
    created_at: str

def generate_id():
    if not products:
        return 1
    return max(p["id"] for p in products) + 1

@app.post("/products", response_model=ProductResponse, status_code=201)
def create_product(product: ProductCreate):
    new_product = {"id": generate_id(), "created_at": datetime.now().isoformat(), **product.model_dump()}
    products.append(new_product)
    return new_product
curl -X POST http://127.0.0.1:8000/products \
  -H "Content-Type: application/json" \
  -d '{"name": "Laptop Pro", "price": 1299.99, "category": "Electronics"}'
# → {"id": 1, "name": "Laptop Pro", "price": 1299.99, "category": "Electronics", "created_at": "2026-03-13T10:30:00"}

Exercise 2: PATCH with exclude_unset (Medium)

Using the list of books from the capsule, create BookPatch and a PATCH /books/{book_id} endpoint that updates only the fields that were sent. Test it with {"year": 2024} and check that the other fields don't change.

See solution
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()
books = [
    {"id": 1, "title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez", "year": 1967, "genre": "Magical realism", "available": True},
]

class BookPatch(BaseModel):
    title: str | None = None
    author: str | None = None
    year: int | None = None
    genre: str | None = None
    available: bool | None = None

class BookResponse(BaseModel):
    id: int
    title: str
    author: str
    year: int
    genre: str
    available: bool

@app.patch("/books/{book_id}", response_model=BookResponse)
def patch_book(book_id: int, book: BookPatch):
    existing = next((b for b in books if b["id"] == book_id), None)
    if not existing:
        return {"error": "Book not found"}
    existing.update(book.model_dump(exclude_unset=True))
    return existing
curl -X PATCH http://127.0.0.1:8000/books/1 \
  -H "Content-Type: application/json" \
  -d '{"year": 2024}'
# → {"id": 1, "title": "One Hundred Years of Solitude", ..., "year": 2024, "available": true}

Only year changed. Everything else is untouched, thanks to exclude_unset=True.

Exercise 3: response_model to filter sensitive data (Medium)

Create a GET /users/{user_id} endpoint and a GET /users endpoint. The "database" has id, username, email, password_hash, and role. UserResponse only includes id, username, email, and role. Check that password_hash never shows up.

See solution
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()
users_db = [
    {"id": 1, "username": "ana_dev", "email": "ana@example.com", "password_hash": "$2b$12$xK3rf...", "role": "admin"},
    {"id": 2, "username": "luis_api", "email": "luis@example.com", "password_hash": "$2b$12$mP9qw...", "role": "user"},
]

class UserResponse(BaseModel):
    id: int
    username: str
    email: str
    role: str

@app.get("/users/{user_id}", response_model=UserResponse)
def get_user(user_id: int):
    user = next((u for u in users_db if u["id"] == user_id), None)
    if not user:
        return {"error": "User not found"}
    return user

@app.get("/users", response_model=list[UserResponse])
def list_users():
    return users_db
curl http://127.0.0.1:8000/users/1
# → {"id": 1, "username": "ana_dev", "email": "ana@example.com", "role": "admin"}
# password_hash filtered out automatically by response_model

Exercise 4: CRUD with inheritance for articles (Medium)

Create an Article resource with: title (1-150), content (1-5000), author (1-100), published (default False). Use inheritance: ArticleBaseArticleCreate, ArticleResponse (with id). Define ArticlePatch separately. Implement POST, GET list, and PATCH.

See solution
from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()

articles = []


class ArticleBase(BaseModel):
    title: str = Field(min_length=1, max_length=150)
    content: str = Field(min_length=1, max_length=5000)
    author: str = Field(min_length=1, max_length=100)
    published: bool = False

class ArticleCreate(ArticleBase):
    pass

class ArticlePatch(BaseModel):
    title: str | None = None
    content: str | None = None
    author: str | None = None
    published: bool | None = None

class ArticleResponse(ArticleBase):
    id: int


def generate_id():
    if not articles:
        return 1
    return max(a["id"] for a in articles) + 1

@app.post("/articles", response_model=ArticleResponse, status_code=201)
def create_article(article: ArticleCreate):
    new_article = {"id": generate_id(), **article.model_dump()}
    articles.append(new_article)
    return new_article

@app.get("/articles", response_model=list[ArticleResponse])
def list_articles():
    return articles

@app.patch("/articles/{article_id}", response_model=ArticleResponse)
def patch_article(article_id: int, article: ArticlePatch):
    existing = next((a for a in articles if a["id"] == article_id), None)
    if not existing:
        return {"error": "Article not found"}
    existing.update(article.model_dump(exclude_unset=True))
    return existing
curl -X POST http://127.0.0.1:8000/articles \
  -H "Content-Type: application/json" \
  -d '{"title": "FastAPI in production", "content": "Complete guide...", "author": "María López"}'
# → {"id": 1, ..., "published": false}

curl -X PATCH http://127.0.0.1:8000/articles/1 \
  -H "Content-Type: application/json" \
  -d '{"published": true}'
# → {"id": 1, ..., "published": true}

Exercise 5: Models with automatic timestamps (Hard)

Extend the books pattern so BookResponse includes created_at and updated_at. created_at is set on creation, updated_at is refreshed on every PATCH. The input models must not include these fields.

See solution
from datetime import datetime
from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()

books = [
    {"id": 1, "title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez",
     "year": 1967, "genre": "Magical realism", "available": True,
     "created_at": "2026-01-15T10:00:00", "updated_at": "2026-01-15T10:00:00"},
]

class BookCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    author: str = Field(min_length=1, max_length=100)
    year: int = Field(ge=1000, le=2030)
    genre: str
    available: bool = True

class BookPatch(BaseModel):
    title: str | None = None
    author: str | None = None
    year: int | None = None
    genre: str | None = None
    available: bool | None = None

class BookResponse(BaseModel):
    id: int
    title: str
    author: str
    year: int
    genre: str
    available: bool
    created_at: str
    updated_at: str

def generate_id():
    if not books:
        return 1
    return max(b["id"] for b in books) + 1

@app.post("/books", response_model=BookResponse, status_code=201)
def create_book(book: BookCreate):
    now = datetime.now().isoformat()
    new_book = {"id": generate_id(), "created_at": now, "updated_at": now, **book.model_dump()}
    books.append(new_book)
    return new_book

@app.patch("/books/{book_id}", response_model=BookResponse)
def patch_book(book_id: int, book: BookPatch):
    existing = next((b for b in books if b["id"] == book_id), None)
    if not existing:
        return {"error": "Book not found"}
    existing.update(book.model_dump(exclude_unset=True))
    existing["updated_at"] = datetime.now().isoformat()
    return existing
curl -X POST http://127.0.0.1:8000/books \
  -H "Content-Type: application/json" \
  -d '{"title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "genre": "Novel"}'
# → {"id": 2, ..., "created_at": "2026-03-13T14:30:00", "updated_at": "2026-03-13T14:30:00"}

curl -X PATCH http://127.0.0.1:8000/books/2 \
  -H "Content-Type: application/json" \
  -d '{"available": false}'
# → {"id": 2, ..., "created_at": "2026-03-13T14:30:00", "updated_at": "2026-03-13T14:35:22"}

created_at doesn't change. updated_at reflects the moment of the PATCH. Neither one shows up in the input models.


Summary

  • One model for everything doesn't work when the input and the output have different fields (id, created_at, password_hash)
  • Models split by operation: BookCreate (POST), BookUpdate (PUT), BookPatch (PATCH), BookResponse (output)
  • response_model automatically filters out sensitive fields — it acts as a security barrier
  • model_dump(exclude_unset=True) is mandatory for PATCH — it tells "field not sent" apart from "field sent as None"
  • Inheritance with BookBase removes duplication: shared fields once, children add or override
  • BookPatch doesn't inherit from BookBase — all of its fields must be optional, so it's the exception to DRY
  • response_model=list[BookResponse] documents and validates list-shaped responses
  • PUT requires every field (no defaults) vs PATCH, which accepts partial data (everything optional)
  • This pattern is the industry standard in every professional FastAPI API

Next capsule: In Module 5 you'll learn Error Handling with HTTPException and CORS — FastAPI's idiomatic way to handle errors and allow cross-origin access to your API.


Additional resources

  1. FastAPI - Response Model - Official documentation on response_model and data filtering
  2. FastAPI - Extra Models - The separate request/response model pattern
  3. Pydantic - Serialization - model_dump(), exclude_unset, and serialization options in Pydantic v2
  4. FastAPI - Body Updates - PUT vs PATCH and partial updates with exclude_unset
  5. Pydantic - Model Inheritance - Model inheritance and composition in Pydantic v2
  6. FastAPI Best Practices - GitHub - A collection of professional patterns, including model separation