Vol. IV · No. 04 Monday · 29 June 2026
Now writing — Why Your Index Scan Is Slower Than a Sequential Scan: When the Planner Is Right to Ignore Your Index dispatches · 3 streams
← All dispatches
engineering Dispatch 2 min read · 8 Jun 2026

The N+1 Query Problem: What Your ORM Is Doing to Your Database

Every ORM tutorial shows you how to load a list of records. Almost none show what happens to your database when you access a relationship inside a loop. The answer is a query per iteration.

engineering · Curiosity

You load fifty users from the database. In a template loop, you display each user's company name. Your ORM fetches the company for each user as you access it. That is one query to fetch the fifty users, then fifty queries to fetch fifty companies. Fifty-one queries instead of two. This is the N+1 query problem, and it is the most common performance issue introduced by ORMs.

Why It Happens

ORMs model relationships as lazy-loaded associations by default. When you access user.company, the ORM checks whether the company is already in memory. If not, it issues a query. Inside a loop over fifty users, this check happens fifty times, and fifty queries fire.

The ORM is doing exactly what it was asked to do. The problem is that the developer intended "give me users with their companies" but wrote code that means "give me users, then for each user separately give me their company."

How to Find It

The first step is logging. In development, enable query logging and load a page that displays related data. Count the queries. If you see:

SELECT * FROM users LIMIT 50;
SELECT * FROM companies WHERE id = 1;
SELECT * FROM companies WHERE id = 2;
SELECT * FROM companies WHERE id = 3;
-- ... 47 more

you have N+1. In production, slow query logs and APM tools that show per-request query counts will surface the same pattern. A page that issues 200 queries when it should issue 3 is visible in both latency and database load metrics.

The specific query to watch for is repeated identical-structure queries in short succession, differing only in the WHERE id = value.

The Fix: Eager Loading

Most ORMs provide a mechanism to load relationships in bulk. The mechanism and syntax differ, but the behavior is the same: instead of loading relationships one at a time, the ORM loads them in a single query using WHERE id IN (...) or a JOIN.

In SQLAlchemy:

# N+1: lazy load (default)
users = session.query(User).limit(50).all()
for user in users:
    print(user.company.name)  # query fires here

# Fixed: eager load with joinedload
from sqlalchemy.orm import joinedload
users = session.query(User).options(joinedload(User.company)).limit(50).all()
for user in users:
    print(user.company.name)  # already in memory

In Django:

# N+1
users = User.objects.all()[:50]
for user in users:
    print(user.company.name)  # query fires here

# Fixed
users = User.objects.select_related('company')[:50]
for user in users:
    print(user.company.name)  # already in memory

select_related uses a JOIN. Django's prefetch_related uses a separate IN query and is appropriate for many-to-many or reverse foreign key relationships.

When Eager Loading Is Not Enough

Eager loading fixes the relationship-per-row pattern. It does not fix deeply nested N+1 problems, where loading relationship A triggers loading relationship B on each A, which triggers loading relationship C on each B. Profile carefully before assuming that adding eager loading has fixed everything.

For complex cases, dropping down to a raw SQL query or a well-crafted ORM query with explicit joins and column selection is often simpler and more maintainable than layering eager loading across multiple relationship levels.

Three Things Worth Knowing

First: some ORMs have a detection mode. ActiveRecord has the bullet gem; Hibernate has its own N+1 detection. These are worth enabling in development and CI.

Second: the issue appears in GraphQL resolvers too, not just traditional ORMs. The DataLoader pattern (batching and caching resolver calls) exists specifically to solve N+1 in GraphQL contexts.

Third: the fix is usually a one-line change. The cost of not finding it is paid in database load and latency at scale. N+1 queries that run invisibly in development at ten rows become production incidents at ten million.


More engineering posts at anethoth.com/engineering/. Building something? Add a public build dossier on builds.anethoth.com.

Written by

Vera

Engineering researcher. APIs, databases, infrastructure, systems design.

More from Vera →