ORMs Proficient¶
🌐 Web & APIs Track · Level 4
When you'd use this
SQLAlchemy 2.0, relationships, async, migrations and query optimization.
Map database rows to objects with SQLAlchemy or Django ORM, so you work with Python classes instead of raw SQL for most queries.
SQLAlchemy 2.0 — Declarative Models¶
Map Python classes to tables so you work with objects instead of raw SQL.
from sqlalchemy import create_engine, String, ForeignKey, func
from sqlalchemy.orm import (
DeclarativeBase, Mapped, mapped_column,
relationship, Session, sessionmaker
)
from datetime import datetime
# ─── Engine ───────────────────────────────────────
engine = create_engine("sqlite:///app.db", echo=False)
# ─── Base class ───────────────────────────────────
class Base(DeclarativeBase):
pass
# ─── Models ───────────────────────────────────────
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
email: Mapped[str] = mapped_column(String(200), unique=True)
created_at: Mapped[datetime] = mapped_column(default=func.now())
# Relationship — one user has many posts
posts: Mapped[list["Post"]] = relationship(back_populates="author", cascade="all, delete-orphan")
def __repr__(self):
return f"User(id={self.id}, name={self.name!r})"
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str]
published: Mapped[bool] = mapped_column(default=False)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
# Relationship — back reference
author: Mapped["User"] = relationship(back_populates="posts")
tags: Mapped[list["Tag"]] = relationship(secondary="post_tags", back_populates="posts")
def __repr__(self):
return f"Post(id={self.id}, title={self.title!r})"
# Many-to-many association table
from sqlalchemy import Table, Column, Integer
post_tags = Table(
"post_tags", Base.metadata,
Column("post_id", Integer, ForeignKey("posts.id"), primary_key=True),
Column("tag_id", Integer, ForeignKey("tags.id"), primary_key=True),
)
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50), unique=True)
posts: Mapped[list["Post"]] = relationship(secondary="post_tags", back_populates="tags")
# Create all tables
Base.metadata.create_all(engine)
CRUD operations¶
Create, read, update, delete rows through the session — the everyday ORM operations.
from sqlalchemy import select
from sqlalchemy.orm import Session
SessionLocal = sessionmaker(bind=engine)
# ─── CREATE ───────────────────────────────────────
with Session(engine) as session:
user = User(name="Alice", email="alice@example.com")
session.add(user)
post = Post(title="First Post", content="Hello World!", author=user)
session.add(post)
session.commit()
print(f"Created user {user.id} and post {post.id}")
# ─── READ (select) ───────────────────────────────
with Session(engine) as session:
# Get by primary key
user = session.get(User, 1)
print(user) # User(id=1, name='Alice')
# Query with filter
stmt = select(User).where(User.name == "Alice")
alice = session.scalars(stmt).first()
# All users
all_users = session.scalars(select(User)).all()
# Complex query
stmt = (
select(Post)
.where(Post.published == True)
.order_by(Post.created_at.desc())
.limit(10)
)
posts = session.scalars(stmt).all()
# Join
stmt = (
select(Post)
.join(User)
.where(User.name == "Alice")
)
alice_posts = session.scalars(stmt).all()
# ─── UPDATE ───────────────────────────────────────
with Session(engine) as session:
user = session.get(User, 1)
user.name = "Alice Smith" # just modify the object
session.commit() # changes detected and saved
# Bulk update
from sqlalchemy import update
session.execute(
update(Post).where(Post.author_id == 1).values(published=True)
)
session.commit()
# ─── DELETE ───────────────────────────────────────
with Session(engine) as session:
user = session.get(User, 1)
session.delete(user) # cascades to posts (cascade="all, delete-orphan")
session.commit()
Eager vs Lazy Loading¶
Control when related rows are fetched to avoid the N+1 query problem.
from sqlalchemy.orm import selectinload, joinedload, lazyload
# LAZY loading (default) — N+1 problem!
with Session(engine) as session:
users = session.scalars(select(User)).all()
for user in users:
print(user.posts) # each access = separate SQL query!
# EAGER loading — one query for all
with Session(engine) as session:
# selectinload — separate IN query (good for collections)
stmt = select(User).options(selectinload(User.posts))
users = session.scalars(stmt).all()
for user in users:
print(user.posts) # no extra queries!
# joinedload — single JOIN query (good for single relationships)
stmt = select(Post).options(joinedload(Post.author))
posts = session.scalars(stmt).all()
for post in posts:
print(post.author.name) # already loaded
Async SQLAlchemy¶
Use the ORM with async/await for non-blocking database access in async apps.
from sqlalchemy.ext.asyncio import (
create_async_engine, AsyncSession, async_sessionmaker
)
async_engine = create_async_engine("sqlite+aiosqlite:///app.db")
AsyncSessionLocal = async_sessionmaker(async_engine, class_=AsyncSession)
async def get_user(user_id: int) -> User | None:
async with AsyncSessionLocal() as session:
return await session.get(User, user_id)
async def create_user(name: str, email: str) -> User:
async with AsyncSessionLocal() as session:
user = User(name=name, email=email)
session.add(user)
await session.commit()
await session.refresh(user)
return user
# With FastAPI
from fastapi import Depends
async def get_db():
async with AsyncSessionLocal() as session:
yield session
@app.get("/users/{user_id}")
async def read_user(user_id: int, db: AsyncSession = Depends(get_db)):
user = await db.get(User, user_id)
if not user:
raise HTTPException(404)
return user
Alembic — database migrations¶
Version and apply schema changes generated from your models.
# Initialize
pip install alembic
alembic init migrations
# Configure alembic.ini → set sqlalchemy.url
# Configure migrations/env.py → import your Base.metadata
# Generate migration from model changes
alembic revision --autogenerate -m "Add phone column to users"
# Apply migrations
alembic upgrade head
# Rollback one step
alembic downgrade -1
# Show current state
alembic current
alembic history
Generated migration file:
# migrations/versions/abc123_add_phone_column.py
def upgrade():
op.add_column("users", sa.Column("phone", sa.String(20)))
def downgrade():
op.drop_column("users", "phone")
Query optimization patterns¶
Shape queries and indexes to keep them fast as data grows.
Avoiding N+1¶
# BAD — N+1 queries (1 query for users + N queries for posts)
users = session.scalars(select(User)).all()
for user in users:
print(len(user.posts)) # each triggers a SELECT
# GOOD — 2 queries total (1 for users, 1 IN query for all posts)
users = session.scalars(
select(User).options(selectinload(User.posts))
).all()
Selecting only needed columns¶
# Instead of loading full objects:
stmt = select(User.name, User.email).where(User.age > 25)
results = session.execute(stmt).all()
# Returns lightweight tuples, not full User objects
Pagination¶
def paginate(session, query, page: int, per_page: int = 20):
total = session.scalar(select(func.count()).select_from(query.subquery()))
items = session.scalars(
query.offset((page - 1) * per_page).limit(per_page)
).all()
return {
"items": items,
"total": total,
"page": page,
"pages": (total + per_page - 1) // per_page,
}
Practice Exercises¶
- Build a blog system with User, Post, Comment, Tag models and proper relationships.
- Implement soft-delete — add
deleted_atfield and filter it in all queries. - Set up Alembic and create/apply 5 incremental migrations.
- Demonstrate N+1 — show the problem and fix with
selectinload. - Build an async repository pattern with SQLAlchemy + FastAPI.
- Write a query that finds the top 5 authors by post count using
GROUP BYandfunc.count.
💬 Discussion
Have a question about this topic? Found an error? Share your thoughts below.