Pydantic request and response models

Define the wire contract with Pydantic: validation, custom validators, and response models that stop internal fields leaking out of your API.

Request models

# schemas.py
from pydantic import BaseModel, ConfigDict, Field, field_validator

class NoteIn(BaseModel):
    model_config = ConfigDict(extra="forbid")   # reject unknown fields

    title: str = Field(min_length=1, max_length=120)
    body: str = ""
    tags: list[str] = Field(default_factory=list, max_length=10)

    @field_validator("title")
    @classmethod
    def normalise_title(cls, value: str) -> str:
        value = " ".join(value.split())
        if not value:
            raise ValueError("title cannot be blank")
        return value

    @field_validator("tags")
    @classmethod
    def lowercase_tags(cls, values: list[str]) -> list[str]:
        return sorted({t.strip().lower() for t in values if t.strip()})
from fastapi import FastAPI
from schemas import NoteIn

app = FastAPI()

@app.post("/notes", status_code=201)
def create_note(note: NoteIn) -> dict[str, object]:
    return {"stored": note.title, "tags": note.tags}
  • Declaring the model as a parameter makes it the JSON body — no request.json() and no manual parsing.
  • extra="forbid" turns typos in the payload into a 422 instead of silently ignoring them.
  • default_factory=list avoids the shared-mutable-default bug that plain = [] would create.
  • Field constraints (min_length, ge, pattern) are enforced before your handler runs.

Response models

class NoteOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)   # build from ORM objects

    id: int
    title: str
    body: str

@app.post("/notes", response_model=NoteOut, status_code=201)
def create_note(note: NoteIn):
    row = db_insert(note)          # returns an object with id, title, body AND password_hash
    return row                     # response_model strips everything not declared

@app.get("/notes/{note_id}", response_model=NoteOut)
def get_note(note_id: int):
    return db_get(note_id)

@app.get("/notes", response_model=list[NoteOut],
         response_model_exclude_unset=True)
def list_notes():
    return db_list()
⚠️
Without response_model (or a return type annotation) FastAPI serialises whatever you return. Returning an ORM row directly then hands every column — password hashes, internal flags, other users' data — to the client. Declare the output shape explicitly on every endpoint.

Useful patterns

class NotePatch(BaseModel):
    title: str | None = None
    body: str | None = None

@app.patch("/notes/{note_id}", response_model=NoteOut)
def patch_note(note_id: int, patch: NotePatch):
    # exclude_unset distinguishes "field absent" from "field set to null"
    changes = patch.model_dump(exclude_unset=True)
    return db_update(note_id, changes)

class Page(BaseModel):
    id: int
    title: str

class PagedNoteOut(BaseModel):
    items: list[NoteOut]
    total: int
    next_cursor: str | None = None
GoalTool
Reject extra keysConfigDict(extra="forbid")
Read from ORM / objectsConfigDict(from_attributes=True)
Partial updatesmodel_dump(exclude_unset=True)
Hide unset defaults in outputresponse_model_exclude_unset=True
Cross-field checks@model_validator(mode="after")
Reuse validatorsMixin base classes or Annotated types

FAQ

Where should I put the shared fields?
Split into NoteBase / NoteIn / NoteOut and inherit. Output models should never carry fields the client is not allowed to set, such as id or created_at.
Can I return a different shape on error?
Yes — declare it in the decorator with responses={404: {"model": ErrorOut}} so the generated OpenAPI schema and the client SDK stay accurate.

Your first FastAPI app Dependencies and async

Last refreshed 2026-09-18.