The N+1 Query Problem: Why One API Request Can Trigger Hundreds of Database Queries
One API request that returns 100 products can quietly fire 301+ database queries — one for the list, then one each for sellers, inventory, reviews and categories. That's the N+1 query problem. This covers what causes it, how ORM lazy loading triggers it, how to detect it in production by counting queries per request, and how to fix it with eager loading, JOINs, batching and DataLoader — with the trade-offs of each.
Backend Performance — a practical, interview-ready explainer
🎯 Practice Explaining This Under Pressure
Spotting an N+1 query problem in your own logs is one skill. Explaining why one request became 300 queries — out loud, in an interview — is another.
→ candidates.intervues.club — run a real AI voice interview on backend and database topics, get a structured report.
→ intervues.club — AI voice interviews for technical hiring.
Practice the interview before the interview.
1. The Problem, Stated Plainly
Your API endpoint is slow. You open the slow query log expecting a monster query. Every query in there runs in under a millisecond. Nothing looks wrong.
Then you count the queries. That one endpoint ran 301 of them for a single request.
This is the N+1 query problem: one query to fetch a list, then one additional query for each row in that list to load its related data. We cover backend fundamentals like this on engineering and in live practice sessions. Individually the queries are fine. Together they flood the database with round-trips, and your latency is death by a thousand cuts.
It shows up constantly in production because most ORMs make it easy to write and invisible until you measure. The good news: once you know the shape, it's fast to detect and usually straightforward to fix with eager loading, a JOIN, or batching. This article shows how, using one e-commerce endpoint the whole way through.
Key takeaway: An N+1 query problem is an application-level inefficiency, not a slow-SQL problem. No individual query has to be slow for the endpoint to be slow.
2. What Is the N+1 Query Problem?
An N+1 query problem occurs when an application runs one query to fetch a collection of records, and then runs one additional query for each record in that collection to load related data. The "1" is the initial list query; the "N" is the per-row follow-up queries.
For a request returning N items, the database sees 1 + N queries. If each of those items needs several related things loaded separately, it becomes 1 + (N × k) — the "hundreds of queries" case.
The defining signature in your logs:
Many queries that are identical except for an ID in the WHERE clause, and whose count scales with the number of rows returned.
That signature is what you look for when detecting it, and it's what tells you it's N+1 rather than some other kind of slowness.
3. A Real-World Example: An E-Commerce API
The endpoint: GET /api/products returns the first 100 products, and each product in the response includes its seller, its inventory status, its review summary, and its category.
3.1 The Initial Product Query
The application starts with one reasonable query:
sql
-- BAD PATTERN starts innocentlySELECT * FROM productsORDER BY created_at DESCLIMIT 100;
One query, 100 rows. So far, so good.
3.2 The Hidden Queries
Now the application loops over those 100 products to build the JSON response. For each product it accesses product.seller, product.inventory, product.reviews, product.category. If those relationships are lazy-loaded, each access triggers its own query:
sql
-- Fired once PER product, 100 times each:SELECT * FROM sellers WHERE id = ?; -- ×100SELECT * FROM inventory WHERE product_id = ?; -- ×100SELECT * FROM reviews WHERE product_id = ?; -- ×100SELECT * FROM categories WHERE id = ?; -- ×100
None of these are slow. WHERE id = ? on a primary key is about as fast as a database gets. But there are now 400 of them, plus the original list query.
3.3 How 1 API Request Becomes Hundreds of Database Queries
1 API REQUEST
↓
1 product list query (the "1")
↓
100 seller queries
100 inventory queries
100 review queries
100 category queries (the "N", four times over)
↓
401 DATABASE QUERIES for one response
The exact number depends entirely on the application: how many relationships each row touches, whether some are cached, whether the ORM deduplicates repeated IDs. The same endpoint might be 101, 201, or 401 queries depending on those details. The pattern is the constant; the multiplier varies.
Key takeaway: The query count grows with the size of the result set. Return 10 products and it's tolerable. Return 1,000 and the same code path issues thousands of queries.
4. Why N+1 Queries Make APIs Slow
Each query is cheap. The cost is in everything around each query, paid hundreds of times:
Network round-trips. Every query is a request/response between app and database. Even at 0.3 ms round-trip, 400 of them is 120 ms of pure waiting, serialized.
Query parsing and planning. The database parses and plans each statement. Prepared statements reduce this, but it's not free at volume.
Connection contention. Those queries hold a pooled connection longer per request, so fewer requests can run concurrently. Under load, request queuing gets worse fast.
ORM overhead. Hydrating each result set into objects, running callbacks, and tracking identity has a per-query cost in the application too.
Database load. 400 queries per request × 50 requests/second = 20,000 queries/second for one endpoint. The database CPU and connection pool feel that even though each query is trivial.
The latency is roughly linear in the number of rows, which is why an endpoint that's fine in development (small dataset) falls over in production (real dataset).
Key takeaway: N+1 latency comes from per-query overhead multiplied by row count, not from any single expensive operation.
5. N+1 Queries vs Other Kinds of Slowness
These get conflated in incident channels. They are different problems with different fixes.
Symptom
What it means
Typical fix
N+1 queries
Many fast queries, count scales with rows, identical except for an ID
Eager loading, JOIN, or batching
Slow query
One query with a bad plan — missing index, full scan, bad join order
Add an index; rewrite the query; check EXPLAIN
Too many queries (not N+1)
High query count that doesn't scale per-row — e.g. 30 independent lookups
Consolidate; cache; question whether all are needed
Database bottleneck
The database itself is saturated — CPU, IOPS, connections, locks
Scale the database; reduce total load; read replicas
Application-level inefficiency
Work the app does badly regardless of SQL — N+1 is one instance of this
Depends; measure first
An N+1 problem does not imply any query is slow, and fixing indexes will not help it. Conversely, a genuinely slow query is not fixed by eager loading. Diagnose which one you have before reaching for a fix.
6. How ORMs Create N+1 Queries
ORMs are not the villain here. They make data access convenient, and the convenience is exactly what hides the query count.
The mechanism is lazy loading: a relationship is not fetched until you access it. In isolation that's a sensible default — you don't pay for data you don't use. Inside a loop, it's an N+1 generator:
# Pseudocode — the shape is the same across ORMs
products = Product.query.limit(100) # 1 query
for product in products:
render(product.seller.name) # lazy load → 1 query, every iteration
render(product.inventory.in_stock) # lazy load → 1 query, every iteration
The fix most ORMs offer is eager loading: tell the ORM up front which relationships you'll need, and it loads them in bulk — either with a JOIN or with a second batched query using WHERE id IN (...).
The concept has a different keyword in each ecosystem, but it's the same idea:
Key takeaway: ORMs don't cause N+1 by being ORMs. They cause it when relationships are lazy-loaded inside a loop and nobody checked the generated SQL. The fix is to declare what you need eagerly.
7. How to Detect the N+1 Query Problem
7.1 Count Queries Per API Request
The single most useful number: how many database queries did this request execute? Compare it to the number of records returned.
100 records, ~1–5 queries → healthy.
100 records, ~400 queries → N+1.
Most frameworks can log this per request in development (Rails logs it, Django Debug Toolbar shows it, SQLAlchemy can echo, Laravel Telescope counts). In production, your APM does it.
7.2 Use Application Monitoring
APM tools (Datadog, New Relic, Sentry Performance, OpenTelemetry traces) show a request's timeline. An N+1 shows up unmistakably as a stack of dozens or hundreds of near-identical short query spans, back to back, inside one endpoint.
7.3 Inspect SQL Logs
Enable statement logging temporarily and look at the raw stream:
sql
-- PostgreSQL, session-level, for a quick lookSET log_statement = 'all';-- or log everything over a thresholdSET log_min_duration_statement = 0;
Scan for the signature: the same query text repeated many times, differing only by a bound parameter.
7.4 Use Database Query Profiling
pg_stat_statements (PostgreSQL) and the MySQL performance schema aggregate queries by normalized text. An N+1 culprit appears as a statement with a very high calls count and a low mean execution time — lots of cheap calls. That combination is the fingerprint.
Key takeaway: You detect N+1 by counting, not by reading query plans. Queries per request vs records per response is the ratio that matters.
8. How to Fix N+1 Queries
There is no single correct fix. Pick based on the relationship shape and how the data is used.
8.1 Eager Loading
What it does: Tells the ORM to load the named relationships in bulk, usually as one extra query per relationship using WHERE id IN (...).
sql
-- BETTER APPROACH: 1 + 4 queries instead of 1 + 400SELECT * FROM products ORDER BY created_at DESC LIMIT 100;SELECT * FROM sellers WHERE id IN (/* 100 seller ids */);SELECT * FROM inventory WHERE product_id IN (/* 100 product ids */);SELECT * FROM reviews WHERE product_id IN (/* 100 product ids */);SELECT * FROM categories WHERE id IN (/* 100 category ids */);
When to use: The default fix for most N+1s, especially has_many relationships where a JOIN would multiply rows.
Trade-offs: You fetch all of the related data whether or not every row needs it. Very large IN lists can themselves be slow — batch them (e.g. chunks of 500–1,000 IDs). Still multiple round-trips, just a constant number instead of N.
8.2 JOINs
What it does: Fetches the parent and related data in a single query.
sql
-- BETTER APPROACH for to-one relationshipsSELECT p.*, s.name AS seller_name, c.name AS category_nameFROM products pJOIN sellers s ON s.id = p.seller_idJOIN categories c ON c.id = p.category_idORDER BY p.created_at DESCLIMIT 100;
When to use:belongs_to / to-one relationships, or when you need to filter or sort by the related table.
Trade-offs: Joining multiple has_many relationships in one query causes a row explosion — 100 products × 20 reviews × 5 inventory records is a 10,000-row cartesian result, with parent columns duplicated on every row. For one-to-many, prefer a separate batched query (8.1) over a wide JOIN. Also, SELECT * across joined tables pulls more data than you need — name your columns.
8.3 Batching
What it does: Collects the IDs needed during processing and issues one query for all of them, instead of one per ID.
sql
-- Instead of 100 × "WHERE id = ?"SELECT * FROM sellers WHERE id IN (11, 12, 13, /* ... */ 130);
When to use: When you can't restructure the top-level query but can defer the related lookups — background jobs, serializers, data pipelines.
Trade-offs: You have to gather the ID set before you can batch, which sometimes means two passes over the data. Watch IN-list size and parameter limits.
8.4 DataLoader-Style Approaches
What it does: A per-request batching-and-caching layer. Individual code paths ask for seller(11), seller(12), … and the loader coalesces all requests made within the same tick into one WHERE id IN (...) query, caching results for the rest of the request.
When to use: GraphQL resolvers especially, where each field resolves independently and you can't easily declare eager loading up front. Also useful in deeply nested REST serializers.
Trade-offs: Adds a caching layer with a lifecycle you must manage (per-request, not global — a long-lived cache serves stale data). More moving parts. It solves coordination, not volume — it still hits the database, just efficiently.
8.5 Select Only the Fields You Need
Related to N+1 but distinct: SELECT * on wide tables, multiplied across eager-loaded relationships, moves a lot of bytes. Once query count is under control, trim query width:
Key takeaway: To-one relationship → JOIN or eager load. One-to-many → batched eager load, not a wide JOIN. GraphQL / independent resolvers → DataLoader.
9. N+1 Queries vs One Large Query
Collapsing everything into a single giant JOIN is not automatically the answer.
N+1 (unfixed)
One wide JOIN
Batched eager load
Round-trips
1 + N (hundreds)
1
small constant (e.g. 5)
Rows transferred
minimal per query
can explode (cartesian)
proportional, no duplication
Parent data duplication
none
repeated on every joined row
none
Query planning cost
paid N times
paid once, but on a complex plan
paid a few times, simple plans
Best for
nothing — it's the bug
filtering/sorting by to-one relations
loading to-many relations for a page of parents
A single JOIN across three has_many tables can transfer more data and plan worse than four clean batched queries. Measure both against realistic data.
10. When N+1 Queries Become a Production Problem
Not every N+1 needs fixing today. It becomes urgent when:
The result set is large or unbounded — pagination hides it at page size 20, exposes it in an export or an internal admin list.
The endpoint is high-traffic — the query multiplier lands on the database as sustained QPS.
The related lookups cross tables that are themselves under load, adding lock and buffer-pool pressure.
It's on a latency-sensitive path — checkout, search, anything a user waits on.
The connection pool is a constraint — long per-request query counts reduce effective concurrency and cause request queuing.
An N+1 on a rarely-hit internal endpoint returning 5 rows is a note for later. The same pattern on GET /products is a page-one incident waiting for a traffic spike.
11. How to Find N+1 Queries in Production
A practical checklist:
Identify a slow endpoint from APM latency percentiles (p95/p99), not averages.
Measure total database queries per request for that endpoint.
Inspect SQL logs or APM query spans for the request.
Look for repeated queries that differ only by an ID or bound parameter.
Compare query count against records returned — if they scale together, it's N+1.
Identify the lazy-loaded relationships driving the repeats (which association, which line of code).
Apply the right fix — eager loading, JOIN, or batching — for that relationship's shape.
Measure again — query count and endpoint latency, on production-like data volume.
Key takeaway: The loop is measure → identify the relationship → fix → measure. Skipping the final measurement is how "fixes" that moved the problem elsewhere ship to production.
12. How to Prevent N+1 Queries
Detection after the fact is fine. Prevention is cheaper.
Log queries-per-request in development. If a page load fires 200 queries, you want to see that number every time, not discover it in an incident.
Add query-count assertions in tests. Many frameworks support asserting "this endpoint runs at most N queries" (assertNumQueries in Django, AssertQueryCount-style helpers elsewhere). A regression that turns 5 queries into 300 then fails CI.
Review generated SQL, not just ORM code. When reviewing a PR that adds a serializer field or a list endpoint, ask what SQL it produces.
Make eager loading explicit at the query site. A list query and its includes/with/prefetch_related should live together so the relationship set is obvious.
Be cautious with serializers and GraphQL resolvers. These resolve fields independently and are the most common place N+1 sneaks back in — reach for DataLoader or explicit prefetching.
Bound your result sets. Enforce pagination limits so a worst-case query count has a ceiling.
13. N+1 Query Problem Checklist
□ Measure queries per request
□ Inspect SQL logs
□ Look for repeated queries (same text, different ID)
□ Check lazy-loaded relationships
□ Use eager loading where appropriate
□ Consider JOINs (to-one) or batching (to-many)
□ Select only the fields you need
□ Measure before and after, on real data volume
□ Add a query-count regression test
14. Comparison Table
Approach
Query behavior
Advantages
Trade-offs
Best use case
N+1 (lazy loading in a loop)
1 + N (or more); scales with rows
Simple code; only loads what's touched
Latency and DB load grow with result size
None intentionally — it's the anti-pattern
Eager loading
1 + k batched IN queries
Kills the per-row explosion; no row duplication
Loads all related rows; large IN lists need chunking
Default fix, especially to-many relations
JOIN
1 query
Fewest round-trips; can filter/sort by related table
Row explosion with multiple to-many joins; wider rows
To-one relationships; filtering by related data
Batching
1 per related entity type, after collecting IDs
Works when you can't change the top query
Needs an ID-collection pass; IN-size limits
Serializers, jobs, pipelines
DataLoader
Coalesces per-tick into IN queries + per-request cache
Great for independent resolvers; dedupes repeated IDs
Extra layer; per-request cache lifecycle to manage
GraphQL resolvers; deeply nested REST
Questions
What is an N+1 query?
An N+1 query is the pattern where an application runs one query to load a list of records, then runs one more query per record to load related data. For N records the database sees 1 + N queries. If each record loads several relationships separately, it's 1 + (N × k).
Why is N+1 bad?
Each extra query carries fixed overhead — a network round-trip, parsing, planning, connection time — and that overhead is paid once per row. Hundreds of trivial queries add up to significant latency and database load, even though no single query is slow. The cost grows with the size of the result set.
How do I detect N+1 queries?
Count the database queries for one API request and compare it to the number of records returned. If the query count scales with the row count, and the logs show the same query repeated with different IDs, it's an N+1. APM query spans and pg_stat_statements (high calls, low mean time) show the same fingerprint.
How do I fix N+1 queries?
Load the related data in bulk instead of per row. Use eager loading (includes / with / prefetch_related / Include) for a batched WHERE id IN (...) query, a JOIN for to-one relationships, or a DataLoader for independent resolvers. Then measure query count and latency again on realistic data.
Can an N+1 query happen with raw SQL?
Yes. N+1 is an application pattern, not an ORM feature. Any code that runs a list query and then issues a per-row follow-up query in a loop produces it, whether the SQL is hand-written or ORM-generated.
Do ORMs cause N+1 queries?
ORMs make it easy to trigger through lazy loading — a relationship accessed inside a loop is fetched with its own query each iteration. ORMs also provide the fix (eager loading). The problem is not using an ORM; it's not checking the SQL it generates.
What is the difference between N+1 and a slow query?
A slow query is a single statement with a bad execution plan — often a missing index. An N+1 problem is many fast queries whose count scales with rows. Adding an index fixes the first and does nothing for the second; eager loading fixes the second and does nothing for the first.
Is N+1 a database problem or an application problem?
It's an application problem that manifests as database load. The database is doing exactly what it was asked; the application asked badly. The fix lives in application code — how relationships are loaded — not in database configuration or indexes.
How can I prevent N+1 queries?
Log queries-per-request in development, add query-count assertions to tests so a regression fails CI, review the SQL that list endpoints and serializers generate, keep eager-loading declarations next to the query, and enforce pagination limits so worst-case query counts are bounded.
15. Key Takeaways
The N+1 query problem is 1 + N queries for a list of N items — one for the collection, one per item for related data — and it often multiplies further when each item loads several relationships.
No individual query has to be slow. The latency is per-query overhead (round-trips, parsing, connection time) paid hundreds of times.
It's an application-level inefficiency, distinct from slow queries and database bottlenecks, and it's diagnosed by counting queries per request, not by reading query plans.
ORM lazy loading is the usual trigger — a relationship accessed inside a loop — and ORM eager loading is the usual fix.
Match the fix to the relationship shape: JOIN for to-one, batched eager loading for to-many, DataLoader for independent resolvers like GraphQL. One giant JOIN can be worse than several clean batched queries.
It becomes a production problem on large result sets, high-traffic endpoints, and latency-sensitive paths — pagination often hides it until an export or a spike exposes it.
Prevent regressions with query-count tests and by measuring before and after every fix on production-like data volume.
16. If Your API Is Unexpectedly Slow
Don't only look for slow SQL. Measure how many database queries each request generates, and compare that number to how many records it returns. Hiring teams who want candidates to explain incidents like this can use structured screening. If the two scale together, you've found an N+1 — and the fix is almost always to load the related data in bulk instead of row by row.
Useful references while debugging: your ORM's eager-loading documentation, the PostgreSQL EXPLAIN documentation, and the pg_stat_statements extension for spotting high-call-count statements.
🎯 Now Explain It Out Loud
"Why did one request make 300 queries?" is a common backend interview question, and a common production incident. Knowing the answer isn't the same as being able to walk someone through it clearly.
→ candidates.intervues.club — practise backend and database interviews with an AI voice interviewer. Speak your answer, get a report on your reasoning and clarity. Free to start.
→ intervues.club — hiring? Run AI voice technical interviews that test whether candidates can actually explain database behaviour.
Nine years as an engineer, three interviewing candidates. I built Intervues because the gap isn't knowledge — it's saying what you know out loud. Real email at the other end: admin@intervues.club.