metadlabs
PerformanceGuide

The N+1 Query Problem: What It Is and How to Fix It

What the N+1 query problem is, how to spot it in production, and exact fixes for Django 6.1, Rails, Hibernate 7.4, Prisma and GraphQL DataLoader.

By Metad Labs team · Sep 25, 2026 · 6 min read

Key takeaways

  • An N+1 is one query to load a list, then one extra query per item in that list. It is invisible in development and crushing in production.
  • You spot it by query count, not query time: dozens or hundreds of near-identical queries per request.
  • The fix is always the same idea: load related data in one or two queries using eager loading, joins or batched IN lookups.
  • Add a guardrail, such as a per-request query budget in tests, so it does not come back.

The N+1 query problem is the most common cause of slow endpoints we find in application code. It is also one of the easiest to fix once you can see it. This guide explains what it is and how to find it, then shows the fix in the frameworks teams actually use.

What is the N+1 query problem?

Say you render a list of 50 orders with each customer's name. The obvious code looks like this.

python
orders = Order.objects.filter(status="open")[:50]   # 1 query
for order in orders:
    print(order.customer.name)                        # 1 query per order

That is one query for the orders plus 50 for the customers, so 51 in total. The "N" is the number of rows in the first result, and it grows with your data. The page that ran 11 queries last year runs 501 today.

Each individual query is fast, which is exactly why this hides so well. The cost is in the round trips. Every one of them pays network latency and a connection checkout, and the database has to parse and plan the query again, hundreds of times over.

Do the math. On your laptop, a query round trip to a local database takes maybe 0.1 ms, so 500 queries cost 50 ms and nobody notices. In production, app and database usually sit on different machines, often in different availability zones, and a round trip of 1 to 5 ms is normal. The same 500 queries now cost 0.5 to 2.5 seconds. Worse, each one holds a pooled connection that other requests are waiting for.

How to spot an N+1

Count the queries per request. How long each one takes will tell you very little.

The quickest check is your logs. Turn on SQL logging in development and load the page. A wall of identical SELECT ... WHERE id = ? lines is the signature.

In production, APM traces show the same pattern. Datadog and New Relic show every DB span in a request, as do Sentry and tools built on OpenTelemetry. An N+1 looks like a comb, with dozens of short, identical spans in a row. Sentry flags this automatically by default when it sees a run of similar sequential queries that together take more than 50 ms.

Your database keeps count too. In Postgres, pg_stat_statements shows the query with a very low mean time and an enormous call count. See how to find the queries burning CPU.

Most frameworks also have a tool that flags repeated queries. Django has Debug Toolbar and Rails has the Bullet gem, while Hibernate statistics and Prisma query logging do the same job for their stacks.

How to fix it, framework by framework

The fix is always the same idea. Fetch the related rows in one extra query (or a join) instead of one per row.

Django

python
# ForeignKey / OneToOne: join in the same query
orders = Order.objects.select_related("customer").filter(status="open")[:50]

# Reverse FK / ManyToMany: one extra query with WHERE ... IN (...)
orders = Order.objects.prefetch_related("items").filter(status="open")[:50]

Django 6.1 added fetch modes. They change what happens when you access a foreign key, one-to-one field or deferred field that you did not load.

python
from django.db import models

# First access loads the relation for every object in the queryset at once (2 queries, not N+1)
orders = Order.objects.filter(status="open").fetch_mode(models.FETCH_PEERS)

# Raise FieldFetchBlocked on any unplanned lazy load, which is great in tests
orders = Order.objects.filter(status="open").fetch_mode(models.FETCH_RAISE)

FETCH_PEERS is a strong default for list views where you cannot predict every relation a template touches. FETCH_RAISE turns silent N+1s into test failures. Collections still need prefetch_related, meaning reverse foreign keys and many-to-many.

Ruby on Rails

