The N+1 Query Problem and How to Fix It
In my experience, most performance problems announce themselves loudly and proudly. Something is visibly heavy, or obviously badly written, and you know precisely where to look.
This case is not quite the same. As I found out recently, the issue can be buried in code that appears perfectly clean and correct on the surface.
It is one of the most common performance problems in application development, and one of the easiest to introduce without noticing.
It is known as the N+1 problem.
If you don’t know what it is, I’ll explain why it happens, what it actually costs you, and how to spot it before it quietly slows everything down.
The feature that introduced the problem
The feature I was working on was quite ordinary: a search box for a website.
A user types a word into the search box, and the page returns any content that seems relevant to that word. In this system, relevance was not based only on the title or body text. Content could also be connected to tags, and those tags were searchable too. So if a user searched for a tag name, the feature needed to find the tags that matched that word, then return the content attached to those tags.

The logic I laid out was the sort almost anyone would write:
- Find every tag whose name matches the search word.
- For each of those tags, find the content that carries it.
- Return the combined list.
Translated into code, it roughly looked like this:
// First, find all tags whose names match the user's search term.
const tags = await db.findTagsMatching(searchWord);
// Create an array to collect every matching item.
const results = [];
// For each matching tag, fetch the items connected to that tag.
for (const tag of tags) {
const items = await db.findItemsWithTag(tag.id);
// Add those items to the final result set.
results.push(...items);
}
And it worked! When testing locally, the search results were correct.
I created the PR. That was when I realised it contained the textbook N+1 problem.
What made it more interesting is that it was not flagged by a human reviewer. It was Claude Code Review that pointed it out. That is worth mentioning because this is exactly the kind of issue that can slip through human review, including my own!
What "N+1" actually means
The name is a description of the query count, and once you see it you can't unsee it.
Look again at the code above. There is one query to fetch the list of tags.
const tags = await db.findTagsMatching(searchWord);
Then there is a loop, and inside that loop is another query, one per tag.
for (const tag of tags) {
const items = await db.findItemsWithTag(tag.id);
}
If the search matches three tags, that is one query plus three more, which makes four in total. If it matches fifty tags, that is one query plus fifty more, which makes fifty-one.
That is the pattern: one query to fetch a list, then N more queries, one for each item in that list. One plus N. N+1.
The defining characteristic is that the query count is not fixed. It is a function of the data. You did not write fifty queries; you wrote a few lines of code that generate however many queries the data happens to demand. The number lives in the database, not in the source code, which is exactly why it is so easy to miss when you read it.
Why one extra query is not "just one extra query"
To understand the consequences, you have to understand what a database query costs. The instinct to treat a query as cheap is at the root of the problem.

A query is a request that travels to another system.
Even in the best case, the application has to get access to a database connection, send the statement across a network socket, and wait for the database to receive it. The database then has to parse the query, plan it, execute it against storage, and serialise the result back over the wire.
On a healthy local setup, that might be a millisecond or two. Against a database on another host, which is the normal production arrangement, it is frequently several milliseconds or more. Often the dominant cost is not the actual database work. It is the network round trip.
The problem is not any single call. The problem is placing those calls one after another. Each call pays the full round-trip cost before the next one begins. Fifty sequential queries do not just cost fifty times the work. When the work is trivial, they mostly cost fifty times the waiting.
To be clear, in this particular feature the problem was probably not catastrophic. A single search word was unlikely to match hundreds of service tags. In a small, curated taxonomy, it might only match two or three. But that is exactly why N+1 problems are easy to dismiss. The issue is not always that the code is currently slow. The issue is that the query count depends on the data, and data is quite unpredictable.
How to fix it
Every fix for an N+1 problem starts with the same change in perspective. The loop is asking the database a series of small, closely related questions:
Which items have tag A? Which items have tag B? Which items have tag C?
But that series is just one larger question wearing a disguise.
-> Which items have tag A, B, or C?

Databases are designed to answer this kind of set-based question efficiently. The fix is therefore to stop issuing a separate query inside the loop and instead pass the complete set of values to a single query.
In this example, findTagsMatching is an application-level database method. It might be a function in a repository, a method on a model, an ORM query, or a call through some other data-access layer. Its job is to find the tags whose names match the user's search word.
The exact name is not important. In my case, the method was called findTagsMatching. In another codebase, it might be called searchTags, findByName, filterTags, or something specific to the framework being used. When applying this pattern, look for the part of your own stack that performs the initial database lookup.
The first query remains unchanged:
const tags = await db.findTagsMatching(searchWord);
The important change concerns what happens next.
Previously, the code called a method such as findItemsWithTag once for every tag:
for (const tag of tags) {
const items = await db.findItemsWithTag(tag.id);
}
That method accepts a single tag ID, so using it inside the loop produces one database query per tag.
The revised version introduces a method that accepts all of the tag IDs at once:
const tags = await db.findTagsMatching(searchWord);
const tagIds = tags.map(tag => tag.id);
const results = await db.findItemsWithAnyTag(tagIds);
Here, findItemsWithAnyTag is another illustrative name rather than a special JavaScript or database function. It represents a database operation that takes a collection of tag IDs and returns every item associated with at least one of them.
The distinction between the two methods is their query shape:
findItemsWithTag(tagId)
accepts one value and answers one narrow question.
findItemsWithAnyTag(tagIds)
accepts a collection and answers the combined question in one operation.
This idea applies regardless of language. The collection might be an array in JavaScript, a list in Python or Java, a slice in Go, a vector in Rust, or some framework-specific collection type. The database method might be written using raw SQL, an ORM, a query builder, a repository pattern, or generated database code. What matters is not the syntax or method name. What matters is that the values are collected first and sent to the database together.
Now there are two queries. There are not two because the search happened to match two tags. There are two because the feature contains exactly two database questions:
- Which tags match the user's search word?
- Which items are attached to any of those matching tags?
The number of queries is now fixed. Whether the search matches three tags or fifty, the application still performs the same two database operations.
Under the hood, the exact implementation depends on the schema and the tools being used. A relational database might use an IN (...) condition, a join through a tag-association table, a subquery, or some combination of these. An ORM might expose a filter that accepts a collection of IDs. In GraphQL, a DataLoader might collect individual lookups and dispatch them as one batched request.
For example, the underlying query might conceptually resemble:
SELECT DISTINCT items.*
FROM items
JOIN item_tags ON item_tags.item_id = items.id
WHERE item_tags.tag_id IN (...);
The specific SQL is less important than the principle. Instead of repeatedly asking the database to process one tag at a time, the application gives it the complete set and allows it to perform the matching as one operation.
A database is generally very good at executing one well-formed query that returns a hundred rows. It is much less efficient to make it answer a hundred tiny, nearly identical queries that each return one row.
There can also be a direct financial cost. On managed databases, serverless databases, and other usage-based infrastructure, turning two queries into fifty can multiply connection usage, request counts, compute time, and billed operations. The N+1 pattern is not only a latency problem. Depending on the platform, it can also become an infrastructure-cost problem.
A PR comment I might write for this problem would look something like this:
This probably is not a major issue at the current data size, but the query shape is risky. We are performing one lookup for every matched tag, so the number of queries scales with the number of matches. Since the same result can be expressed as one combined query, could we batch the tag IDs and fetch the associated items in a single operation rather than leave a hidden scaling problem in the search path?
Beware of hidden queries
The obvious N+1 pattern is a query inside a loop. That version is easy to spot. In real applications, however, the query is often hidden behind an abstraction.
Lazy loading in ORMs is a common example. An object-relational mapper makes database rows feel like ordinary objects, which is both convenient and risky. You might loop over a list of orders and read order.customer.name for each one. Depending on the ORM and its configuration, accessing customer may trigger a separate query for every order.
The code looks like simple property access. The database sees a stream of repeated queries.
GraphQL can produce the same problem. A resolver might fetch a list of articles, then resolve the author of each article separately. Fetching one hundred articles can therefore result in one query for the articles and another hundred for their authors. This is why batching tools such as DataLoader are common in GraphQL applications.
The underlying pattern is always the same. You have a collection, and for each element you return to a data source to fetch related information. Once you recognise that shape, you can spot N+1 problems across languages, frameworks, ORMs, APIs, and other data-access layers.
How to detect it
N+1 problems are difficult to spot because the additional queries are often hidden behind ordinary-looking code. Detection starts by making the query count visible.
The simplest approach is to enable query logging in development. Most ORMs and database drivers can log every statement they execute. Load a page or perform an action, then inspect the output. A burst of nearly identical queries that differ only by an ID is a strong sign of an N+1.
Tracing and application performance monitoring tools can reveal the same pattern in production. They show how a request spends its time, including each database call. An N+1 often appears as a long sequence of small, similar query spans, especially when the queries run one after another.
For important code paths, you can also assert on query counts in tests. A test might verify that loading a page performs two or three queries regardless of how many records are returned. If a later change reintroduces a query per record, the test fails before the problem reaches production.
When an N+1 is acceptable
Not every N+1 is worth fixing.
If the collection is strictly bounded and will never contain more than a few items, the extra queries may cost less than the work required to remove them. Optimising a path that runs twice a day over three records is rarely the best use of engineering time.
There are two reasons to be cautious.
- Supposedly fixed limits often change. A list that is always small can become merely usually small, then unexpectedly large after a product change or increase in usage. Because an N+1 scales with the data, it can remain harmless for years and then become expensive without any corresponding change to the code.
- Batching is often clearer even when performance is not yet a concern. If the related data can be fetched in one straightforward operation, there may be little reason to preserve the repeated queries.
The practical rule is not to optimise every bounded loop reflexively. However, any database call inside a loop over user-controlled, variable, or unbounded data should be treated as suspicious until its behaviour has been measured and justified.
The underlying lesson
The mistake behind an N+1 is not really about loops, ORMs, GraphQL, or any particular framework. It comes from treating a database query as though it were as cheap as an ordinary line of code.
It is not.
Reading a value already held in memory is cheap. Asking another system a question over a network has connection, execution, and round-trip costs. Those costs become significant when they are multiplied by a number the application does not control.
The durable habit is to design database operations around the complete set of data you need, rather than around the loop you happen to be writing. When you are about to fetch something separately for every element in a collection, stop and ask whether those values can be collected and fetched together.
Often, they can.
The batched version is usually faster, places less pressure on the database, and may also be simpler to reason about. Correct code that becomes slower as the amount of data grows is a poor trade. N+1 problems are one of the most common ways to make that trade accidentally, and recognising their shape is usually the first step towards preventing them.