Iron Law
NEVER USE A SINGLE PYDANTIC MODEL FOR BOTH CREATE AND RESPONSE — separate contracts prevent leaking internal fields, block over-posting, and make API evolution safe
Pydantic v2 Models
Create Pydantic models following the multi-model pattern for clean, safe API contracts.
Multi-Model Pattern
| Model | Purpose | Required Fields |
|---|---|---|
Base |
Common fields shared across all variants | Shared, validated fields |
Create |
POST request body — fields the client supplies | Required fields only |
Update |
PATCH request body — partial update | All optional (use None default) |
Response |
API response — all fields the client sees | Includes id, timestamps |
InDB |
Internal/repository layer — DO NOT expose directly | DB-only fields (e.g., hashed passwords) |
Full Pattern Example
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field, ConfigDict
# ── Base: shared validated fields ──────────────────────────────────────────
class UserBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
email: str = Field(..., pattern=r"^[^@]+@[^@]+\.[^@]+$")
# ── Create: POST /users body ────────────────────────────────────────────────
class UserCreate(UserBase):
"""Request body for user creation. Never include id or timestamps."""
password: str = Field(..., min_length=8)
# ── Update: PATCH /users/{id} body ─────────────────────────────────────────
class UserUpdate(BaseModel):
"""All fields optional — client sends only what changes."""
name: str | None = Field(None, min_length=1, max_length=100)
email: str | None = Field(None, pattern=r"^[^@]+@[^@]+\.[^@]+$")
# ── Response: GET /users or POST /users response ────────────────────────────
class UserResponse(UserBase):
"""What the API returns. Never include password or internal fields."""
id: UUID
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True) # ORM compatibility
# ── InDB: internal repository layer only ────────────────────────────────────
class UserInDB(UserResponse):
"""Internal model for repository layer. NEVER return this from a route."""
hashed_password: str
camelCase Aliases (for JS/TS clients)
from pydantic import BaseModel, Field, ConfigDict
class UserResponse(BaseModel):
workspace_id: str = Field(..., alias="workspaceId")
created_at: datetime = Field(..., alias="createdAt")
updated_at: datetime = Field(..., alias="updatedAt")
model_config = ConfigDict(
populate_by_name=True, # Accept both snake_case and camelCase on input
)
Use model.model_dump(by_alias=True) when serializing for JSON responses to emit camelCase.
PATCH Endpoint — Partial Update Pattern
from pydantic import BaseModel, Field
class ProjectUpdate(BaseModel):
"""All fields optional for PATCH requests. None means 'do not change'."""
name: str | None = Field(None, min_length=1)
description: str | None = None
is_active: bool | None = None
In the service layer, apply only the non-None fields:
async def update_project(id: UUID, payload: ProjectUpdate, session: AsyncSession) -> Project:
project = await session.get(Project, id)
update_data = payload.model_dump(exclude_unset=True) # Only fields client sent
for field, value in update_data.items():
setattr(project, field, value)
await session.commit()
await session.refresh(project)
return project
PostgreSQL / SQLAlchemy Integration
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, DateTime, func
from uuid import UUID
import uuid
class Base(DeclarativeBase):
pass
class UserORM(Base):
__tablename__ = "users"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(100), nullable=False)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(),
# Convert ORM → Pydantic Response (model_config from_attributes=True required)
user_response = UserResponse.model_validate(user_orm_instance)
Integration Steps
- Create models in
src/<service>/schemas/<resource>.py - Export from
src/<service>/schemas/__init__.py - Import in routes:
from src.<service>.schemas.<resource> import UserCreate, UserResponse - Use
Createas request body param,Responseas return type annotation - Use
model_validate(orm_obj)in service layer to convert ORM → Response
Naming Conventions
| Model | Naming Pattern | Example |
|---|---|---|
| Base | {Resource}Base |
UserBase |
| Create | {Resource}Create |
UserCreate |
| Update | {Resource}Update |
UserUpdate |
| Response | {Resource}Response |
UserResponse |
| InDB | {Resource}InDB |
UserInDB |
Hard Prohibitions
- Never return
InDBfrom a route handler — it contains internal fields (hashed passwords, etc.) - Never use
Optional[str]— usestr | None(Python 3.10+ union syntax) - Never use
@validator— this is Pydantic v1 syntax; use@field_validatorin v2 - Never use
orm_mode = True— usemodel_config = ConfigDict(from_attributes=True)in v2 - Never skip
exclude_unset=Truein PATCH service logic — it distinguishes "not sent" fromnull