What is the N+1 query problem, and how do you actually fix it?
One list endpoint, one innocent-looking loop, and suddenly your database is answering a thousand queries for a page that should have needed two.
LEARN BY DOINGPut your intuition to work3 steps
What is the N+1 query problem, and how do you actually fix it?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The setup
An ORM makes the easy case look free. You fetch a list of orders, then for each order you ask for its customer, and the code reads like plain, sequential, obviously-correct object traversal:
orders = Order.objects.all() # 1 queryfor order in orders:print(order.customer.name) # 1 query PER order, lazily loaded
That is the N+1 query problem: 1 query to fetch the parent rows, plus N more queries, one per row, to fetch each row's related data. With 20 orders on the page it's 21 round trips instead of 2. With 2,000 orders in a report job, it's 2,001 — and at that point the bottleneck isn't your business logic, it's network latency multiplied by row count.
#Why it happens
Lazy loading is the default in almost every ORM (Django, Hibernate/JPA, ActiveRecord, SQLAlchemy without explicit options) because it has to guess what you'll touch, and guessing wrong in the "load everything" direction wastes bandwidth on data you never use. So the ORM defers the related-object fetch until the attribute is actually accessed — which is exactly what happens inside your loop, one row at a time. The query itself is cheap; it's the count of round trips that kills you, especially over a network hop to a managed database where each round trip costs a few milliseconds of pure latency before the database even starts working.
#The fix: tell the ORM what you need, up front
The fix is always some form of "fetch the related data in the same query, or in one batched follow-up query, instead of one-per-row." Concretely, that's a JOIN or a batched WHERE id IN (...):
# Django: JOIN in a single SQL query (foreign key / one-to-one)orders = Order.objects.select_related("customer")for order in orders:print(order.customer.name) # no extra query — already in memory# Django: batched second query (many-to-many / reverse FK)orders = Order.objects.prefetch_related("line_items")
The equivalent in raw SQL is just writing the join yourself instead of letting per-row lazy loads simulate one badly:
-- 1 query total instead of 1 + NSELECT o.id, o.total, c.id AS customer_id, c.nameFROM orders oJOIN customers c ON c.id = o.customer_id;
Java/Hibernate has the identical two shapes: JOIN FETCH in JPQL for the single-query case, or @BatchSize / a second batched query for collections where a JOIN would multiply rows unpleasantly (a one-to-many join duplicates the parent row once per child, which is its own trap on large fan-outs).
#select_related vs. prefetch_related — pick by cardinality
This is the part people memorize as a rule without understanding why, so it's worth stating the actual reason:
| Relationship | Strategy | Why | |
|---|---|---|---|
select_related | many-to-one, one-to-one | SQL JOIN | One row in, one row out — a join doesn't multiply anything |
prefetch_related | one-to-many, many-to-many | Separate batched query + in-memory stitch | A join would duplicate the parent row per child, bloating the result set |
Joining a one-to-many relationship "for eager loading" is a subtler version of the same mistake: you replace 1+N queries with 1 query that returns N times more bytes than you needed, because every child row repeats the full parent row next to it. For fan-out relationships, a second query with WHERE parent_id IN (...) and an in-memory merge is both fewer bytes and easier for the query planner to optimize independently.
#Spotting it before it ships
N+1s are invisible in development because your test data has 3 rows and 4 queries feels the same as 1. They become a production incident because 3 rows in dev is 50,000 in prod. The two reliable ways to catch it early:
- Query-count assertions in tests — assert that fetching a list of N items issues a constant number of queries regardless of N (most ORMs expose a query-log/query-counter for exactly this).
- APM / slow-query logs in staging — a repeating identical query pattern (same shape, different parameter) is the fingerprint; Django Debug Toolbar, Rails Bullet gem, and Hibernate's statistics interceptor all flag this automatically.
🧠 Check your understanding
0/2 · 0/2 answered1. A list endpoint renders 50 orders and each order's customer name. Fetching the list takes 1 query; rendering the page takes 51 queries total. What's the most likely cause?
2. Why does eager-loading a one-to-many relationship with a SQL JOIN sometimes hurt more than it helps?