Cheatsheet for cleanly mapping between SQLAlchemy ORM and Pydantic.
Why separate
Pydantic models = API I/O contracts. SQLAlchemy models = DB persistence.
They look similar; their concerns differ:
- API schemas evolve with consumers.
- DB models evolve with storage / queries.
- Sensitive columns (password_hash) shouldn’t leak to API.
Don’t reuse SQLAlchemy classes for API responses.
Read / Create / Update trio
# Pydantic
class UserBase(BaseModel):
email: EmailStr
name: str
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
email: EmailStr | None = None
name: str | None = None
class UserRead(UserBase):
id: int
created_at: datetime
model_config = {"from_attributes": True}
# SQLAlchemy
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(unique=True)
name: Mapped[str]
hashed_password: Mapped[str]
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
Note: hashed_password exists on DB model only.
Create handler
@app.post("/users", response_model=UserRead, status_code=201)
async def create(data: UserCreate, db: AsyncSession = Depends(get_db)):
user = User(
email=data.email,
name=data.name,
hashed_password=hash_pw(data.password),
)
db.add(user)
await db.commit()
await db.refresh(user)
return user # SA → UserRead
Get handler
@app.get("/users/{id}", response_model=UserRead)
async def get_(id: int, db: AsyncSession = Depends(get_db)):
user = await db.get(User, id)
if not user: raise HTTPException(404)
return user
response_model=UserRead + from_attributes=True = automatic mapping.
PATCH (partial update)
@app.patch("/users/{id}", response_model=UserRead)
async def update_(id: int, data: UserUpdate, db: AsyncSession = Depends(get_db)):
user = await db.get(User, id)
if not user: raise HTTPException(404)
# Only fields explicitly sent
for k, v in data.model_dump(exclude_unset=True).items():
setattr(user, k, v)
await db.commit()
return user
List handler
@app.get("/users", response_model=list[UserRead])
async def list_(db: AsyncSession = Depends(get_db)):
return (await db.execute(select(User))).scalars().all()
Nested relations
# SA
class User(Base):
posts: Mapped[list["Post"]] = relationship(back_populates="author")
# Pydantic
class PostRead(BaseModel):
id: int
title: str
model_config = {"from_attributes": True}
class UserWithPosts(UserRead):
posts: list[PostRead]
Eager-load in handler:
@app.get("/users/{id}", response_model=UserWithPosts)
async def get_(id: int, db: AsyncSession = Depends(get_db)):
user = await db.scalar(
select(User).options(selectinload(User.posts)).where(User.id == id)
)
if not user: raise HTTPException(404)
return user
SA hybrid_property → Pydantic computed_field
# SA
class User(Base):
first: Mapped[str]
last: Mapped[str]
@hybrid_property
def full_name(self) -> str:
return f"{self.first} {self.last}"
# Pydantic — reads via from_attributes
class UserRead(BaseModel):
id: int
full_name: str # populated from SA hybrid_property
model_config = {"from_attributes": True}
Or use Pydantic @computed_field if SA model doesn’t have it:
class UserRead(BaseModel):
id: int
first: str
last: str
@computed_field
@property
def full_name(self) -> str:
return f"{self.first} {self.last}"
model_config = {"from_attributes": True}
Sensitive columns
Never include in Read model:
class UserRead(BaseModel):
id: int
email: str
# NO hashed_password, internal_notes, etc.
If you forget: FastAPI’s response_model shaping drops fields not in the model.
Aliases for camelCase API
from pydantic.alias_generators import to_camel
class UserRead(BaseModel):
full_name: str
created_at: datetime
model_config = {
"from_attributes": True,
"alias_generator": to_camel,
"populate_by_name": True,
}
# Output: {"fullName": "...", "createdAt": "..."}
SA enum ↔ Pydantic
import enum
class Role(str, enum.Enum):
USER = "user"
ADMIN = "admin"
# SA
class User(Base):
role: Mapped[Role] = mapped_column(SAEnum(Role))
# Pydantic — uses the same enum
class UserRead(BaseModel):
role: Role
model_config = {"from_attributes": True}
JSONB ↔ Pydantic-backed JSONB
See SQLA Postgres Cheatsheet 02
for PydanticJSONB.
Service layer (between API and DB)
class UserService:
def __init__(self, db: AsyncSession):
self.db = db
async def create(self, data: UserCreate) -> User:
user = User(email=data.email, hashed_password=hash_pw(data.password))
self.db.add(user)
await self.db.commit()
return user
async def get(self, id: int) -> User | None:
return await self.db.get(User, id)
Handler stays thin:
@app.post("/users", response_model=UserRead)
async def create_(data: UserCreate, svc: UserService = Depends(get_user_service)):
return await svc.create(data)
Common mistakes
- Returning SA model directly without
response_model— leaks fields. - Same model for input and output — too rigid; sensitive fields in input contract.
- Lazy-load in async without eager-load — error converting to Pydantic.
from_attributes=Truemissing on Read model — model_validate fails on SA instance.
Read this next
If you want my schema/model trio template, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .