N+1 Problem
Concept
A screen slows to a crawl because the code fetches a list and then asks the database again once per item, multiplying the number of queries.
Why it exists
Talking to a database used to mean writing SQL by hand. Spelling out every request was tedious, but it had one property worth missing: the number of trips to the database was visible in the code. A hundred queries meant a hundred lines somebody had typed.
Object-relational mapping (ORM) made that part comfortable. An ORM connects objects in code to tables in the database, so instead of writing query text you read values as if they were already in memory. Write order.customer.name and the name appears. The catch is that this one expression quietly sends a query. Put it inside a loop over a list and the queries multiply with the list, while the code still shows three lines of a loop.
That's why this problem tends to look fine on a developer's laptop and break in production. With ten rows of test data, eleven queries finish in 30 milliseconds and nobody notices. The same code handed 3,000 real orders fires 3,001 queries and takes more than nine seconds to render. Not a character of the code changed — only the amount of data.
How it works
One query fetches the list, then N more queries follow, one per item, for N+1 round trips in total. The N and the 1 in the name point at exactly those two parts.
Laid out in order:
- Fetch the list. One query asks for this month's 100 orders. Nothing is wrong yet.
- Render those 100 rows, showing the customer's name next to each order. But the order records only carry a customer number; the names live in a separate customer table.
- So each row sends its own query — give me the name for customer 12, give me the name for customer 88 — 100 times for 100 rows.
- That comes to 101 queries.
Even at 3 milliseconds per query, 101 of them add up to 303 milliseconds. Add network time for a database running on another machine and a second goes by easily. The volume of data moved is tiny; the cost is in how many times the code speaks at all. It's the difference between carrying one box a hundred times and loading the whole lot onto one cart.
The fix is to fetch in bulk up front. Collect all 100 customer numbers first and ask once for every customer in that set of numbers. Queries drop from 101 to 2, and joining the two tables into a single statement gets it to 1. Every ORM has its own name for this feature, but they all do the same thing: telling the database now about the values that would otherwise be requested one at a time later. ORMs don't do this on their own because they can't tell which related values a screen will actually touch. Fetching everything attached to every row would waste far more than it saves on the pages that need none of it, so the decision is left to whoever writes the query.
Where it shows up
Anywhere many rows land on one screen. Order lists, post lists, user administration pages — anything that pulls a value from another table for each row. Nesting makes it worse. A hundred orders, five products under each order, and a seller looked up per product comes to more than 600 queries. Each level multiplies rather than adds, which is why a page that felt acceptable grows unusable after one more column is added to the table.
It's also common in GraphQL, where the client picks which fields to receive. The server can't know in advance how many levels deep a request will nest, so a single request for one screen can be translated into hundreds of queries inside the server.
Finding it follows a fixed routine. Turn on query logging in development and the log shows exactly how many queries one page render sent. A run of near-identical statements with only the numbers changing is the signature. In production, tools that track performance — application performance monitoring (APM) — report the query count per request, so opening the slowest screens usually lands on one of these. To confirm, change the size of the list: if 11 queries at 10 rows becomes 51 queries at 50 rows, that's the structure.
How it sounds at work
"This screen has an N+1. It renders 100 rows and sends 300 queries."
Redundant queries are firing repeatedly while a single screen is being drawn.
"This will turn into an N+1. Let's batch the fetch instead."
Flagging a spot in review before it has caused any trouble.
"It was fast locally and takes five seconds in production. Let's check for an N+1 first."
The problem only surfaces at volume, so start with the query count.
At Pagedeck, the query log is the first thing Theo opens when he touches an admin screen. When a report came in that the document list took more than three seconds, the log showed the same statement repeated once per document. It came from the column showing who last edited each file.
After the fix, queries dropped from the low 200s to 3 and the screen rendered in 0.2 seconds. The only change in the code was one line naming what to fetch alongside the list. Since then Theo has asked the team to include the query count in any review that touches a list screen.
What people get wrong
The most frequent mistake is expecting a database index to solve it. An index is the first tool most people learn for slow screens and it genuinely does speed up an individual query, so the leap is easy to make. But an index turns one query that scans a million rows into one that reads a handful, and the trouble here isn't the speed of one query — it's that a hundred of them go out. Indexing might take each one from 3 milliseconds to 1, turning 303 milliseconds into 101, but it never turns 100 trips into one. Chase the wrong cause and the screen stays slow while unused indexes pile up, each one adding work every time a row is written.
Leaving it alone because N is small is the other common call. With a 20-item list, 21 queries finish in 60 milliseconds and nothing looks broken. The structure grows with the data, though. When those 20 items become 2,000 a year later, the identical code spends six seconds. The product gets slower the better it does, and by the time the slowdown is visible, customers have already lived through it.
Where to go next
- Database
start one level up to see the area this problem lives in
- Database index
the other side of the performance branch, aimed at a different problem
Last updated 2026-09-16