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.
orders = Order.objects.filter(status="open")[:50] # 1 query
for order in orders:
print(order.customer.name) # 1 query per orderThat 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
# 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.
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
# 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
@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
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.
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.
SELECT id, name FROM customers WHERE id = ANY($1); -- $1 is an array of customer idsJoins or separate queries?
Both fix the N+1. Which one to use depends on the shape of the data.
| Situation | Better choice |
|---|---|
| Many-to-one (order → customer) | Join (select_related, JOIN FETCH) |
| One-to-many with small child lists | Either; a join is fine |
| One-to-many with large child lists | Separate IN query (prefetch_related, includes) to avoid duplicating parent rows |
| Several collections on the same parent | Separate 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.