DevgainsDevgainsDevgains
All articles

N+1 Queries: The Silent Performance Killer

·6 min read·Updated Jun 30, 2026
N+1 Queries: The Silent Performance Killer

Photo: Unsplash

The N+1 query problem rarely shows up in code review. It hides behind a clean-looking loop, passes every test on a database with ten rows, and then quietly melts in production when that table has ten thousand. It is called silent because nothing throws an error. The page just gets slower as your data grows, and by the time anyone investigates, the cause is buried under a friendly ORM API.

If you have ever watched a database log scroll past with hundreds of nearly identical SELECT statements during a single page load, you have met N+1. It is one of the most common performance bugs in real applications, and it is almost always trivial to fix once you can see it. It is also the one performance problem no index can rescue — as the Postgres performance guide puts it, indexes fix one slow query while N+1 needs you to collapse many round trips into one.

What N+1 actually means

The name is the arithmetic. You run 1 query to fetch a list of parent records, then N more queries, one per parent, to fetch each parent's related child records. One list of authors, then one query per author to get their books. For 100 authors that is 1 + 100 = 101 round trips to the database.

# 1 query: fetch all authors
authors = Author.objects.all()
 
for author in authors:
    # N queries: one per author, fired lazily inside the loop
    print(author.name, author.books.count())

That author.books access looks like a harmless attribute. Under the hood, each iteration issues a fresh database query. The SQL log tells the real story:

-- The 1
SELECT id, name FROM author;
 
-- The N (repeated once per author, with a different id each time)
SELECT count(*) FROM book WHERE author_id = 1;
SELECT count(*) FROM book WHERE author_id = 2;
SELECT count(*) FROM book WHERE author_id = 3;
-- ... 97 more ...

Each of those queries is individually fast. That is exactly why N+1 is so deceptive. No single query is slow enough to flag. The damage is the round trips: every query carries network latency and per-statement overhead, and a hundred of them in series add up to a page that takes seconds instead of milliseconds.

Why ORMs hand you this bug by default

N+1 is not really an ORM defect; it is the natural consequence of lazy loading, which most ORMs do by default and for good reasons. Lazy loading means a related object is only fetched when you actually touch it. In isolation that is sensible: you do not want every query dragging in the entire object graph.

The trouble is that lazy loading inside a loop is invisible at the call site. The code reads like plain object navigation, author.books, with no syntactic hint that a database round trip is happening. The abstraction that makes the ORM pleasant to use is the same abstraction that hides the cost.

N+1 is an abstraction leak, not a typo. The fix is almost never "write more code." It is telling the ORM, up front, which relationships you intend to use so it can fetch them in one shot instead of lazily, one row at a time.

The cure is eager loading: tell the data layer in advance which related records you will need, and let it fetch them with a single additional query (or a join) instead of N lazy ones. Every mature ORM has this; only the spelling differs.

# Django: prefetch_related collapses the N queries into 2 total
authors = Author.objects.prefetch_related("books")
 
# Rails / ActiveRecord: includes does the same
# authors = Author.includes(:books)
 
# SQLAlchemy: selectinload
# authors = session.query(Author).options(selectinload(Author.books))

With eager loading, the same loop now generates just two queries regardless of how many authors there are:

SELECT id, name FROM author;
 
-- One query for ALL the children, using the collected parent ids
SELECT id, title, author_id
FROM book
WHERE author_id IN (1, 2, 3, /* ... */ 100);

Two queries instead of 101. The ORM stitches the results together in memory. This is the IN (...) pattern, and it is the most common eager-loading strategy. The alternative is a JOIN:

-- The join approach: one query returns parents and children together
SELECT a.id, a.name, b.id AS book_id, b.title
FROM author a
LEFT JOIN book b ON b.author_id = a.id;

Joins are great for one-to-one and many-to-one relationships. For one-to-many, the IN-based two-query approach often wins because a join multiplies parent columns across every child row, inflating the data on the wire. Most ORMs let you choose; knowing both lets you pick deliberately.

How to catch it before production

You cannot fix what you cannot see, and N+1 is specifically the bug that does not announce itself. Make it visible:

  • Log queries in development. Print the SQL count per request. A page that issues 200 queries is screaming N+1.
  • Assert on query counts in tests. Many frameworks let you assert "this endpoint runs at most N queries." Such a test fails the moment someone reintroduces lazy loading in a loop, turning a silent regression into a loud one.
  • Watch the count stay flat as data grows. A correct endpoint runs a constant number of queries whether there are 10 parents or 10,000. If query count scales with row count, you have N+1.

The EXPLAIN family helps you analyze the replacement query, but it will not reveal N+1 itself, because each individual query looks perfectly healthy. The diagnostic signal is the count of queries, not the cost of any one of them. For background on how the database executes the join or IN query you replace it with, the PostgreSQL documentation and the MySQL documentation both cover join processing in depth.

A note on going too far

Eager-load what you will actually use, and no more. Reflexively prefetching every relationship swings the pendulum the other way: you pull back data the page never renders, bloating memory and the result set. The goal is not "always eager." It is "fetch exactly the related data this code path needs, in as few round trips as possible." Profile, see what the page actually touches, and prefetch precisely that.

Takeaways

  • N+1 means 1 query for a list of parents plus N queries for their children, one per parent, usually triggered by lazy loading inside a loop.
  • It is silent because every individual query is fast; the cost is the sheer number of round trips, which grows with your data.
  • ORMs cause it by default through lazy loading, an abstraction that hides database access behind ordinary object navigation.
  • Fix it with eager loading (prefetch_related, includes, selectinload, or a join), collapsing N+1 queries into one or two.
  • Catch it by counting queries per request and asserting that count stays constant as data grows, not by reading individual query plans.
6 min read

Read next