Module 4: SQLAlchemy ORM
Queries with select(): filter, join, group_by
Description
select() is the universal query constructor in SQLAlchemy 2.0. It replaces 1.x's session.query(). Any SQL query you wrote in modules 2-3 can be expressed with select() — JOINs, subqueries, aggregations, window functions, all of it.
This capsule teaches you to build complex queries with the modern API. By the end, you will be able to translate any module 2 query into SQLAlchemy.
Basic syntax
from sqlalchemy import select
from app.models import User
stmt = select(User)
# SELECT users.id, users.email, ... FROM users
result = session.execute(stmt)
users = result.scalars().all()
select(User) returns User objects. If you want tuples (specific columns):
stmt = select(User.username, User.email)
# SELECT users.username, users.email FROM users
result = session.execute(stmt)
rows = result.all()
# [Row(username='maria', email='maria@blog.local'), ...]
WHERE: filtering
stmt = select(User).where(User.is_active == True)
# WHERE users.is_active = TRUE
# Multiple conditions (implicit AND)
stmt = select(User).where(
User.is_active == True,
User.email_verified == True,
)
# WHERE users.is_active = TRUE AND users.email_verified = TRUE
# Explicit AND/OR
from sqlalchemy import and_, or_
stmt = select(User).where(
or_(
User.is_admin == True,
and_(User.is_active == True, User.email_verified == True),
)
)
Operators
| Python | SQL | Example |
|---|---|---|
==, != | =, != | User.username == "maria" |
<, <=, >, >= | the same | Post.created_at >= datetime(2026, 1, 1) |
.in_(list) | IN | User.id.in_([id1, id2]) |
.notin_(list) | NOT IN | Post.author_id.notin_(banned_ids) |
.between(a, b) | BETWEEN | Post.created_at.between(start, end) |
.like(pattern) | LIKE | User.email.like('%@gmail.com') |
.ilike(pattern) | ILIKE (case-insensitive) | User.username.ilike('mar%') |
.is_(None), .is_not(None) | IS NULL, IS NOT NULL | User.deleted_at.is_(None) |
~ | NOT | ~User.is_active |
⚠️ Never use
User.deleted_at == None— Python reads that as a special value. AlwaysUser.deleted_at.is_(None).
ORDER BY, LIMIT, OFFSET
stmt = (
select(Post)
.where(Post.published == True)
.order_by(Post.published_at.desc())
.limit(10)
.offset(20) # pagination
)
.desc() and .asc() for the direction (default: ASC).
Multiple columns:
.order_by(Post.published.desc(), Post.published_at.desc())
JOINs
INNER JOIN
stmt = (
select(Post)
.join(User, Post.author_id == User.id)
.where(User.username == "maria")
)
# SELECT posts.* FROM posts INNER JOIN users ON posts.author_id = users.id WHERE users.username = 'maria'
If you have the relationship() configured, you can simplify:
stmt = (
select(Post)
.join(Post.author) # uses the relationship
.where(User.username == "maria")
)
LEFT JOIN (outer)
stmt = (
select(User, Post)
.outerjoin(Post, Post.author_id == User.id)
)
# LEFT OUTER JOIN
Or with the relationship:
stmt = select(User).outerjoin(User.posts)
Multiple joins
stmt = (
select(Post.title, User.username, Category.name)
.join(Post.author)
.outerjoin(Post.category)
.where(Post.published == True)
.order_by(Post.published_at.desc())
)
SELECT with multiple entities / columns
# Tuples of objects
stmt = select(Post, User).join(Post.author)
result = session.execute(stmt).all()
# [(Post, User), (Post, User), ...]
# Mixing columns and entities
stmt = select(Post, User.username).join(Post.author)
# [(Post, "maria"), ...]
# Columns only
stmt = select(Post.title, Post.published_at)
# [Row(title=..., published_at=...), ...]
To extract:
result = session.execute(stmt)
# If it is ONE model: scalars()
posts = result.scalars().all()
# If it is MULTIPLE things: iterate the Rows
for post, username in session.execute(stmt):
print(f"{username}: {post.title}")
Aggregations: COUNT, SUM, AVG, GROUP BY
from sqlalchemy import func
# Total published posts
total = session.execute(
select(func.count(Post.id)).where(Post.published == True)
).scalar_one()
# Average title length
avg_len = session.execute(
select(func.avg(func.length(Post.title)))
).scalar_one()
# Posts per author
stmt = (
select(User.username, func.count(Post.id).label("num_posts"))
.outerjoin(User.posts)
.group_by(User.id, User.username)
.order_by(func.count(Post.id).desc())
)
for username, num in session.execute(stmt):
print(f"{username}: {num} posts")
func.X() reaches any SQL function: func.count(), func.avg(), func.now(), func.lower(), func.coalesce(), func.string_agg(), etc.
HAVING
stmt = (
select(User.username, func.count(Post.id).label("n"))
.join(User.posts)
.group_by(User.id, User.username)
.having(func.count(Post.id) >= 2)
)
# HAVING count(...) >= 2
FILTER (PostgreSQL-specific)
stmt = (
select(
User.username,
func.count(Post.id).label("total"),
func.count(Post.id).filter(Post.published == True).label("published"),
func.count(Post.id).filter(Post.published == False).label("drafts"),
)
.outerjoin(User.posts)
.group_by(User.id, User.username)
)
func.count(...).filter(condition) translates to count(...) FILTER (WHERE ...).
Subqueries
from sqlalchemy import select
# A scalar subquery
posts_count_per_user = (
select(func.count(Post.id))
.where(Post.author_id == User.id)
.scalar_subquery()
)
stmt = select(User.username, posts_count_per_user.label("num_posts"))
IN with a subquery
admin_ids = select(User.id).where(User.is_admin == True)
stmt = select(Post).where(Post.author_id.in_(admin_ids))
EXISTS
from sqlalchemy import exists
stmt = select(User).where(
exists().where(Post.author_id == User.id, Post.published == True)
)
CTEs (WITH ... AS)
# The CTE
post_counts = (
select(Post.author_id, func.count().label("n"))
.group_by(Post.author_id)
.cte("post_counts")
)
# The main query using the CTE
stmt = (
select(User.username, post_counts.c.n)
.join(post_counts, User.id == post_counts.c.author_id)
)
post_counts.c.n reaches the CTE's n column (the c is like Table.c.column).
A recursive CTE
# A comment tree for a specific post
top_comments = (
select(Comment)
.where(Comment.post_id == post_id, Comment.parent_comment_id.is_(None))
.cte("comment_tree", recursive=True)
)
# The "anchor" + the recursive part
top_comments = top_comments.union_all(
select(Comment).join(top_comments, Comment.parent_comment_id == top_comments.c.id)
)
stmt = select(top_comments)
Recursive CTEs are rare but powerful. For the blog, a library like sqlalchemy-utils or direct SQL queries can read better.
Useful functions for the blog
Top N per category (a window function)
from sqlalchemy import func
# Posts: for each category, the 3 most recent
ranked = (
select(
Post,
func.row_number().over(
partition_by=Post.category_id,
order_by=Post.published_at.desc(),
).label("rn"),
)
.where(Post.published == True)
.subquery()
)
stmt = select(ranked).where(ranked.c.rn <= 3)
Case-insensitive search with an index
# Assumes idx_users_email_lower (module 3)
stmt = select(User).where(func.lower(User.email) == "maria@blog.local".lower())
Important: the expression must match the index. func.lower(User.email) == "maria@blog.local".lower() matches idx_users_email_lower.
Aggregating tags as a string (STRING_AGG)
from sqlalchemy import func
stmt = (
select(
Post.title,
func.string_agg(Tag.name, ', ').label("tags"),
)
.join(Post.tags)
.group_by(Post.id, Post.title)
)
Most commented posts
stmt = (
select(
Post.title,
func.count(Comment.id).label("num_comments"),
)
.outerjoin(Post.comments)
.where(Post.published == True)
.group_by(Post.id, Post.title)
.order_by(func.count(Comment.id).desc())
.limit(10)
)
Every module 2 query, translated
Let's recall the 10 product queries from module 2, capsule 08. Here are some of them in SQLAlchemy:
Query 1: Main feed with author + category + tags
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload # capsule 07
stmt = (
select(Post)
.options(
selectinload(Post.author),
selectinload(Post.category),
selectinload(Post.tags),
)
.where(Post.published == True)
.order_by(Post.published_at.desc())
.limit(10)
)
posts = session.execute(stmt).scalars().all()
for p in posts:
print(f"{p.title} — {p.author.username} — {p.category.name if p.category else 'No cat'}")
print(f" Tags: {', '.join(t.name for t in p.tags)}")
Query 3: Top 5 authors by published posts
stmt = (
select(
User.username,
User.full_name,
func.count(Post.id).label("published_posts"),
func.max(Post.published_at).label("last_post"),
)
.join(User.posts)
.where(Post.published == True)
.group_by(User.id, User.username, User.full_name)
.order_by(func.count(Post.id).desc())
.limit(5)
)
for username, full_name, count, last in session.execute(stmt):
print(f"{username} ({full_name}): {count} posts, latest: {last}")
Query 4: Top 10 tags
stmt = (
select(
Tag.name,
func.count(post_tags_table.c.post_id).label("usage_count"),
)
.join(post_tags_table, Tag.id == post_tags_table.c.tag_id)
.group_by(Tag.id, Tag.name)
.order_by(func.count().desc())
.limit(10)
)
Query 8: Related posts (the same tag)
post_x = session.execute(select(Post).where(Post.slug == "postgresql-in-production")).scalar_one()
post_x_tag_ids = select(post_tags_table.c.tag_id).where(post_tags_table.c.post_id == post_x.id)
stmt = (
select(Post, func.count(post_tags_table.c.tag_id).label("shared_tags"))
.join(post_tags_table, Post.id == post_tags_table.c.post_id)
.where(
post_tags_table.c.tag_id.in_(post_x_tag_ids),
Post.id != post_x.id,
Post.published == True,
)
.group_by(Post.id)
.order_by(func.count().desc())
.limit(5)
)
We organize the complete blog in SQLAlchemy in capsule 08, with the Repository pattern.
Running raw SQL when you need it
Sometimes it is easier to write SQL directly. SQLAlchemy allows it:
from sqlalchemy import text
stmt = text("""
SELECT username, count(*) AS n
FROM users u
JOIN posts p ON p.author_id = u.id
WHERE p.published = true
GROUP BY username
ORDER BY n DESC
LIMIT :limit
""").bindparams(limit=5)
result = session.execute(stmt)
for row in result:
print(row.username, row.n)
Don't overuse
text()— you lose the typing and the IDE stops helping you. Use it only when the ORM really falls short.
Composing reusable queries
SQLAlchemy queries are objects. You can pass them as arguments, modify them, reuse them:
def published_posts_query():
"""The base query for published posts."""
return select(Post).where(Post.published == True)
def by_author(stmt, username):
"""Filters a query by author."""
return stmt.join(Post.author).where(User.username == username)
def order_recent(stmt):
"""Orders by date descending."""
return stmt.order_by(Post.published_at.desc())
# Composable usage
stmt = order_recent(by_author(published_posts_query(), "maria")).limit(10)
posts = session.execute(stmt).scalars().all()
This is NOT possible with text() — it is one of the ORM's advantages.
Common mistakes
MultipleResultsFound
session.execute(select(User)).scalar_one()
# ERROR if there is >1 user
The fix: use scalars().first() or scalars().all() if you expect multiple.
NoResultFound
session.execute(select(User).where(User.username == "doesnotexist")).scalar_one()
# ERROR if there is no match
The fix: scalar_one_or_none().
Forgetting .scalars()
result = session.execute(select(User)).all()
# Returns [Row(User=<User>), ...] — tuples with one element
result = session.execute(select(User)).scalars().all()
# Returns [<User>, <User>, ...] — a flat list
== with None
# ❌
.where(User.deleted_at == None)
# It works but it is a Python anti-pattern (linters complain)
# ✅
.where(User.deleted_at.is_(None))
Exercises
Exercise 1. Implement the plain-SQL "main feed" query as a select() with SQLAlchemy. Verify it returns the same results.
Solution
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database import db_session
from app.models import Post
with db_session() as session:
posts = session.execute(
select(Post)
.options(selectinload(Post.author), selectinload(Post.category))
.where(Post.published == True)
.order_by(Post.published_at.desc())
.limit(10)
).scalars().all()
for p in posts:
print(f"{p.title} — {p.author.username}")
Exercise 2. Count how many posts and comments there are, grouped by author.
Solution
from sqlalchemy import func, select
from app.database import db_session
from app.models import User, Post, Comment
with db_session() as session:
stmt = (
select(
User.username,
func.count(Post.id.distinct()).label("posts"),
func.count(Comment.id.distinct()).label("comments"),
)
.outerjoin(Post, Post.author_id == User.id)
.outerjoin(Comment, Comment.author_id == User.id)
.group_by(User.id, User.username)
)
for u, p, c in session.execute(stmt):
print(f"{u}: {p} posts, {c} comments")
distinct() to avoid counting duplicate combinations from the join.
Exercise 3. Find users who have commented but never published, using EXISTS and NOT EXISTS.
Solution
from sqlalchemy import select, exists
from app.database import db_session
from app.models import User, Post, Comment
with db_session() as session:
stmt = (
select(User.username)
.where(
exists().where(Comment.author_id == User.id),
~exists().where(Post.author_id == User.id, Post.published == True),
)
)
for (username,) in session.execute(stmt):
print(username)
Exercise 4. Implement "tags related to postgresql" (co-occurrence, query 9 from module 2).
Solution
from sqlalchemy import func, select
from sqlalchemy.orm import aliased
from app.database import db_session
from app.models import Tag, post_tags_table
with db_session() as session:
# The base tag
tag_pg = session.execute(select(Tag).where(Tag.slug == "postgresql")).scalar_one()
# Aliases to join post_tags with itself
pt1 = aliased(post_tags_table)
pt2 = aliased(post_tags_table)
stmt = (
select(
Tag.name.label("related"),
func.count().label("co_occurrence"),
)
.select_from(pt1)
.join(pt2, pt1.c.post_id == pt2.c.post_id) # the same post
.join(Tag, Tag.id == pt2.c.tag_id)
.where(pt1.c.tag_id == tag_pg.id, pt2.c.tag_id != tag_pg.id)
.group_by(Tag.id, Tag.name)
.order_by(func.count().desc())
)
for name, n in session.execute(stmt):
print(f"{name}: {n}")
Exercise 5. Build a search_posts(session, query: str, tag: str | None = None) function that returns posts whose title contains query (case-insensitive), optionally filtered by tag.
Solution
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.models import Post, Tag
def search_posts(session: Session, query: str, tag: str | None = None) -> list[Post]:
stmt = (
select(Post)
.options(selectinload(Post.author), selectinload(Post.tags))
.where(
Post.published == True,
Post.title.ilike(f"%{query}%"),
)
)
if tag:
stmt = stmt.join(Post.tags).where(Tag.slug == tag)
return list(session.execute(stmt).scalars().unique().all())
# Usage
from app.database import db_session
with db_session() as session:
results = search_posts(session, "postgres", tag="tutorial")
for p in results:
print(p.title)
.unique() deduplicates if the JOIN produces repeated rows.
Summary
select(Model)orselect(Model.col1, Model.col2)to build queries.where()with==,!=,<,.in_(),.like(),.is_(None), etc..join(Model)or.join(Model.relationship)(preferred if the relationship exists).outerjoin()for a LEFT JOIN.order_by(),.limit(),.offset()for sorting and paginationfunc.count(),func.sum(),func.avg(),func.X()for any SQL function.group_by()+.having()for groupingsfunc.X(...).filter(condition)for FILTER WHERE.cte()for Common Table Expressions- Results:
.scalars().all()for lists of models,.execute().all()for tuples - Subqueries:
.scalar_subquery()for scalars, a plainselect(...)inside.in_() - Composable: queries are Python objects, chainable and reusable
In the next capsule, the most important performance topic: eager vs lazy loading and the N+1 problem.
Additional Resources
Next: Capsule 07 — Eager vs Lazy Loading.