HomeProjectsBlogContact

The N+1 Query Problem: How to Spot It and Fix It for Good

Your page loads 20 posts and quietly runs 21 database queries. The N+1 problem is the most common ORM performance bug. Here is how to recognise it and fix it in any stack.

The N+1 Query Problem: How to Spot It and Fix It for Good

Here's a performance bug that hides perfectly in development and shows up the moment real data arrives. Your page lists 20 blog posts with their authors. Locally, with five rows in the database, it's instant. In production, with thousands of rows and a database a few milliseconds away, it crawls.

The cause, very often, is the N+1 query problem.

What N+1 actually means

You run 1 query to get a list of things, then N more queries, one for each item, to fetch related data.

const posts = await Post.findAll({ limit: 20 })   // 1 query

for (const post of posts) {
  post.author = await User.findByPk(post.authorId) // 20 more queries
}

That's 21 queries where 1 or 2 would do. Each query has network and parsing overhead, so 21 small queries are much slower than one slightly bigger one. Add comments, tags and likes and you can easily hit hundreds of queries for a single page.

ORMs make this easy to do by accident, because lazy-loaded relationships look like ordinary property access:

for post in Post.objects.all()[:20]:
    print(post.author.name)   # a query hides behind this dot

How to spot it

  • Turn on query logging in development. Most ORMs can print every SQL statement. If you see the same query shape repeated with different IDs, that's N+1.
  • Use your framework's debug tools. Laravel Debugbar, Django Debug Toolbar and the Rails development logs all show query counts per request.
  • Watch the query count, not just the time. Locally, 200 queries might take 40ms. Over a real network, that becomes seconds.
  • Look at APM traces in production. A request with a staircase of identical tiny database calls is the classic signature.

Fix 1: eager loading

Tell the ORM upfront which relationships you need, and it fetches them in one or two queries.

// Sequelize
Post.findAll({ include: [User], limit: 20 })

// Prisma
prisma.post.findMany({ take: 20, include: { author: true } })
// Laravel
Post::with('author')->take(20)->get();
# Django
Post.objects.select_related('author')[:20]        # foreign key, via JOIN
Post.objects.prefetch_related('tags')[:20]        # many-to-many, via second query
# Rails
Post.includes(:author).limit(20)

Same idea everywhere, different spelling.

Fix 2: batch it yourself

Without an ORM, or with complex logic, collect the IDs and fetch them in one go:

const posts = await db.query('SELECT * FROM posts ORDER BY id DESC LIMIT 20')
const ids = [...new Set(posts.map(p => p.author_id))]
const authors = await db.query('SELECT * FROM users WHERE id = ANY($1)', [ids])
const byId = new Map(authors.map(a => [a.id, a]))
posts.forEach(p => { p.author = byId.get(p.author_id) })

Two queries, no matter how many posts.

Fix 3: a JOIN

Sometimes the cleanest fix is plain SQL:

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.id DESC
LIMIT 20;

Great when you need a few columns from the related table. For one-to-many relations, joins can multiply rows, so a second query (like Fix 2) is often cleaner.

Fix 4: DataLoader for GraphQL

GraphQL resolvers run per field, which makes N+1 almost the default. The DataLoader pattern collects every ID requested during one tick and loads them in a single batch. Most GraphQL server ecosystems ship an implementation. Use it from the start.

Don't over-correct

Eager loading everything "just in case" creates the opposite problem: huge queries pulling data nobody displays. Load what the page actually uses. And make sure the foreign key columns you join on are indexed, otherwise even the fixed query is slow. In Postgres, foreign keys are not indexed automatically (why indexing matters).

Make it hard to reintroduce

  • Some ORMs can be configured to throw on lazy loading in development. Laravel's Model::preventLazyLoading() is a good example.
  • Add a test that asserts the query count for important endpoints.
  • Review new list endpoints with query logging on. It takes a minute.

If you're chasing general slowness rather than this specific bug, start with why is my website slow?

FAQ

Is the N+1 problem only an ORM issue?

No. It happens any time code fetches related data inside a loop: raw SQL, REST calls to another service, even frontend code calling an API once per list item. ORMs just make it easier to miss.

Is eager loading always better?

Not always. It's better when you'll definitely use the related data. If you only need it occasionally, lazy loading the few cases is fine. The goal is a predictable, small number of queries per request.

How many queries per page is too many?

There's no magic number, but a list page should usually need a handful of queries, and that number shouldn't grow with the number of items displayed.

Keep reading

More from the workbench

All articles →