The N+1 Query Problem: How to Detect and Fix It
Practice4 minInnovialabs editorial team
In short
N+1 happens when code runs one query for a list and then one more query per item; you fix it by loading related data up front in one or two queries with eager loading, JOINs or batching.
What N+1 is and why it hurts
N+1 is when an application runs 1 query to fetch a list of records and then N separate queries, one per record, to load related data.
Example: a page with 100 posts, each showing its author. The code fetches posts in one query, then loops and fetches the author for each post. That is 101 queries instead of 1 or 2.
On a local database with ten rows you will not notice. In production, where the database lives on another server and every query is a network round-trip, the page slows down and database load grows with the size of the list.
How ORMs create N+1 silently
ORMs make relations look like ordinary object fields. But touching such a field can run a query.
Django:
posts = Post.objects.all()[:100]
for post in posts:
print(post.author.name) # a database query on every iteration
Laravel Eloquent:
$posts = Post::take(100)->get();
foreach ($posts as $post) {
echo $post->author->name; // lazy loading: one query per post
}
Prisma and TypeORM do not load relations on access, so N+1 there tends to be explicit: a query written inside a loop or inside Promise.all over a list:
const posts = await prisma.post.findMany({ take: 100 });
const result = await Promise.all(
posts.map((p) => prisma.user.findUnique({ where: { id: p.authorId } }))
);
GraphQL is a separate source of N+1: the author field resolver is called once per post.
How to detect N+1
- SQL logs. Turn on query logging in development. The sign of N+1 is a run of identical queries that differ only in the
idparameter. - APM and tracing. In a request trace, N+1 looks like a "staircase" of dozens of identical short database spans inside one HTTP request.
- Framework tools. Django Debug Toolbar shows the query count and duplicates. In Laravel you can enable
Model::preventLazyLoading()in development so lazy loading throws an exception. - Query count tests. For example,
assertNumQueriesin Django: the test fails if an endpoint starts running more queries than expected.
How to fix it
Eager loading
Tell the ORM up front which relations you need:
| ORM | Fix |
|---|---|
| Django | select_related("author") for ForeignKey (JOIN), prefetch_related("tags") for M2M and reverse relations (separate IN query) |
| Laravel Eloquent | Post::with('author')->take(100)->get() |
| Prisma | prisma.post.findMany({ take: 100, include: { author: true } }) |
| TypeORM | find({ relations: { author: true } }) or leftJoinAndSelect in QueryBuilder |
Before: 1 query for posts + 100 for authors = 101.
After: 2 queries (posts + authors via IN) or 1 query with a JOIN.
A manual JOIN
If you only need a couple of fields, one query is simpler:
SELECT p.id, p.title, u.name AS author_name
FROM posts p
JOIN users u ON u.id = p.author_id
ORDER BY p.created_at DESC
LIMIT 100;
Batching and DataLoader
When relations are loaded from different places in the code (GraphQL resolvers, services), use DataLoader: it collects all ids requested within one tick and runs one WHERE id IN (...) query.
const userLoader = new DataLoader(async (ids: readonly number[]) => {
const users = await prisma.user.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => byId.get(id) ?? null);
});
Important: DataLoader must return results in the same order as ids. Create a new instance per incoming request so the cache never mixes data between users.
Common mistakes when fixing it
- Loading everything. An
includefive levels deep pulls unnecessary data, which is a different performance problem. - JOIN on one-to-many with pagination. Rows get duplicated and
LIMITcuts result rows, not posts. For such relations a separateINquery works better. - Fixing one endpoint and forgetting the rest. Without query count tests, N+1 comes back with the next change.