Glossary · N

N+1 Query

A performance anti-pattern where code runs one query for a list and then one additional query for each item in it.

By Obseria Editorial TeamUpdated

Definition

An N+1 query is a database access anti-pattern in which an application runs one query to fetch a list of N records and then N further queries, one per record, to load related data. It usually stems from lazy loading in ORMs and turns a single round trip into hundreds, multiplying latency and database load.

What the N+1 query problem is

The N+1 query problem appears when code loads a collection and then accesses a relation of each element inside a loop. Consider an order page that lists 50 orders with their customer names. The application first runs one query for the orders, then, for each order, one query for its customer: 1 + 50 = 51 queries where one or two would have been enough.

SELECT * FROM orders WHERE created_at > now() - interval '1 day' LIMIT 50;
SELECT * FROM customers WHERE id = 101;
SELECT * FROM customers WHERE id = 102;
... 48 more ...

The pattern is almost always introduced by object-relational mappers (ORMs) such as Hibernate/JPA, Entity Framework, Django ORM, ActiveRecord or Sequelize, whose lazy loading fetches related objects on first access. The code looks innocent, for example order.customer.name in a template, and works perfectly with ten rows in a development database.

Why it matters

Each query is cheap on its own, often a millisecond or two, but every one adds a network round trip, connection pool usage and work on the database server. With illustrative numbers, 51 queries at 2 ms each add about 100 ms to a request that should take 10 ms, and the cost grows linearly with the page size. Under load, the extra queries consume connection pool slots and database CPU, so other requests slow down too. That makes N+1 a classic cause of latency that scales with data volume and a hidden limiter of throughput.

The problem often appears gradually: a list that used to show five items now shows 200 after a customer grows, and response times rise without any code change.

How to detect it

Distributed traces make N+1 queries easy to recognize. Under one request span you see a long ladder of database spans with the same normalized statement and different parameter values, executed sequentially. Useful signals:

  • The number of database spans per request, especially when it varies with the size of the result.
  • Repeated identical query shapes within one trace, identified by attributes such as db.query.text or a normalized query summary from the OpenTelemetry database semantic conventions.
  • A large share of request duration spent in many short database calls rather than in one slow one.
  • On the database side, very high call counts for a simple primary-key lookup in statistics such as pg_stat_statements.

Framework tools help during development, for example the Bullet gem for Rails or query-count assertions in tests that fail when a view exceeds a fixed number of queries.

How to fix it

TechniqueIdeaExamples
Eager loading with a joinFetch parents and related rows in one queryDjango select_related, JPA JOIN FETCH, Rails eager_load
Batch loading with INOne extra query for all related rows: WHERE id IN (...)Django prefetch_related, Rails preload/includes, Hibernate @BatchSize, EF Core Include
Request-scoped batchingCollect keys during a request and resolve them togetherDataLoader pattern in GraphQL servers
Purpose-built querySelect only the needed columns with a join or viewHand-written SQL, projections, read models

In the order example, a single join or one additional IN query reduces 51 round trips to one or two, and the cost no longer grows with the number of rows.

Best practices

  • Make loading strategy explicit for list views and APIs instead of relying on ORM defaults.
  • Assert query counts in integration tests for critical endpoints.
  • Paginate collections so that even an unnoticed N+1 has an upper bound.
  • Watch database spans per request on dashboards and alert on sudden increases after deployments.
  • Do not over-correct: eager loading everything can fetch far more data than needed and create large, slow joins.

Common mistakes

A frequent mistake is fixing N+1 in one place while the same relation is accessed lazily somewhere else, for example in a serializer or a template helper, so the query count barely changes. Another is profiling only with small test data, where 1 + 5 queries look harmless. Finally, teams sometimes run the N lookups in parallel instead of batching them. That shortens wall-clock time but keeps the load on the database and the connection pool, and under traffic it can make things worse.

How Obseria helps

Obseria distributed tracing shows every database call as a span with its normalized statement, so the ladder of repeated queries behind an N+1 pattern is visible at a glance. Database monitoring shows the same query from the database's perspective, and AI Code Insights links a regression to the pull request and the files that introduced it.

Frequently asked questions

Because the code issues one query to fetch N parent records and then N additional queries, one for each record, to load related data: N+1 queries in total.

No. Lazy loading is useful when related data is rarely accessed. It becomes a problem when it happens inside a loop over a collection. Choose the loading strategy per use case.

A cache can hide the database cost but not the pattern: the application still performs N lookups, now against the cache. Fixing the access pattern with eager or batch loading is usually simpler and more robust.

Yes. The same pattern appears with HTTP or gRPC calls, for example fetching a list from one service and then calling another service once per item. Batch endpoints or request-scoped batching solve it in the same way.

Put these concepts into practice

Try Obseria free for 14 days, with OpenTelemetry and no proprietary agents.