Here's a performance bug that never shows up in a slow-query log.
Every individual query is fast: 1 to 2 ms, perfectly indexed, textbook plans. Your database CPU is bored. And yet the endpoint takes 400 ms, gets slower as data grows, and under load it eats your connection pool alive.
That's the N+1 query problem: you run 1 query to fetch a list, then N more queries, one per item, to fetch something related. 100 items means 101 round trips to the database.
It's one of the most common performance problems in backend code, and it's easy to write by accident, especially with ORMs, GraphQL, and async/await, which make a loop of queries look completely innocent.
In this post we'll cover how to recognize it, how to prove it's happening, and the fixes, from plain SQL to DataLoader to ORM eager loading.
What N+1 looks like
Say we have a blog with authors, posts, and comments:
CREATE TABLE authors (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE posts (id BIGSERIAL PRIMARY KEY, author_id BIGINT NOT NULL REFERENCES authors(id),
title TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now());
CREATE TABLE comments (id BIGSERIAL PRIMARY KEY, post_id BIGINT NOT NULL REFERENCES posts(id),
body TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now());
CREATE INDEX posts_author_idx ON posts (author_id);
CREATE INDEX comments_post_idx ON comments (post_id, created_at DESC);
The endpoint: "list the 50 latest posts with their author's name."
// ❌ N+1: 1 query for posts + 50 queries for authors
app.get('/posts', async (req, res) => {
const { rows: posts } = await pool.query(
'SELECT id, author_id, title FROM posts ORDER BY created_at DESC LIMIT 50',
);
for (const post of posts) {
const { rows } = await pool.query('SELECT name FROM authors WHERE id = $1', [post.author_id]);
post.author = rows[0];
}
res.json(posts);
});
That's 51 queries. It reads naturally, it works in development with 5 rows, and it passes code review because no single line looks wrong.
Why it hurts more than you'd expect
- Round-trip latency dominates. Each query pays network latency to the database, even if execution takes microseconds. If a round trip costs ~1 to 2 ms, 51 sequential queries cost 50 to 100 ms of pure waiting, versus one or two trips for the right approach. (Those numbers are illustrative. Measure your own environment, since the gap is much bigger when the database is in a different availability zone.)
- It scales with data, not with traffic. The cost grows with N. A page that returns 20 items today returns 200 next quarter.
-
It's invisible to
EXPLAIN. Each query's plan is perfect. The problem is the count, which per-query tools can't show. -
It hammers the connection pool. A single request holding a connection through 50 sequential queries (or fanning out 50 in parallel with
Promise.all) can monopolize your pool. N+1 is a frequent root cause of the pool starvation we've covered in earlier posts.
The hidden variants
The loop-with-a-query version is the easy one to spot. In real codebases N+1 hides in several disguises.
1. Promise.all over a map
// ❌ Still N+1. It's just concurrent N+1
const withAuthors = await Promise.all(
posts.map(async (p) => ({
...p,
author: (await pool.query('SELECT name FROM authors WHERE id = $1', [p.author_id])).rows[0],
})),
);
This is faster in wall-clock terms (the queries overlap), but it still issues N queries, and now they all hit the pool at once. It trades latency for pool pressure.
2. GraphQL resolvers
const resolvers = {
Query: { posts: () => db.query('SELECT * FROM posts ORDER BY created_at DESC LIMIT 50') },
Post: {
// ❌ Called once per post in the result
author: (post) => db.query('SELECT * FROM authors WHERE id = $1', [post.author_id]),
},
};
Field resolvers run per parent object, so a nested field becomes an N+1 automatically. This is the textbook GraphQL performance trap.
3. ORM lazy loading
Many ORMs let you access a relation as a property and load it on demand. Looping over a collection and touching post.author fires a query per iteration without a single explicit query() call in your code. If your ORM supports lazy relations, they are a common source of N+1.
4. Helper functions that hide the query
// Looks like a pure function call. Is actually a database round trip.
for (const order of orders) {
order.total = await calculateTotal(order.id); // runs SELECT ... inside
}
Any await of a data-access function inside a loop deserves suspicion.
Step 1: Detect it
You can't fix what you can't see. Here are four increasingly rigorous ways to catch N+1s.
A. Read the query log for repeating patterns
Temporarily log every statement in a dev or staging database:
-- Log all statements (very noisy; use on dev/staging, briefly)
ALTER SYSTEM SET log_min_duration_statement = 0;
SELECT pg_reload_conf();
Hit the endpoint once and look at the log. N+1 is unmistakable:
SELECT id, author_id, title FROM posts ORDER BY created_at DESC LIMIT 50
SELECT name FROM authors WHERE id = 7
SELECT name FROM authors WHERE id = 12
SELECT name FROM authors WHERE id = 7
SELECT name FROM authors WHERE id = 31
... (46 more)
Same statement shape, different parameter, repeated. (Remember to turn logging back down afterward: ALTER SYSTEM RESET log_min_duration_statement; and reload.)
B. pg_stat_statements: find it in production
The pg_stat_statements extension aggregates normalized queries across all traffic. N+1 shows up as a cheap query with an enormous call count:
SELECT
calls,
round(mean_exec_time::numeric, 3) AS mean_ms,
round(total_exec_time::numeric, 0) AS total_ms,
left(query, 80) AS query
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 20;
Look for a statement like SELECT name FROM authors WHERE id = $1 with millions of calls and a tiny mean time. That profile (low mean, huge calls, large total) is the N+1 fingerprint. (The extension must be in shared_preload_libraries and created with CREATE EXTENSION pg_stat_statements. Column names like total_exec_time apply to PostgreSQL 13+.)
C. Count queries per request in your app
Use AsyncLocalStorage to attribute queries to the request that issued them, then log or alert when the count is suspicious:
// query-counter.ts
import { AsyncLocalStorage } from 'node:async_hooks';
import type { Pool } from 'pg';
interface Counter { count: number; queries: string[] }
export const als = new AsyncLocalStorage<Counter>();
export function instrument(pool: Pool): Pool {
const original = pool.query.bind(pool) as (...args: any[]) => Promise<any>;
(pool as any).query = (...args: any[]) => {
const store = als.getStore();
if (store) {
store.count++;
const text = typeof args[0] === 'string' ? args[0] : args[0]?.text;
store.queries.push(text);
}
return original(...args);
};
return pool;
}
// middleware
app.use((req, res, next) => {
const counter: Counter = { count: 0, queries: [] };
als.run(counter, () => {
res.on('finish', () => {
metrics.histogram('http.db_queries', counter.count, { route: req.route?.path ?? req.path });
if (counter.count > 30) {
logger.warn({ route: req.path, count: counter.count, sample: counter.queries.slice(0, 5) },
'suspiciously many queries in one request');
}
});
next();
});
});
Note that this wraps pool.query. Queries run on a client from pool.connect() (for example, inside transactions) need the same wrapper on client.query. Once you graph "DB queries per request" per route, N+1 routes stand out immediately: they're the ones where the number tracks the size of the response.
D. Assert query counts in tests (the best long-term defense)
Detection tells you about today's bugs. A test prevents tomorrow's:
// posts.test.ts
import { it, expect } from 'vitest';
import { als } from './query-counter';
async function countQueries<T>(fn: () => Promise<T>) {
const counter = { count: 0, queries: [] as string[] };
const result = await als.run(counter, fn);
return { result, ...counter };
}
it('lists posts with authors without N+1', async () => {
await seedPosts(50); // 50 posts, many authors
const { count } = await countQueries(() => getLatestPosts(50));
expect(count).toBeLessThanOrEqual(2); // fixed number, independent of N
});
The crucial design choice is asserting a constant bound, and testing with enough rows that an N+1 would blow it. A test with 2 posts would pass an N+1 implementation with 3 queries.
Step 2: Fix it
The principle behind every fix is the same: replace N queries with a constant number of queries, regardless of how many rows you're processing.
Fix 1: Use a JOIN
When you need related data for every row, let the database join it:
// ✅ 1 query, regardless of N
const { rows } = await pool.query(`
SELECT p.id, p.title, p.created_at,
a.id AS author_id, a.name AS author_name
FROM posts p
JOIN authors a ON a.id = p.author_id
ORDER BY p.created_at DESC
LIMIT 50
`);
const posts = rows.map((r) => ({
id: r.id,
title: r.title,
createdAt: r.created_at,
author: { id: r.author_id, name: r.author_name },
}));
For many-to-one relations (post → author), a join is almost always the best answer. Make sure the join columns are indexed (posts.author_id above).
Fix 2: Batch with = ANY($1) (the "WHERE IN" pattern)
When a join is awkward (data lives in another service, or you want to keep queries separate), fetch the parents, collect their IDs, and load all children in one query:
// ✅ 2 queries total
const { rows: posts } = await pool.query(
'SELECT id, author_id, title FROM posts ORDER BY created_at DESC LIMIT 50',
);
const authorIds = [...new Set(posts.map((p) => p.author_id))];
const { rows: authors } = await pool.query(
'SELECT id, name FROM authors WHERE id = ANY($1::bigint[])',
[authorIds],
);
const authorById = new Map(authors.map((a) => [a.id, a]));
const result = posts.map((p) => ({ ...p, author: authorById.get(p.author_id) }));
Details that matter:
- Pass the array as a single parameter and use
= ANY($1::bigint[]). node-postgres converts a JS array into a Postgres array, so you never buildIN (...)strings by hand, and it stays safe from SQL injection. - Deduplicate IDs with a
Setso you don't fetch the same author repeatedly. - Index the lookup column.
- For truly huge ID lists (tens of thousands), process them in chunks.
This "fetch parents, then fetch children by ID list, then stitch in memory" approach is exactly what many ORMs do under the hood for eager loading.
Fix 3: One-to-many with json_agg
One-to-many relations (post → comments) are trickier, because a naive JOIN repeats the parent columns on every child row. You can aggregate children into JSON in the same query:
const { rows } = await pool.query(`
SELECT p.id, p.title,
COALESCE(
json_agg(json_build_object('id', c.id, 'body', c.body) ORDER BY c.created_at DESC)
FILTER (WHERE c.id IS NOT NULL),
'[]'
) AS comments
FROM (
SELECT * FROM posts ORDER BY created_at DESC LIMIT 50
) p
LEFT JOIN comments c ON c.post_id = p.id
GROUP BY p.id, p.title, p.created_at
ORDER BY p.created_at DESC
`);
// rows[i].comments is already a JS array (pg parses json columns)
Notice the subquery with LIMIT 50 on posts first, then the join. If you join first and LIMIT afterward, you're limiting rows, not posts, and you'll get half-loaded posts or wasted work.
Fix 4: "Latest 3 comments per post" with LATERAL
A very common requirement is a limited number of children per parent. json_agg can't limit per group on its own. LATERAL handles it cleanly:
SELECT p.id, p.title, lc.comments
FROM (SELECT * FROM posts ORDER BY created_at DESC LIMIT 50) p
LEFT JOIN LATERAL (
SELECT COALESCE(json_agg(t), '[]') AS comments
FROM (
SELECT c.id, c.body
FROM comments c
WHERE c.post_id = p.id
ORDER BY c.created_at DESC
LIMIT 3
) t
) lc ON true
ORDER BY p.created_at DESC;
The LATERAL subquery runs per post inside the database (fast, using the comments (post_id, created_at DESC) index), with zero extra application round trips. Alternatively, you can use row_number() OVER (PARTITION BY post_id ORDER BY created_at DESC) in a batched child query and filter rn <= 3.
Fix 5: DataLoader for GraphQL (and other resolver-style code)
In GraphQL, you can't easily restructure around joins because resolvers are independent. DataLoader solves this by batching all .load(key) calls made in the same tick of the event loop into one function call:
import DataLoader from 'dataloader';
function createLoaders() {
return {
authorById: new DataLoader<number, Author>(async (ids) => {
const { rows } = await pool.query(
'SELECT id, name FROM authors WHERE id = ANY($1::bigint[])',
[ids as number[]],
);
const byId = new Map(rows.map((r) => [Number(r.id), r]));
// MUST return results in the same order and length as `ids`
return ids.map((id) => byId.get(id) ?? new Error(`Author ${id} not found`));
}),
};
}
// Create per request, so the cache never leaks across users
const server = new ApolloServer({ typeDefs, resolvers });
// context: () => ({ loaders: createLoaders(), user })
const resolvers = {
Post: {
author: (post, _args, { loaders }) => loaders.authorById.load(post.author_id),
},
};
Fifty resolvers each call loaders.authorById.load(...); DataLoader collects them and invokes your batch function once with all the IDs. That turns 50 queries into 1.
Rules for DataLoader:
- One instance per request, never a global. It caches by key, and a global cache would serve stale or cross-user data.
- The batch function must return an array the same length and order as the keys. Missing keys should map to
nullor anError, not be skipped. A mismatch is the most common DataLoader bug. - DataLoader batches calls made within the same tick, so the
loadcalls must happen before an interveningawaitpushes them into different ticks. In practice, resolver trees satisfy this naturally.
Fix 6: ORM eager loading
If you use an ORM, the fix is usually one option away. The exact API varies, so check your ORM's docs for the version you use:
// Prisma: load relations up front
const posts = await prisma.post.findMany({
take: 50,
orderBy: { createdAt: 'desc' },
include: { author: true },
});
// Sequelize: include => JOIN
const posts = await Post.findAll({ limit: 50, order: [['createdAt', 'DESC']], include: [Author] });
// TypeORM: join and select the relation
const posts = await dataSource
.getRepository(Post)
.createQueryBuilder('post')
.leftJoinAndSelect('post.author', 'author')
.orderBy('post.createdAt', 'DESC')
.take(50)
.getMany();
Different ORMs use different strategies (a SQL join versus a second batched IN query), and some let you choose. Whichever it is, verify with the query log or your query counter rather than assuming. "I used include" has produced plenty of surprise N+1s, especially with nested relations or relations accessed through a different path than the one you eager-loaded.
Pitfalls when you fix N+1
Eliminating N+1 can introduce its own problems if you're not careful.
Row explosion from multiple one-to-many joins
-- ❌ 50 posts × 20 comments × 10 tags = 10,000 rows for 50 posts
SELECT ... FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
LEFT JOIN post_tags t ON t.post_id = p.id
Joining two independent one-to-many relations produces a Cartesian product per parent. Use json_agg in separate lateral subqueries, or load each relation with its own batched ANY($1) query and stitch in memory. Several well-shaped queries beat one giant join.
Pagination with joins
Apply LIMIT/OFFSET (or, better, keyset pagination) to the parent set in a subquery, as in Fix 3. Never paginate after a one-to-many join.
Over-fetching
Batching is not a license to SELECT * everything. If you only need authors.name, select just that. Wide rows multiplied by many results add up over the wire.
Don't batch what shouldn't be batched
One query with a 50,000-element ANY array, or a join that drags back millions of rows, is not an improvement. Cap batch sizes and chunk large inputs.
Premature "optimization"
N+1 with N = 3 and a sub-millisecond query is not worth rewriting for. The problem matters when N is unbounded or large, when queries are slow, or when latency to the database is high. Prioritize with data: use the per-route query counts and pg_stat_statements rather than hunting by code review.
Putting guardrails in place
- Graph "DB queries per request" by route. It should be flat as response size grows.
- Assert constant query counts in integration tests, using enough seed rows to expose a loop.
-
Review
pg_stat_statementsperiodically for cheap queries with huge call counts. - Make DataLoader (or an equivalent batching pattern) the default in GraphQL APIs, so the batched path is the easy path.
-
Treat
awaitinside loops as a code review smell, especiallyawait db.something()insideforor.map. -
Limit pool damage while you fix things: set
connectionTimeoutMillis, and cap concurrency if you must fan out.
Before and after
| Naive loop | JOIN / batched ANY
|
|
|---|---|---|
| Queries for 50 posts | 51 | 1 or 2 |
| Queries for 500 posts | 501 | 1 or 2 |
| Scales with | Number of rows (N) | Roughly constant |
| Visible in slow query log? | No (each query is fast) | n/a |
| Pool pressure | High | Low |
Visible in pg_stat_statements? |
Yes: huge calls
|
Normal |
Key takeaways
- N+1 means 1 query for a list plus N queries for related data. The pattern repeats because the loop looks innocent.
-
Each query is fast, so slow-query tools miss it. Detect it by counting queries per request, scanning logs for repeated shapes, and checking
pg_stat_statementsfor high-callsqueries. -
Promise.alldoesn't fix it. It just makes the N queries concurrent and puts pressure on the connection pool. -
Replace N queries with a constant number: JOINs for many-to-one,
= ANY($1)batching,json_aggorLATERALfor one-to-many, DataLoader for GraphQL, eager loading for ORMs. - Beware row explosion and pagination. Limit parents in a subquery first, and don't join multiple independent one-to-many relations into one result.
- Lock it in with tests that assert a fixed query count against realistic data sizes.
Where have you found the sneakiest N+1 in your own codebase? Let me know in the comments.
Further reading: the PostgreSQL docs on pg_stat_statements, the DataLoader repository, and your ORM's documentation on eager loading and query logging.
Top comments (0)