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, nocreated_at. Only the fields the client provides.availabledefaults toTrue. - BookUpdate — Like Create but without defaults. PUT replaces everything — if you leave out
available, that's an error. - BookPatch — Everything optional. PATCH sends only what you want to change.
- BookResponse — Includes
id(always present). Never includespassword_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 null — exclude_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
| Aspect | A single model | Separate models |
|---|---|---|
| Optional fields | id: int | None = None — ambiguous | id only in BookResponse — clear |
| /docs documentation | Confusing schema, optional fields that shouldn't be | Each endpoint shows exactly what it expects and returns |
| Security | Easy to expose password_hash | response_model filters automatically |
| PATCH | No clean way to do partial updates | BookPatch with exclude_unset=True |
| Maintenance | Hard to tell which fields apply where | Each model documents its purpose |
| Production | Not recommended | Industry 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: ArticleBase → ArticleCreate, 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_modelautomatically filters out sensitive fields — it acts as a security barriermodel_dump(exclude_unset=True)is mandatory for PATCH — it tells "field not sent" apart from "field sent as None"- Inheritance with
BookBaseremoves duplication: shared fields once, children add or override BookPatchdoesn't inherit fromBookBase— all of its fields must be optional, so it's the exception to DRYresponse_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
- FastAPI - Response Model - Official documentation on response_model and data filtering
- FastAPI - Extra Models - The separate request/response model pattern
- Pydantic - Serialization - model_dump(), exclude_unset, and serialization options in Pydantic v2
- FastAPI - Body Updates - PUT vs PATCH and partial updates with exclude_unset
- Pydantic - Model Inheritance - Model inheritance and composition in Pydantic v2
- FastAPI Best Practices - GitHub - A collection of professional patterns, including model separation