N+1 Queries
Date: 2026-08-17
One query to fetch a list, then one more for each item in it. The most common performance bug in application code, and the hardest to see, because every individual query is fast and the code that causes it looks perfectly reasonable.
An N+1 query problem is issuing one query to retrieve N records, then N additional queries to retrieve related data for each — where a single query could have fetched everything.
The shape
const orders = await db.orders.findAll() // 1
for (const o of orders) {
o.customer = await db.customers
.findById(o.customerId) // N
}100 orders
→ 1 + 100 = 101 queries
each query: 2ms
total: 202ms
one joined query: 3ms
Sixty-seven times slower, and the profiler shows nothing wrong — no single query is slow. The slow thing is the page, and the cause is a count.
Why it hides so well
- Each query is fast. A slow-query log with a 100ms threshold catches none of them
- ORMs make it invisible.
order.customerlooks like a property access and is a database round trip. Lazy loading is the mechanism, and it’s on by default in most ORMs - It scales with data, not code. Fine with 10 test records, ruinous with 1,000 real ones
- It appears in templates. A loop rendering
{{ order.customer.name }}triggers it from the view layer, far from any query code
Finding it
1 log every query with a count per request
2 look for the same query shape repeated
with different parameters
3 a request issuing 50+ queries is
almost always this
A query counter in development is the single most effective control — log a warning above a threshold and it stops being possible to ship one unnoticed.
The fixes
Eager loading — tell the ORM upfront, and get one query with a join, or two queries total.
orders.findAll({ include: ['customer'] })Batch fetch — do it yourself, in one query.
const ids = orders.map(o => o.customerId)
const customers = await db.customers
.whereIn('id', ids) // ONE query
const byId = new Map(
customers.map(c => [c.id, c]))
orders.forEach(o =>
o.customer = byId.get(o.customerId))DataLoader — batches and deduplicates requests within a tick, issuing one query. The standard answer in GraphQL.
The batch-fetch pattern is worth internalising — fetch the ids, one query with IN, build a Map, join in memory. It works everywhere, with or without an ORM, and turns O(n) round trips into O(1) — Data Structures.
The trap on the other side
Eager loading everything is its own problem:
include: ['customer', 'items',
'items.product', 'shipping',
'payments', 'refunds']
→ one enormous join
→ row multiplication: 100 orders ×
5 items × 3 payments = 1,500 rows
to represent 100 orders
→ megabytes over the wire to render
a list of order numbers
Load what the page uses. Nothing more. The right answer is usually two or three queries, not one giant join and not 101 small ones.
It isn’t only databases
The same shape, worse, over a network:
GET /api/orders → 100 orders
GET /api/customers/1 ┐
GET /api/customers/2 ├ 100 requests
... ┘
100 × 80ms latency = 8 seconds
Latency makes the API version dramatically worse than the database version — Latency and Bandwidth. It’s why REST endpoints grow ?include= parameters and why GraphQL exists, and why an await inside a loop deserves a second look every time — Async Models.
The rule to carry
Any loop containing a query, a fetch, or an await is a potential N+1. That’s the pattern to notice in review. The fix is nearly always the same: hoist the fetch out of the loop and do it once for all the items.