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.textor 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
| Technique | Idea | Examples |
|---|---|---|
| Eager loading with a join | Fetch parents and related rows in one query | Django select_related, JPA JOIN FETCH, Rails eager_load |
| Batch loading with IN | One extra query for all related rows: WHERE id IN (...) | Django prefetch_related, Rails preload/includes, Hibernate @BatchSize, EF Core Include |
| Request-scoped batching | Collect keys during a request and resolve them together | DataLoader pattern in GraphQL servers |
| Purpose-built query | Select only the needed columns with a join or view | Hand-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.