Module 4: The N+1 problem with SQLAlchemy
Module 4 project: eliminating N+1 from the bookstore
What will you build and why?
In module 1 you met the bookstore and planted a problematic endpoint: GET /books-with-author?author_name=X. At that moment you only identified it as suspicious. You enabled echo=True, counted 51 queries per request, and said "we'll fix it later". "Later" has arrived.
In this module 4 project, you take that endpoint and tear it apart:
- You measure the baseline with
wrk(module 1) to have a solid starting number. - You diagnose with
nplusoneandecho=True(capsules 02-03) to understand exactly where the lazy loads are. - You apply the correct combination of
selectinload+ possiblyjoinedload+raiseload(capsules 04-07). - You validate that the N+1 disappeared:
nplusonedetects nothing,echo=Trueshows 2-3 fixed queries. - You report the improvement with numbers: queries before/after, p50/p95/p99 latency before/after.
The result is a "before/after" commit with concrete metrics you'll be able to quote verbatim in an interview: "I reduced an endpoint from 51 queries and 850ms p95 to 2 queries and 95ms p95 by applying correct selectinload in SQLAlchemy 2.0 async."
That's portfolio-worthy. It's the difference between "I worked with SQLAlchemy" and "I master SQLAlchemy performance tuning in production".
Project objective
By completing this project:
- You'll have measured the problematic endpoint's baseline with
wrk(p50/p95/p99 latency) and the query count withnplusone. - You'll have applied the correct eager loading techniques, justifying the choice between
selectinloadandjoinedloadfor each relationship. - You'll have configured
nplusonein pytest so CI breaks if someone re-introduces an N+1. - You'll have produced a before/after report with concrete numbers in a
BENCHMARKS.mdformat. - You'll have committed the changes on a clean branch, ready for a PR.
How it fits with what you learned
This project integrates all the module's capsules:
| Capsule | How it's used in the project |
|---|---|
| 02 — What is N+1? | Initial diagnosis: count the endpoint's queries with echo=True |
03 — Detecting with nplusone | Configure nplusone in pytest to validate after the fix and prevent regressions |
04 — joinedload vs selectinload | Choose the right strategy for Author → books → reviews |
05 — subqueryload | Not applied directly, but you use the knowledge to rule it out |
| 06 — Async and AsyncSession | All the code is async; you apply canonical patterns |
| 07 — Anti-patterns | You validate you're not over-fetching or using selectinload('*') |
Think of the project as a mini case study: you take a broken endpoint, apply engineering discipline (measure, diagnose, fix, validate, report), and produce documented evidence of the impact.
Technical specifications
Stack
- Language: Python 3.12+
- Framework: FastAPI 0.110+
- ORM: SQLAlchemy 2.0+ async
- DB driver: asyncpg
- Database: PostgreSQL 16+
- Testing: pytest + pytest-asyncio + httpx
- N+1 detection:
nplusone - Load testing:
wrk(installed in module 1)
Initial setup
If you have the module 1 bookstore running, skip to step 2. If not:
Step 1: bring up the module 1 bookstore.
# Assume the bookstore-baseline folder exists from module 1
cd ~/projects/bookstore-baseline
# If Postgres isn't running:
docker run -d --name bookstore-pg \
-e POSTGRES_USER=bookstore \
-e POSTGRES_PASSWORD=bookstore \
-e POSTGRES_DB=bookstore \
-p 5432:5432 \
postgres:16
# Activate the venv:
source venv/bin/activate
# Re-run the seed if the DB is empty:
python seed.py
# Verify the problematic endpoint is still slow:
uvicorn main:app --reload &
curl "http://localhost:8000/books-with-author?author_name=tolkien" | head -c 200
Step 2: create a branch for the project.
git checkout -b module-04-eliminate-n1
Step 3: install new dependencies.
pip install nplusone pytest pytest-asyncio httpx
pip freeze > requirements.txt
Mandatory features
1. Establish the baseline
Before changing a line of code, measure the endpoint's current state.
1.1 — Query count
Enable echo=True and count:
# In main.py make sure you have:
# engine = create_async_engine(DATABASE_URL, echo=True)
uvicorn main:app --reload 2> baseline_echo.log &
curl "http://localhost:8000/books-with-author?author_name=tolkien" > /dev/null
sleep 1
# Stop uvicorn
# Count SELECTs
grep -c "INFO sqlalchemy.engine.Engine SELECT" baseline_echo.log
Note the number. For tolkien with 20 books from the default seed, you expect ~22 queries (1 author + 1 books + 20 reviews).
1.2 — Latency with wrk
Hit the endpoint with wrk to measure percentiles:
uvicorn main:app --reload &
sleep 2
wrk -t4 -c20 -d30s --latency \
"http://localhost:8000/books-with-author?author_name=tolkien"
Typical output (will vary depending on your hardware):
Running 30s test @ http://localhost:8000/books-with-author?author_name=tolkien
4 threads and 20 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 245.32ms 58.14ms 650ms 78.45%
Req/Sec 20.43 4.12 35.00 72.10%
Latency Distribution
50% 235.00ms
75% 281.00ms
90% 320.00ms
99% 485.00ms
2438 requests in 30.00s, 5.23MB read
Requests/sec: 81.27
Note: p50, p95, p99, RPS.
1.3 — Validate with nplusone that there's an N+1
Configure nplusone in warn mode:
# In main.py, after creating the engine:
import os
if os.getenv("APP_ENV") in ("development", "testing"):
from nplusone.ext.sqlalchemy import NPlusOne
NPlusOne(engine)
Hit the endpoint and observe warnings:
APP_ENV=development uvicorn main:app --reload &
curl "http://localhost:8000/books-with-author?author_name=tolkien"
# In the logs you'll see many:
# WARNING:nplusone:Potential n+1 query detected on `Book.reviews`
Confirmed: there's an N+1. You have a complete baseline.
2. Diagnosis
Look at the endpoint's current code in main.py and answer:
- How many queries does it fire?
- Which relationships are lazy?
- What's the optimal strategy for each one?
Model structure (recap from module 1):
class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True)
books: Mapped[list["Book"]] = relationship(back_populates="author")
class Book(Base):
__tablename__ = "books"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"))
author: Mapped["Author"] = relationship(back_populates="books")
reviews: Mapped[list["Review"]] = relationship(back_populates="book")
class Review(Base):
__tablename__ = "reviews"
id: Mapped[int] = mapped_column(primary_key=True)
book_id: Mapped[int] = mapped_column(ForeignKey("books.id"))
rating: Mapped[int]
book: Mapped["Book"] = relationship(back_populates="reviews")
Current endpoint (with N+1):
@app.get("/books-with-author")
async def list_books_with_author(author_name: str):
async with SessionLocal() as session:
author_q = select(Author).where(Author.name == author_name)
author = (await session.execute(author_q)).scalar_one_or_none()
if not author:
return {"error": "not found"}
books_q = select(Book).where(Book.author_id == author.id)
books = (await session.scalars(books_q)).all()
result = []
for book in books:
# N+1: for each book, a separate query for reviews
reviews_q = select(Review).where(Review.book_id == book.id)
reviews = (await session.scalars(reviews_q)).all()
result.append({
"title": book.title,
"review_count": len(reviews),
})
return {"author": author.name, "books": result}
Expected analysis:
- 1 query for author.
- 1 query for books.
- N queries (one per book) for reviews. For tolkien with 20 books = 20 queries.
- Total: 22 queries.
- Optimal strategy:
Author → books: small 1:N (20 books). Could bejoinedloadorselectinload. Since there's another nested layer,selectinload.Book → reviews: 1:N (5 reviews per book on average).selectinloadto avoid a cartesian explosion.- Combination:
selectinload(Author.books).selectinload(Book.reviews). Total: 3 queries.
3. Apply the fix
3.1 — Refactor the endpoint
from sqlalchemy.orm import selectinload, raiseload
from sqlalchemy import select, func
@app.get("/books-with-author")
async def list_books_with_author(author_name: str):
async with SessionLocal() as session:
stmt = (
select(Author)
.options(
selectinload(Author.books).selectinload(Book.reviews),
raiseload('*'), # discipline: any other lazy → error
)
.where(Author.name == author_name)
)
author = await session.scalar(stmt)
if not author:
return {"error": "not found"}
return {
"author": author.name,
"books": [
{
"title": book.title,
"review_count": len(book.reviews),
}
for book in author.books
],
}
Key changes:
- A single main query with
select(Author)instead of two separate queries (author + books). selectinload(Author.books).selectinload(Book.reviews)loads both levels eagerly.raiseload('*')prevents any OTHER relationship from being loaded lazy accidentally in the future.- List comprehension inside the session block — everything accessed while the session is open.
3.2 — Optional improvement with func.count
If you only need the review count (not the complete reviews), you can avoid loading the whole reviews and use SQL aggregation:
from sqlalchemy import select, func
@app.get("/books-with-author")
async def list_books_with_author(author_name: str):
async with SessionLocal() as session:
# Subquery for review counts by book_id
review_count_subq = (
select(
Review.book_id,
func.count(Review.id).label("review_count"),
)
.group_by(Review.book_id)
.subquery()
)
stmt = (
select(Author, Book, review_count_subq.c.review_count)
.join(Book, Book.author_id == Author.id)
.outerjoin(review_count_subq, review_count_subq.c.book_id == Book.id)
.where(Author.name == author_name)
)
rows = (await session.execute(stmt)).all()
if not rows:
return {"error": "not found"}
author_name_value = rows[0][0].name
return {
"author": author_name_value,
"books": [
{"title": book.title, "review_count": count or 0}
for _, book, count in rows
],
}
This version does a single query with a JOIN and aggregation. It's the most efficient — it returns only the counts, not the complete reviews. The trade-off is more complex code. For this project, the version with selectinload is the correct solution (more readable, sufficient for the expected data).
4. Post-fix validation
4.1 — Re-measure queries with echo=True
APP_ENV=development uvicorn main:app --reload 2> after_echo.log &
curl "http://localhost:8000/books-with-author?author_name=tolkien" > /dev/null
sleep 1
# Stop uvicorn
grep -c "INFO sqlalchemy.engine.Engine SELECT" after_echo.log
Expected: 3 queries. If you see more, something lazy slipped through.
4.2 — Re-measure latency with wrk
wrk -t4 -c20 -d30s --latency \
"http://localhost:8000/books-with-author?author_name=tolkien"
Expected:
- p50 improves 5-15x (from ~235ms to ~15-50ms).
- p95 improves 5-15x (from ~320ms to ~30-80ms).
- RPS increases 5-15x.
(The exact numbers depend on your hardware and the specific tolkien. What matters is the relative improvement.)
4.3 — Verify with nplusone
APP_ENV=development uvicorn main:app --reload &
curl "http://localhost:8000/books-with-author?author_name=tolkien"
# You should NOT see: WARNING:nplusone:Potential n+1...
If there are no warnings, the N+1 is eliminated.
5. Automated tests
Configure pytest with nplusone in raise mode so CI breaks if someone re-introduces an N+1.
5.1 — tests/conftest.py
import os
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from nplusone.ext.sqlalchemy import NPlusOne
os.environ["NPLUSONE_RAISE"] = "True"
from main import app
TEST_DB_URL = "postgresql+asyncpg://bookstore:bookstore@localhost:5432/bookstore"
test_engine = create_async_engine(TEST_DB_URL, echo=False)
NPlusOne(test_engine)
@pytest_asyncio.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
5.2 — tests/test_books_with_author.py
import pytest
@pytest.mark.asyncio
async def test_books_with_author_no_n_plus_one(client):
"""
If someone re-introduces a lazy load in /books-with-author,
nplusone raises NPlusOneError and this test fails.
"""
response = await client.get("/books-with-author?author_name=tolkien")
assert response.status_code == 200
data = response.json()
assert data["author"] == "tolkien"
assert len(data["books"]) > 0
# Each book must have title and review_count
for book in data["books"]:
assert "title" in book
assert "review_count" in book
assert isinstance(book["review_count"], int)
@pytest.mark.asyncio
async def test_books_with_author_not_found(client):
response = await client.get("/books-with-author?author_name=nonexistent")
# Behavior: depends on how you handle the error in your endpoint
assert response.status_code in (200, 404)
5.3 — Run the tests
pytest tests/ -v
Expected: both tests pass. If test_books_with_author_no_n_plus_one fails with NPlusOneError, there's still a lazy load. Diagnose it and fix it.
6. Benchmark report
Create BENCHMARKS.md at the project root with the format:
# Benchmarks: /books-with-author
## Setup
- **Hardware:** [your CPU, RAM, OS]
- **PostgreSQL:** 16.x
- **Python:** 3.12.x
- **SQLAlchemy:** 2.0.x
- **wrk:** -t4 -c20 -d30s
- **Target:** /books-with-author?author_name=tolkien
- **Seed data:** 5,000 authors, 100,000 books, 500,000 reviews
- **tolkien:** 20 books with ~5 reviews each
## Results
| Metric | Before (with N+1) | After (with selectinload) | Improvement |
|---------|------------------|---------------------------|--------|
| SQL queries per request | 22 | 3 | **7.3x fewer** |
| p50 latency | 235ms | 18ms | **13x faster** |
| p95 latency | 320ms | 42ms | **7.6x faster** |
| p99 latency | 485ms | 78ms | **6.2x faster** |
| Sustained RPS | 81 | 612 | **7.5x more throughput** |
## Diagnosis
The endpoint fired 1 query for author + 1 query for books + N queries for reviews
(one per book). For tolkien with 20 books, total: 22 queries in series.
Each individual query was fast (~0.5ms in pure SQL), but the network round-trip cost
added up: 22 × ~10ms = 220ms just in transport per request.
## Applied solution
Replaced `for book in books: query_reviews(book.id)` with:
```python
stmt = (
select(Author)
.options(selectinload(Author.books).selectinload(Book.reviews))
.where(Author.name == author_name)
)
This reduces to 3 fixed queries regardless of the number of books or reviews.
Regression checks
- pytest test with
NPLUSONE_RAISE=Truevalidates that no N+1 appears. - If someone edits the endpoint and leaves a lazy load, CI breaks.
raiseload('*')in the query prevents accidentally loading another relationship.
Next steps (module 8)
- Apply the same techniques to other bookstore endpoints.
- Profiling with
pg_stat_statements(module 5) to find non-obvious N+1s. - Connection pooling with PgBouncer (module 6) to sustain higher RPS.
**The number of columns and metrics are a guide — adapt them to your real measurements.**
### 7. Commit and PR
```bash
git add main.py tests/ BENCHMARKS.md requirements.txt
git commit -m "Eliminate N+1 in /books-with-author with nested selectinload"
git push origin module-04-eliminate-n1
If you have a GitHub repo, open a PR with a description that includes the content of BENCHMARKS.md. That's portfolio material: anyone who reads it sees what you did, why, and what improved.
Validations and error handling
What must be validated
- The endpoint still works: status 200 with
tolkien. - The response still has the same format:
{"author": ..., "books": [{"title": ..., "review_count": ...}]}. - For a nonexistent author, behavior consistent with before (404 or
{"error": "not found"}). - The number of SQL queries in
echo=Trueis constant (3) no matter how many books the author has. - The pytest test with
NPLUSONE_RAISE=Truepasses. - The p95 latency improves at least 5x.
Errors that must be handled
- Author not found: return 404 or a clear error structure.
- Author with no books: return
{"books": []}, not an error. - DB unavailable: connection errors propagate as 500 (DB error handling is module 6).
Minimal implementation example
Complete post-fix main.py:
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import selectinload, raiseload
from models import Author, Book, Review
DATABASE_URL = "postgresql+asyncpg://bookstore:bookstore@localhost:5432/bookstore"
engine = create_async_engine(DATABASE_URL, echo=False)
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
# Enable nplusone only in dev/testing
ENV = os.getenv("APP_ENV", "production")
if ENV in ("development", "testing"):
from nplusone.ext.sqlalchemy import NPlusOne
NPlusOne(engine)
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
await engine.dispose()
app = FastAPI(lifespan=lifespan)
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/books-with-author")
async def list_books_with_author(author_name: str):
async with SessionLocal() as session:
stmt = (
select(Author)
.options(
selectinload(Author.books).selectinload(Book.reviews),
raiseload('*'),
)
.where(Author.name == author_name)
)
author = await session.scalar(stmt)
if not author:
raise HTTPException(404, f"Author '{author_name}' not found")
return {
"author": author.name,
"books": [
{
"title": book.title,
"review_count": len(book.reviews),
}
for book in author.books
],
}
To run:
APP_ENV=development uvicorn main:app --reload
Evaluation rubric (self-check)
Diagnosis (20 points)
- (5 pts) You enabled
echo=Trueand counted baseline queries. - (5 pts) You configured
nplusoneand confirmed N+1 warnings. - (5 pts) You measured baseline latency with
wrk(p50, p95, p99, RPS). - (5 pts) You identified exactly which lazy loading was in the code.
Implementation (35 points)
- (10 pts) You refactored the endpoint with correct nested
selectinload. - (5 pts) You added
raiseload('*')to prevent regressions. - (5 pts) The refactor preserves exactly the same response format.
- (5 pts) You validated post-fix that
echo=Trueshows 3 fixed queries. - (5 pts) You validated post-fix that
nplusoneemits no warnings. - (5 pts) You configured
expire_on_commit=Falsecorrectly.
Automated tests (15 points)
- (5 pts)
tests/conftest.pyconfiguresnplusonein raise mode. - (5 pts) A test exercises the endpoint and validates that it passes.
- (5 pts) A test covers the author-not-found case.
Benchmarking (15 points)
- (5 pts) You measured post-fix latency with
wrk(same setup as the baseline). - (5 pts) You calculated the relative improvement (X times faster) per percentile.
- (5 pts) You documented the improvement in sustained RPS.
Documentation (15 points)
- (10 pts)
BENCHMARKS.mdwith a before/after table, diagnosis, solution. - (5 pts) Descriptive commit message (not just "fix N+1").
Extra credit (optional, up to +10 pts)
- (+5 pts) You implemented the alternative version with
func.countand an explicit JOIN; you compared latency with theselectinloadversion. - (+5 pts) You applied the pattern to another bookstore endpoint with N+1 (create one if it doesn't exist).
Total: 100 points. Passing: ≥75 / 100. Excellent: ≥90 / 100 — ready to show in an interview.
Common mistakes in this project
Mistake 1: measuring latency with the server in debug mode
Symptom: "My baseline p95 is 800ms, after the fix it's still 700ms."
Why it happens: you ran wrk against uvicorn --reload, which has file watching and reload overhead. The measurements are noisy.
How to fix it: run without --reload to measure:
uvicorn main:app --workers 1
And measure with wrk only after the server is stable (1-2 seconds).
Mistake 2: forgetting expire_on_commit=False
Symptom: after the refactor, you see random MissingGreenlet on some requests.
Why it happens: expire_on_commit=True (default) invalidates objects after a commit. In async, that leads to accidental lazy loads that fail.
How to fix it:
SessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False, # ← essential in async
)
Mistake 3: tests without pytest-asyncio configured
Symptom: pytest shows "skipped" on async tests, or fails with "fixture not found".
Why it happens: pytest-asyncio needs explicit configuration.
How to fix it: add pytest.ini:
[pytest]
asyncio_mode = auto
Or pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto"
Mistake 4: NPLUSONE_RAISE isn't applied
Symptom: "My test doesn't fail even though there's an N+1."
Why it happens: the env var is set after importing NPlusOne, or it's set in the shell but pytest doesn't inherit it.
How to fix it: configure it BEFORE any nplusone import:
# tests/conftest.py — at the start of the file
import os
os.environ["NPLUSONE_RAISE"] = "True"
# Now you can import
from nplusone.ext.sqlalchemy import NPlusOne
Or use pytest-env with pytest.ini:
[pytest]
env =
NPLUSONE_RAISE=True
Mistake 5: forgetting raiseload('*') and introducing a regression
Symptom: weeks after the fix, someone edits the endpoint, adds book.author.country to the response. N+1 comes back.
Why it happens: without raiseload('*'), new lazy accesses don't break or alert.
How to prevent it: raiseload('*') in the initial query. Any future lazy access raises InvalidRequestError with a clear message. CI breaks. Bug caught before prod.
Mistake 6: comparing measurements from different hardware
Symptom: "My colleague measured a p95 of 100ms on their laptop, I measure 300ms."
Why it happens: different hardware, different seed data distribution, other processes consuming CPU.
How to handle it: what matters is the relative improvement (from X to Y, factor Z), not the absolute numbers. Each person measures in their own environment with their own baseline.
What to do if you get stuck
- Setup doesn't work: review module 1 capsule 08 (baseline project). Make sure the bookstore runs and has seeded data.
nplusonedoesn't detect an obvious N+1: verify that you passed the correctenginetoNPlusOne(engine). Enableecho=Trueto see the SQL — if you see queries in a loop, there's an N+1 even ifnplusonedoesn't warn.MissingGreenletpost-fix: you're accessing an unloaded relationship. Captureecho=True, identify which relationship, and add it to theselectinload. Orraiseload('*')will tell you exactly which one.- The test passes but
wrkdoesn't improve: check that you're measuring the correct endpoint and that the server restarted with your changes. Basic but common mistakes. - Post-fix latency the same as baseline: verify that
selectinloadwas really applied (3 queries inecho=True). If you see 22 queries, the refactor didn't take due to some import error.
What's next
What you built here is the foundation of the module 8 consolidating final project. There you'll take the bookstore with ALL the problems (not just N+1, but also large OFFSET, COUNT(*), missing GIN, untuned pool) and solve each one by applying techniques from modules 1-8. The N+1 will already be resolved because you fixed it here.
Before advancing to module 5, make sure your project meets:
- The
/books-with-authorendpoint fires exactly 3 queries (verifiable withecho=True). - The pytest test with
NPLUSONE_RAISE=Truepasses. -
BENCHMARKS.mddocuments the improvement with before/after numbers. - The branch is committed and pushed (even if you don't open a PR yet).
Next module (5): Query Profiling in Production. Your current workflow depends on echo=True in dev. In production you can't enable that. You learn pg_stat_statements to identify your app's most expensive queries without touching code, and auto_explain to capture slow query plans automatically. You'll take this module's detection skills (where echo=True shows you everything) to production (where you only have aggregated metrics and have to recognize patterns).
Resources for the project
- SQLAlchemy 2.0 — Relationship Loading Techniques — reference for all the eager loading options.
- jmcarp/nplusone (GitHub) — the detection library.
- wrk — A modern HTTP benchmarking tool (GitHub) — the load testing tool.
- FastAPI — Testing — pytest with FastAPI fundamentals.
- pytest-asyncio Documentation — configuration for async tests.
- Mike Bayer — "Asynchronous I/O in SQLAlchemy" — the official async reference for the project's patterns.
- Asif Muhammad — "Solving the N+1 problem in FastAPI with SQLAlchemy eager loading" — a case applied to the path's stack with complete code.
Module 4 — Database Performance & Query Tuning Guide