The single most impactful Pydantic pattern in FastAPI development is schema separation — using different Pydantic models for different operations on the same resource. A single “Post” model that serves as both the request body and the response body forces you to either expose internal fields (id, created_at, password_hash) in the request body or accept those fields from clients who should not be able to set them. The three-schema pattern — PostCreate, PostUpdate, PostResponse — solves this cleanly and is the pattern used in virtually every production FastAPI codebase.
The Three-Schema Pattern
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Literal
# ── 1. Create Schema — what the client sends to create a resource ─────────────
# All fields required (except those with sensible defaults)
# No id, created_at, or server-generated fields
class PostCreate(BaseModel):
title: str = Field(..., min_length=3, max_length=200)
body: str = Field(..., min_length=10)
slug: str = Field(..., pattern=r"^[a-z0-9-]+$", max_length=200)
status: Literal["draft", "published"] = "draft"
tags: list[str] = []
# ── 2. Update Schema — what the client sends to update a resource (PATCH) ─────
# ALL fields optional — client sends only what they want to change
class PostUpdate(BaseModel):
title: str | None = Field(None, min_length=3, max_length=200)
body: str | None = None
slug: str | None = Field(None, pattern=r"^[a-z0-9-]+$")
status: Literal["draft", "published", "archived"] | None = None
tags: list[str] | None = None
def to_update_dict(self) -> dict:
"""Return only fields that were explicitly set (not None-defaulted)."""
return self.model_dump(exclude_none=True)
# ── 3. Response Schema — what the API returns to the client ───────────────────
# Includes computed/server-generated fields
# Excludes sensitive internal fields (password_hash, etc.)
class PostResponse(BaseModel):
model_config = {"from_attributes": True} # read from ORM objects
id: int
title: str
body: str
slug: str
status: str
tags: list[str] = []
view_count: int
created_at: datetime
updated_at: datetime
author_id: int
PostUpdate schema’s to_update_dict() method returns model_dump(exclude_none=True) — only the fields the client actually sent. If a PATCH request sends {"title": "New Title"}, to_update_dict() returns {"title": "New Title"} — not the full dict with all other fields as None. Without exclude_none=True, applying the update would overwrite existing database values with None for every field the client did not include. This is the most common PATCH implementation bug in FastAPI.PostBase model with fields shared across all three schemas, then inherit from it. PostCreate(PostBase) adds required-only fields, PostUpdate inherits from PostBase but makes everything optional, and PostResponse(PostBase) adds server-side fields. This reduces duplication while keeping the three schemas distinct. Use Pydantic’s model_fields to inspect which fields each schema has when debugging.id and created_at in the request body schema in Swagger UI (implying they should send them), and your validation accepts those fields even though you ignore them. This confuses API consumers and wastes bandwidth. Always define separate schemas even if they look very similar — the schemas will diverge over time as features evolve.Base + Specialised Schema Pattern
from pydantic import BaseModel, Field
from datetime import datetime
# ── Shared base ────────────────────────────────────────────────────────────────
class PostBase(BaseModel):
title: str = Field(..., min_length=3, max_length=200)
body: str = Field(..., min_length=10)
# ── Create: base + extra required fields ─────────────────────────────────────
class PostCreate(PostBase):
slug: str = Field(..., pattern=r"^[a-z0-9-]+$")
tags: list[str] = []
# ── Update: all optional ───────────────────────────────────────────────────────
class PostUpdate(BaseModel): # do NOT inherit PostBase here (all fields optional)
title: str | None = Field(None, min_length=3, max_length=200)
body: str | None = None
slug: str | None = Field(None, pattern=r"^[a-z0-9-]+$")
tags: list[str] | None = None
status: str | None = None
# ── Response: base + server fields ────────────────────────────────────────────
class PostResponse(PostBase):
model_config = {"from_attributes": True}
id: int
slug: str
status: str
view_count: int
author_id: int
created_at: datetime
updated_at: datetime
Using Schemas in Route Handlers
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
router = APIRouter()
@router.post("/", response_model=PostResponse, status_code=201)
def create_post(post: PostCreate, db: Session = Depends(get_db),
current_user = Depends(get_current_user)):
db_post = Post(**post.model_dump(), author_id=current_user.id)
db.add(db_post)
db.commit()
db.refresh(db_post)
return db_post # filtered through PostResponse by response_model
@router.patch("/{post_id}", response_model=PostResponse)
def update_post(post_id: int, update: PostUpdate,
db: Session = Depends(get_db),
current_user = Depends(get_current_user)):
post = db.query(Post).filter(Post.id == post_id).first()
if not post:
raise HTTPException(404, "Post not found")
if post.author_id != current_user.id:
raise HTTPException(403, "Not your post")
# Only update provided fields
for field, value in update.to_update_dict().items():
setattr(post, field, value)
db.commit()
db.refresh(post)
return post
Common Mistakes
Mistake 1 — Using model_dump() without exclude_none in PATCH handler
❌ Wrong — overwrites all fields including ones not sent:
for field, value in update.model_dump().items():
setattr(post, field, value) # sets body=None if client only sent title!
✅ Correct:
for field, value in update.model_dump(exclude_none=True).items():
setattr(post, field, value) # ✓ only changes provided fields
Mistake 2 — Accepting server-generated fields in Create schema
❌ Wrong — client can set id and created_at:
class PostCreate(BaseModel):
id: int | None = None # client should NEVER set this!
title: str
created_at: datetime | None = None # server sets this, not client
✅ Correct — PostCreate only has user-supplied fields.
Mistake 3 — Forgetting from_attributes on response schema
❌ Wrong — ValidationError when building from SQLAlchemy ORM:
class PostResponse(BaseModel):
id: int; title: str
# PostResponse.model_validate(orm_post) → ValidationError
✅ Correct:
class PostResponse(BaseModel):
model_config = {"from_attributes": True} # ✓
id: int; title: str
Quick Reference
| Schema | Purpose | Fields |
|---|---|---|
PostCreate |
POST request body | User-supplied required fields, no id/timestamps |
PostUpdate |
PATCH request body | All fields optional (| None = None) |
PostResponse |
Response body | All fields including id, timestamps, from_attributes=True |