---
title: "N+1 Queries: What the ORM Is Actually Doing"
description: "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."
url: https://sqlpress.com/blog/n-plus-one-queries/
category: SQL Fundamentals
tags: ["orm", "query-optimization", "performance"]
author: "Elias Rowe"
published: 2026-08-30T00:00:00.000Z
measured: false
---

# N+1 Queries: What the ORM Is Actually Doing

## Key takeaways

- An N+1 pattern issues one query to fetch a collection and then one additional query per element, usually triggered by attribute access rather than by an explicit call.
- Each individual query is fast, so database-side monitoring shows nothing wrong; the cost is per statement, paid N times.
- The fix is to declare what you need before iterating, so the data is fetched in a bounded number of statements.

## Test environment

- System: PostgreSQL 18
- Workload: Illustrative (examples verified, nothing timed)
- Notes: The only engine-specific claim is the pg_stat_statements call-count diagnosis; the pattern itself is engine-neutral.

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

```python title="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.

## FAQ

### 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.

## Sources

- [PostgreSQL 18 documentation — pg_stat_statements](https://www.postgresql.org/docs/18/pgstatstatements.html) (docs) — Aggregates by normalized statement, which is how an N+1 becomes visible as a call count.
- [Django 5.2 documentation — Database access optimization](https://docs.djangoproject.com/en/5.2/topics/db/optimization/) (docs)
- [Ruby on Rails Guides — Active Record Query Interface](https://guides.rubyonrails.org/active_record_querying.html) (docs) — Defines eager_load, preload and includes, and the join-versus-second-query split between them.
- [Hibernate 6.6 — A Guide to Hibernate Query Language](https://docs.hibernate.org/orm/6.6/querylanguage/html_single/Hibernate_Query_Language.html) (docs) — join fetch overrides the laziness of an association; the guide names the n+1 selects problem directly.
- [Hibernate ORM 6.6 User Guide — Fetching](https://docs.hibernate.org/orm/6.6/userguide/html_single/Hibernate_User_Guide.html#fetching) (docs) — Batch fetching and @BatchSize, the alternative to a fetch join.
- [Entity Framework Core — Single vs. split queries](https://learn.microsoft.com/en-us/ef/core/querying/single-split-queries) (docs) — AsSplitQuery replaces the JOIN with one additional query per included collection navigation.
- [SQLAlchemy 2.0 — Relationship Loading Techniques](https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html) (docs) — joinedload attaches a JOIN to the SELECT; selectinload emits a second SELECT with an IN clause.
