Schema Separation — Create, Update and Response Models

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
Note: The 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.
Tip: Create a 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.
Warning: Never share the same schema for create and response. If you do, clients can see fields like 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

🧠 Test Yourself

A PATCH request sends {"title": "New Title"}. Your PostUpdate schema has title: str | None = None and body: str | None = None. You call update.model_dump() and apply all fields. What happens to the existing body in the database?