ruby
# Let Rails pick JOIN or a separate IN query
orders = Order.includes(:customer).where(status: "open").limit(50)

Enable strict_loading on models or associations and Rails raises an error whenever lazy loading happens. New N+1s then fail in tests.

Hibernate / JPA

java
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findOpenWithCustomer(@Param("status") String status);

You can also use an @EntityGraph, or set hibernate.default_batch_fetch_size so lazy collections load in batches instead of one by one.

One long-standing trap got fixed recently. Combining JOIN FETCH on a collection with pagination (setMaxResults) used to make Hibernate load every row and paginate in memory. Since Hibernate ORM 7.4, the limit is applied in the database on databases that support it. On an older version, fetch the page of parent IDs first and then fetch their collections with an IN query.

Prisma and other TypeScript ORMs

ts
const orders = await prisma.order.findMany({
  where: { status: "open" },
  take: 50,
  include: { customer: true },
});

GraphQL

GraphQL makes N+1s easy to write because every field resolver runs independently. DataLoader batches them.

ts
const customerLoader = new DataLoader(async (ids: readonly string[]) => {
  const rows = await db.customer.findMany({ where: { id: { in: [...ids] } } });
  const byId = new Map(rows.map((r) => [r.id, r]));
  return ids.map((id) => byId.get(id) ?? null);
});

// resolver
customer: (order) => customerLoader.load(order.customerId),

Create a new loader per request so caching does not leak between users.

Plain SQL

If you are writing queries by hand, replace the loop with a single IN or ANY lookup.

sql
SELECT id, name FROM customers WHERE id = ANY($1);  -- $1 is an array of customer ids

Joins or separate queries?

Both fix the N+1. Which one to use depends on the shape of the data.

SituationBetter choice
Many-to-one (order → customer)Join (select_related, JOIN FETCH)
One-to-many with small child listsEither; a join is fine
One-to-many with large child listsSeparate IN query (prefetch_related, includes) to avoid duplicating parent rows
Several collections on the same parentSeparate queries; joining multiple collections multiplies rows

Keep it from coming back

Fixing today's N+1 is easy. Stopping the next one takes a guardrail.

The simplest is a query budget in your tests. Assert the number of queries per endpoint, for example with assertNumQueries in Django, and a new N+1 fails CI.

Strict loading works at runtime. Rails has strict_loading (or strict_loading_by_default globally) and Django 6.1 has FETCH_RAISE, and for hot paths you can disable lazy loading in your ORM.

Then watch production. Alert on the ratio of DB calls to requests, because a sudden jump usually means a new N+1 shipped.

Frequently asked questions

What is the N+1 query problem in simple terms?

Your code runs one query to fetch a list of N records, then runs one more query for each record to fetch related data. Instead of 2 queries you run N+1. With 500 records that is 501 round trips to the database for a single page load.

Why doesn't the N+1 problem show up in development?

Development databases are small and local, so each extra query costs well under a millisecond. In production every query pays a network round trip, often 1 to 5 ms between app and database in the cloud, and there are far more rows. 500 queries at 2 ms each is a full second of pure waiting, before the database does any real work.

Is eager loading always better than lazy loading?

No. Eager loading helps when you will use the related data for most rows. If you only need it for one or two rows, or the related collection is huge, eager loading can fetch far more data than needed. Load what the page actually renders.

How do I fix N+1 queries in GraphQL?

Use a batching layer such as DataLoader. Each resolver asks the loader for a single ID, the loader collects all IDs requested in the same tick, and resolves them with one query using WHERE id IN (...).

Still slow after trying this?

We profile your app, API or database, find the real bottleneck, and fix it. Most audits pay for themselves in cloud savings alone.

Book a free 30-min callor run our free Postgres health check →

Newsletter

Get the next post in your inbox

Practical write-ups on fast systems, AI and automation from the engineers doing the work. About every two weeks.

No pitch. Unsubscribe from any email.

Keep reading