Skip to content
SQLPRESS
SQL Fundamentals

N+1 Queries: What the ORM Is Actually Doing

One query for the list, then one more for every item in it. The cost is per statement, which is why the profiler shows a fast database and a slow request.

Elias Rowe3 min read
N+1 Queries: What the ORM Is Actually Doing — SQL Fundamentals article cover

An endpoint returns fifty orders with their customer names. The database reports fifty-one queries, each taking under a millisecond, and total database time of four milliseconds. The endpoint takes a quarter of a second.

Nothing in that picture is slow except the shape.

What happens

the-shape.py
orders = Order.objects.filter(status="pending") # 1 query
for order in orders:
print(order.customer.name) # 1 query, each iteration

The first statement fetches the orders. The attribute access inside the loop looks like reading a field on an object already in memory. It is not: the customer was never loaded, so the ORM issues a query for it, transparently, on each iteration.

Fifty orders, fifty-one queries. Hence N+1.

The reason it is easy to write is that nothing in the loop looks like a database call. The reason it is easy to miss in review is the same.

Why doesn’t the database show the N+1 as slow?

Each of those fifty queries is a primary key lookup. It is genuinely fast — tens of microseconds of execution. Averaged query duration looks excellent, and CPU on the database is unremarkable.

The cost is per statement, not per row: the round trip to the server, the parse and plan lookup, the protocol overhead, and the ORM’s own work rebuilding an object from each result set. Half a millisecond of round trip is negligible once and adds up to twenty-five milliseconds across fifty iterations. Put the rest of the per-statement work at a few milliseconds each and the fifty-one statements account for almost the whole quarter second — sixty times what the database was charged for.

Those round trips also hold the connection open for the whole request, so the pressure lands on the connection pool, where the queue forms, rather than on the database.

This is why the pattern survives so long in production systems. Every dashboard that measures the database says the database is healthy, and it is right.

How is an N+1 query found in production?

pg_stat_statements aggregates by normalized statement, so an N+1 appears as a single entry with a call count far out of proportion to the number of requests served. A statement called fifty thousand times an hour on a system serving a thousand requests an hour is the whole diagnosis.

On the application side, the reliable measurement is queries per request. Most frameworks can log it; some fail a test when the count exceeds a declared budget. Asserting a query count in a test is the only mechanism that keeps the fix in place, because the regression is invisible in every other signal.

The fix, in principle

Tell the ORM what you will need before you iterate. Every mature ORM has this, under different names:

Framework Declaration
Django select_related (join), prefetch_related (second query)
Rails eager_load (join), preload (second query), includes
Hibernate JOIN FETCH, or a batch size on the association
EF Core Include, with AsSplitQuery to control the shape
SQLAlchemy joinedload, selectinload

Each collapses N queries into one or two. Choosing between a join and a second query is a real decision with its own failure mode.

The case where N+1 is acceptable

If N is bounded and small — a page of ten items, each with one lookup that hits a warm cache — the pattern costs a few milliseconds and the clarity may be worth it. The problem is that N is rarely bounded by anything except the current data volume. The endpoint that was fine at ten rows behaves differently at ten thousand, and the code did not change.

Bounding the page size is a legitimate answer. Assuming the collection will stay small is not.

Frequently asked questions

What is an N+1 query problem?
An N+1 pattern issues one query to fetch a collection and then one additional query for every element in it. It is usually triggered by attribute access inside a loop rather than by an explicit call: the associated object was never loaded, so the ORM fetches it transparently on each iteration. Fifty orders read with their customer names produce fifty-one queries.
Why doesn't the database show an N+1 as slow?
Each of the extra queries is a primary key lookup taking tens of microseconds, so average query duration looks excellent and database CPU is unremarkable. The cost is per statement rather than per row: the round trip to the server, the parse and plan lookup, the protocol overhead, and the ORM's own work rebuilding an object from each result set. Half a millisecond of round trip is negligible once and adds up to twenty-five milliseconds across fifty iterations, with the rest of the per-statement work landing on top of it.
How do I find N+1 queries in PostgreSQL?
pg_stat_statements aggregates by normalized statement, so an N+1 appears as a single entry with a call count far out of proportion to the number of requests served. A statement called fifty thousand times an hour on a system serving a thousand requests an hour is the whole diagnosis. On the application side the equivalent measurement is queries per request.
Is an N+1 query ever acceptable?
When N is bounded and small, such as a page of ten items each with one cached lookup, the pattern costs a few milliseconds and the clarity can be worth it. The risk is that N is rarely bounded by anything except the current data volume, so an endpoint that was fine at ten rows behaves differently at ten thousand without the code changing. Bounding the page size is a legitimate answer; assuming the collection stays small is not.

References

  1. DocsPostgreSQL 18 documentation — pg_stat_statements

    Aggregates by normalized statement, which is how an N+1 becomes visible as a call count.

  2. DocsRuby on Rails Guides — Active Record Query Interface

    Defines eager_load, preload and includes, and the join-versus-second-query split between them.

  3. DocsHibernate 6.6 — A Guide to Hibernate Query Language

    join fetch overrides the laziness of an association; the guide names the n+1 selects problem directly.

  4. DocsHibernate ORM 6.6 User Guide — Fetching

    Batch fetching and @BatchSize, the alternative to a fetch join.

  5. DocsEntity Framework Core — Single vs. split queries

    AsSplitQuery replaces the JOIN with one additional query per included collection navigation.

  6. DocsSQLAlchemy 2.0 — Relationship Loading Techniques

    joinedload attaches a JOIN to the SELECT; selectinload emits a second SELECT with an IN clause.

Share

Written by

Elias RoweDatabase engineer

Elias Rowe writes about database engineering, SQL performance, and production systems. He focuses on measurable behavior, practical trade-offs, and conclusions that can be reproduced rather than assumed.

PostgreSQL3 min read

Why an Index Is Not Used: the Estimate, Not the Index

An index the planner ignores is usually a row-estimate problem. The fix starts with what the optimizer expected, not with the index definition.

  • #query-optimization
  • #indexing
  • #execution-plans
  • #statistics

Start typing to search the archive.