Loading post
Jun 29, 2026
.png)
When an application feels slow, the cause is not always a complex algorithm or an overloaded server. Sometimes it is one ordinary-looking loop.
The N+1 query problem happens when your code first fetches a list of records, then runs one extra database query for each item in that list.
In short:
| Step | Query count |
|---|---|
| Load the main list | 1 |
| Load related data for each item | N |
| Total | N+1 |
That is why it is called the N+1 problem.
Imagine a blog page that shows post titles and author names.
You might write code like this:
const posts = await db.post.findMany();
for (const post of posts) {
const author = await db.user.findUnique({
where: { id: post.authorId },
});
console.log(post.title, author?.name);
}
This code is easy to read, but it hides a performance problem.
If there are 10 posts, the app runs 11 queries.
1 query -> fetch posts
10 queries -> fetch each author
11 queries total
If there are 100 posts, the app runs 101 queries.
1 query -> fetch posts
100 queries -> fetch each author
101 queries total
The page still works, but the database is doing much more work than necessary.
The application may be sending SQL like this:
SELECT * FROM posts LIMIT 100;
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
-- repeated for every post
The better approach is usually to fetch the related data together.
SELECT
posts.id,
posts.title,
users.name AS author_name
FROM posts
JOIN users ON users.id = posts.author_id
LIMIT 100;
Now the app can load posts and author names with one query.
The tricky part is that N+1 problems often do not look bad in development.
With 5 records in a local database, the page may feel instant. In production, the same code might handle 500 records, real network latency, and a busy database.
| Number of posts | Queries with N+1 | Queries with eager loading, join, or batching |
|---|---|---|
| 5 | 6 | 1 or 2 |
| 50 | 51 | 1 or 2 |
| 500 | 501 | 1 or 2 |
| 5,000 | 5,001 | 1 or 2 |
Even if each query is fast, many small queries add up. They also increase connection usage, database CPU, network round trips, and tail latency.
The important detail is that N+1 is not about normal loop logic by itself.
If you are only mapping over data that is already in memory, that is not an N+1 query problem.
const posts = [
{ title: "Post A", authorName: "Yudai" },
{ title: "Post B", authorName: "Alex" },
];
const result = posts.map((post) => ({
title: post.title,
authorName: post.authorName,
}));
This is just array processing. It does not touch the database or an API.
N+1 becomes a problem when the loop calls an external data source.
The common name is "N+1 query problem", so it usually appears in database discussions. But the same shape can happen with APIs too.
const posts = await fetchPosts();
for (const post of posts) {
// One HTTP request per post
const author = await fetchAuthor(post.authorId);
console.log(post.title, author.name);
}
So the problem is not for or map. The problem is calling a database or API inside the loop.
The fix for N+1 is not a complicated algorithm.
The basic rule is:
Do not fetch related data one item at a time inside a loop.
The bad shape looks like this:
const posts = await db.post.findMany();
const result = [];
for (const post of posts) {
const author = await db.user.findUnique({
where: { id: post.authorId },
});
result.push({
title: post.title,
authorName: author?.name,
});
}
The problem is the database call inside the for loop. If there are 100 posts, the app may fetch authors 100 times.
The better shape is to load the related data before rendering the list.
const posts = await db.post.findMany({
include: {
author: true,
},
});
const result = posts.map((post) => ({
title: post.title,
authorName: post.author.name,
}));
This code does not call the database inside map. The author data is already inside posts.
Conceptually, the returned data shape changes.
Without include, a post only has authorId.
[
{
id: "post_1",
title: "N+1 Problem",
authorId: "user_1",
},
];
With include: { author: true }, the author object comes back with the post.
[
{
id: "post_1",
title: "N+1 Problem",
authorId: "user_1",
author: {
id: "user_1",
name: "Yudai",
},
},
];
That means this post.author.name is not a database call:
const result = posts.map((post) => ({
title: post.title,
authorName: post.author.name,
}));
It is only reading an object that is already in memory. The database work already happened in findMany({ include: ... }).
Some frameworks can fetch data lazily when you access post.author. Rails and Django can behave this way if you do not preload relationships. That is why includes and select_related matter: they tell the framework to fetch the relationship before the loop.
author: true Mean?This Prisma syntax can feel strange at first.
const posts = await db.post.findMany({
include: {
author: true,
},
});
The true does not mean "author is true."
It means:
include: {
author: true, // include the author relation in the result
}
So you are asking Prisma to return the related author object together with each post.
With include, the main Post fields are returned, and the selected relation is added.
{
id: "post_1",
title: "N+1 Problem",
authorId: "user_1",
author: {
id: "user_1",
name: "Yudai",
email: "yudai@example.com"
}
}
If you do not need every author field, use select instead of true.
const posts = await db.post.findMany({
include: {
author: {
select: {
id: true,
name: true,
},
},
},
});
Now the author object only contains id and name.
{
title: "N+1 Problem",
author: {
id: "user_1",
name: "Yudai"
}
}
If you want to choose fields from the Post itself too, use top-level select.
const posts = await db.post.findMany({
select: {
title: true,
author: {
select: {
name: true,
},
},
},
});
This means: "Only return the post title and the author's name."
The idea is the same when multiple related records are needed.
Imagine a post list that needs:
A naive implementation can easily become N+1:
const posts = await db.post.findMany();
for (const post of posts) {
const author = await db.user.findUnique({ where: { id: post.authorId } });
const category = await db.category.findUnique({ where: { id: post.categoryId } });
const comments = await db.comment.findMany({ where: { postId: post.id } });
const tags = await db.tag.findMany({ where: { posts: { some: { id: post.id } } } });
console.log(post.title, author?.name, category?.name, comments.length, tags);
}
With 100 posts and 4 related lookups per post, this can become 1 + 100 * 4 = 401 queries.
In Prisma, you can declare the relations up front.
const posts = await db.post.findMany({
include: {
author: {
select: { id: true, name: true },
},
category: {
select: { id: true, name: true },
},
comments: {
select: { id: true },
},
tags: {
select: { id: true, name: true },
},
},
});
const result = posts.map((post) => ({
title: post.title,
authorName: post.author.name,
categoryName: post.category.name,
commentCount: post.comments.length,
tagNames: post.tags.map((tag) => tag.name),
}));
Again, the map does not call the database. It is only shaping data that was already loaded.
But including everything is not automatically good. If each post has comments, comment authors, likes, and attachments, including all of it can make the response huge.
For a list page, it is often better to return a count instead of every comment body.
const posts = await db.post.findMany({
select: {
title: true,
author: {
select: { name: true },
},
_count: {
select: { comments: true },
},
},
});
A common practical split is: list pages load lightweight summary data, detail pages load deeper data.
N+1 is easiest to understand as external I/O complexity, not just CPU complexity.
| Implementation | DB/API calls | In-memory work | Comment |
|---|---|---|---|
| Fetch one related record inside a loop | 1 + N | O(N) | N+1. External I/O grows with the list |
Fetch in parallel with Promise.all | 1 + N | O(N) | Waiting may shrink, but call count is unchanged |
include / join | Usually 1 | O(N) | Loads the relationship up front |
Batch with WHERE id IN (...) | Usually 2 | O(N + U) | U is the number of unique related IDs |
| Multiple relations | Around 1 to 1 + R | O(N + related rows) | R is relation count; exact behavior depends on the ORM |
The important point is that map being O(N) is normal. The issue is when every iteration also triggers DB/API I/O, making external I/O O(N).
Also, one giant JOIN is not always faster. Deep joins across one-to-many relationships can duplicate parent rows and inflate the returned data.
In practice:
include or JOIN for lightweight one-to-one and many-to-one relationships.WHERE id IN (...).If the word ORM feels abstract, think of it as the framework layer that lets you write database access using application objects.
Prisma, Rails Active Record, Django ORM, and Laravel Eloquent all translate code like post.author or include: { author: true } into SQL.
When N+1 happens, the framework is often doing something like this:
When you fix it, you tell the framework that authors are needed from the beginning.
Common framework patterns:
| Framework | N+1 prevention syntax | What it does |
|---|---|---|
| Prisma | include: { author: true } | Loads the related author with the post |
| Rails Active Record | Post.includes(:author) | Preloads authors |
| Django | Post.objects.select_related("author") | Joins the author for a foreign key |
| Laravel Eloquent | Post::with('author')->get() | Preloads authors |
The exact SQL can differ. Some frameworks use a JOIN. Others use a second batched query with WHERE id IN (...).
The important point is: 100 parent rows should not cause 100 child lookups.
Also, Promise.all does not fix the root problem.
const posts = await db.post.findMany();
const result = await Promise.all(
posts.map(async (post) => {
const author = await db.user.findUnique({
where: { id: post.authorId },
});
return {
title: post.title,
authorName: author?.name,
};
}),
);
This may reduce waiting time, but it does not reduce query count. With 100 posts, it still fetches authors 100 times. To prevent N+1, change parallel per-item fetching into batched fetching.
"Do not call DB/API inside a loop" is mainly a rule for list reads and read-heavy endpoints.
In real systems, external I/O inside a loop is sometimes necessary.
Examples:
Even then, unbounded Promise.all is risky.
// Risky: 1000 users means 1000 requests at once
await Promise.all(
users.map((user) => sendWelcomeEmail(user.email)),
);
This can hit external rate limits or exhaust your own database connections.
Use this order of thinking instead.
await db.user.updateMany({
where: { plan: "trial" },
data: { plan: "expired" },
});
For database writes, one bulk operation is often better than many per-row updates.
const users = await db.user.findMany({
where: {
id: { in: userIds },
},
});
For reads, check whether WHERE id IN (...) is enough.
const chunkSize = 50;
for (let i = 0; i < users.length; i += chunkSize) {
const chunk = users.slice(i, i + chunkSize);
await Promise.all(
chunk.map((user) => sendWelcomeEmail(user.email)),
);
}
This does not eliminate N+1 completely, but it controls load and rate limits better than firing 1000 requests at once.
Email delivery, image processing, and external service sync often should not block the user request.
for (const user of users) {
await jobQueue.enqueue("sendWelcomeEmail", {
userId: user.id,
});
}
Return the API response quickly, then let workers handle the slow work.
Summary:
| Situation | Response |
|---|---|
| Reading related data for a list | include, JOIN, or batch loading |
| Reading the same kind of record by IDs | WHERE id IN (...) |
| External API has a batch endpoint | Use the batch endpoint |
| External API has no batch endpoint | Chunking, concurrency limits, retries, rate-limit handling |
| Emails, payments, and other side effects | Queue or background job |
| Large data processing | Pagination, streaming, or chunk processing |
So the more precise rule is not "never call DB/API in a loop." It is: avoid designs where external I/O grows without control as the list grows.
Most ORMs have a way to fetch related data up front.
With Prisma, you can use include:
const posts = await db.post.findMany({
include: {
author: true,
},
});
for (const post of posts) {
console.log(post.title, post.author.name);
}
With Rails Active Record, you can use includes:
posts = Post.includes(:author).limit(100)
posts.each do |post|
puts "#{post.title} - #{post.author.name}"
end
With Django, you can use select_related for foreign keys:
posts = Post.objects.select_related("author")[:100]
for post in posts:
print(post.title, post.author.name)
With Laravel Eloquent, you can use with:
$posts = Post::with('author')->limit(100)->get();
foreach ($posts as $post) {
echo "{$post->title} - {$post->author->name}";
}
The syntax differs, but the idea is the same: tell the ORM that you will need the related records before entering the loop.
Sometimes a join is not the best fit. For example, you may want to fetch posts first, then fetch all needed authors in one second query.
const posts = await db.post.findMany();
const authorIds = [...new Set(posts.map((post) => post.authorId))];
const authors = await db.user.findMany({
where: {
id: { in: authorIds },
},
});
const authorById = new Map(authors.map((author) => [author.id, author]));
for (const post of posts) {
const author = authorById.get(post.authorId);
console.log(post.title, author?.name);
}
This uses two queries:
1 query -> fetch posts
1 query -> fetch all authors used by those posts
2 queries total
This pattern is common in GraphQL resolvers, API composition layers, and service-to-service calls.
GraphQL makes N+1 easy to create because each field resolver can load its own data.
Problematic resolver:
const resolvers = {
Post: {
author: async (post, _args, context) => {
return context.db.user.findUnique({
where: { id: post.authorId },
});
},
},
};
If the query asks for 100 posts and each post's author, the author resolver may run 100 separate queries.
A common fix is DataLoader:
import DataLoader from "dataloader";
function createAuthorLoader(db) {
return new DataLoader(async (authorIds: readonly string[]) => {
const authors = await db.user.findMany({
where: {
id: { in: [...authorIds] },
},
});
const authorById = new Map(authors.map((author) => [author.id, author]));
return authorIds.map((id) => authorById.get(id) ?? null);
});
}
const resolvers = {
Post: {
author: (post, _args, context) => {
return context.loaders.author.load(post.authorId);
},
},
};
DataLoader batches many small loads into fewer larger loads.
You usually find N+1 issues by looking at query logs, tracing, or performance profiles.
Watch for these signals:
| Signal | What it often means |
|---|---|
| The same SQL appears repeatedly with different IDs | Related records are being loaded one by one |
| A page gets slower as rows increase | Query count grows with result size |
| One API request makes many DB calls | Work is happening inside a loop or resolver |
| GraphQL field resolvers call the DB directly | Batch loading may be missing |
In code review, this pattern is worth checking carefully:
const items = await loadItems();
for (const item of items) {
await loadSomethingRelated(item.id);
}
This is not always wrong, but it should make you ask: can this be joined, included, or batched?
The fix is not always "join everything."
A huge join can duplicate data, return too many columns, or make pagination harder. Sometimes two focused queries are better than one complicated query.
A practical rule:
| Situation | Usually better option |
|---|---|
| Simple one-to-one or many-to-one relationship | Join or eager loading |
| Many related rows per parent | Preload, batch, or paginate |
| GraphQL field resolution | DataLoader-style batching |
| Repeated access to stable data | Cache after fixing the query shape |
N+1 is hard to prevent by discipline alone. The most reliable approach is to make query count visible.
With Prisma, you can enable query logging in development.
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient({
log: ["query", "warn", "error"],
});
If opening one list page prints many similar SELECT ... WHERE id = ... queries, suspect N+1.
Rails logs SQL in development. In real projects, the bullet gem is commonly used to detect N+1 queries.
In Django, Django Debug Toolbar can show the SQL list and query count for each request.
For important list pages and APIs, you can test that query count stays controlled.
Django example:
def test_post_list_does_not_have_n_plus_one(self):
with self.assertNumQueries(2):
posts = list(Post.objects.select_related("author")[:100])
for post in posts:
_ = post.author.name
Rails projects can also test query count depending on the test setup.
assert_queries(2) do
posts = Post.includes(:author).limit(100).to_a
posts.each { |post| post.author.name }
end
The goal is that query count should not grow much when the list grows from 10 rows to 100 rows.
When you see code like this, check for N+1:
for (const item of items) {
await db.someModel.findUnique({
where: { id: item.someId },
});
}
Ask if it can be replaced with one of these:
include, with, includes, or select_related.WHERE id IN (...).Before shipping a list page or API endpoint, ask:
include, preload, select_related, or prefetch_related?WHERE id IN (...) query?The N+1 problem is simple:
Fetch a list once, then accidentally fetch related data one item at a time.
It matters because it turns one request into many database round trips. The fix is usually not complicated: eager load, join, batch, or paginate.
The key engineering habit is to count the queries, not just read the code. If the number of database calls grows with the number of rows, you may have an N+1 problem.