12k
All articles

Understanding the N+1 Query Problem

N+1 query problem explained with Rails, Django, Hibernate, and Laravel fixes, plus eager loading vs join strategies and detection methods.

OpenReplay Team
OpenReplay Team
Understanding the N+1 Query Problem

The N+1 query problem occurs when an application runs one query to fetch a list of N records, then runs one additional query per record to load a related entity: N+1 queries where one or two would suffice.

Most developers meet it the same way. A page that felt instant against a seeded dev database crawls once it hits real data, and the query log turns out to be the same SELECT repeated four hundred times with a different id.

It is the most common performance bug in code that uses an object-relational mapper, and it shows up identically across Rails, Django, Hibernate, and Laravel because they all share the same default behavior: lazy loading. This article defines the pattern, shows it in four stacks, maps the exact fix for each framework, corrects a persistent misconception about eager fetching, and covers how to catch N+1 before it reaches production.

Key Takeaways

  • The N+1 query problem is one query to load N parent rows plus N follow-up queries to load a related record for each. The query count scales linearly with N.
  • It happens because most ORMs lazy-load associations by default, so accessing a relation inside a loop silently fires a query on every iteration.
  • There are two correct fixes, and both collapse N+1 to a constant query count: a single JOIN that loads parents and children together, or a second batched query using WHERE id IN (...).
  • Setting FetchType.EAGER in JPA does not fix N+1 under JPQL. It changes when the extra queries fire, not whether they are batched.
  • Development query logs only catch N+1 on code paths you happen to exercise; a production APM catches the ones your local dataset was too small to reveal.

What Is the N+1 Query Problem?

Take a posts/authors relationship. You run one query to load all posts, then loop over them and read post.author.name on each. That property access is a second query, repeated once per post. Ten posts produce eleven queries; a thousand posts produce a thousand and one, and the total response time grows linearly with the number of records.

The linear growth is what makes N+1 dangerous. An N+1 bug is usually invisible in development against five seeded rows and becomes an outage in production against five thousand. The endpoint that returned in 40 ms on your laptop returns in 4 seconds, or times out, once real data arrives.

Why Does N+1 Happen?

N+1 happens because most ORMs lazy-load associations by default: a related object is not fetched when you load the parent, but on first access. Eloquent relationships behave exactly like this. Reading one as a property fires the query at the moment of access rather than when the parent model was loaded, and eager loading is the opt-in alternative. The same holds for ActiveRecord proxies, Django’s related managers, and Hibernate’s lazy proxies.

Inside a loop, that lazy access is silent and per-iteration. Nothing in the source flags it: no N+1 keyword, no warning. That is exactly why it survives code review and only surfaces under load.

What It Looks Like in Code

The before/after is the same shape in every stack: a loop that touches a relation, rewritten to load that relation up front.

Rails (ActiveRecord):

# N+1: 1 query for books + 1 per book for the author
Book.limit(10).each { |book| puts book.author.last_name }

# Fixed: 2 queries total
Book.includes(:author).limit(10).each { |book| puts book.author.last_name }

Django ORM:

# N+1: 1 query for books + 1 per book for the author
for book in Book.objects.all():
    print(book.title, book.author.name)

# Fixed: one JOIN
for book in Book.objects.select_related("author"):
    print(book.title, book.author.name)

JPA / Hibernate (JPQL):

// N+1: findAll() loads transports, then one SELECT per driver on access
List<Transport> all = transportRepository.findAll();

// Fixed: a single fetch join
@Query("SELECT t FROM Transport t JOIN FETCH t.driver")
List<Transport> findAllWithDriver();

Raw SQL: replace the per-row lookup with one LEFT JOIN, using LEFT so parents with zero children are retained:

SELECT c.id, c.name, i.id AS item_id, i.name AS item_name
FROM categories c
LEFT JOIN items i ON i.category_id = c.id
ORDER BY c.name, i.name;

How to Fix the N+1 Query Problem

There are two correct ways to fix N+1, and both collapse it to a constant number of queries: a single JOIN that loads parents and children together, or a second batched query that fetches all related rows with one WHERE id IN (...). The JOIN uses one round trip but can duplicate parent rows (and Cartesian-explode across multiple collections); the batched query uses two round trips but returns no duplicate data. Every framework exposes both strategies under different names.

FrameworkJOIN strategy (one query)Batched-query strategy (WHERE id IN)
Railseager_load(:assoc)preload(:assoc)
Rails (auto)includes(:assoc) (Rails picks one)includes(:assoc)
Djangoselect_related("assoc")prefetch_related("assoc")
JPA/HibernateJOIN FETCH / @EntityGraph / QueryDSL fetchJoin()batch fetching (@BatchSize)
Laravelwith('assoc')
Raw SQLLEFT JOINsecond SELECT ... WHERE fk IN (...)

The two Django methods are the ones most often mixed up, so it is worth being exact about which does what. The Django QuerySet API reference draws the line by relationship arity: select_related builds a JOIN and pulls the related rows back in the same statement, which only works when a parent has at most one related record, so it covers ForeignKey and OneToOneField. prefetch_related issues its own query per relationship and stitches the results together in Python, which is what lets it handle ManyToManyField and reverse foreign keys.

Rails splits the same distinction across three methods. The Active Record Query Interface guide describes preload as firing one extra query per named association, and eager_load as pulling everything back through a single LEFT OUTER JOIN. includes sits between the two: the API documentation describes it as defaulting to a separate query per association and switching to a join only when the query’s conditions force one. In short: preload is always a separate query, eager_load is always a JOIN, and includes lets ActiveRecord choose.

In Laravel, with() is the canonical eager-loading fix, issuing one batched query for the relation. Laravel 12.8 added Model::automaticallyEagerLoadRelationships(), which auto-eager-loads any relation a collection accesses without an explicit with() call.

Why FetchType.EAGER Doesn’t Fix N+1

Setting FetchType.EAGER does not fix N+1 under JPQL. Eager fetching changes when the extra queries fire, not whether they are batched, so you still need JOIN FETCH or an @EntityGraph. This is the most common JPA misconception. The Hibernate ORM user guide spells it out: a JPQL query that leaves an eager association out of its fetch plan makes Hibernate run one follow-up select per eager association, which is N+1 by another name, and the guide’s own recommendation is to map associations lazily and pull them in eagerly query by query.

The general principle holds across ORMs: configuring a relation as eager on the mapping is a when decision, not a batching decision. A fetch join or entity graph is what actually loads the association in one statement, collapsing parent and children into a single round trip.

How to Detect N+1 Queries

Start by reading the SQL your ORM emits. The Rails development log prints every query; Django exposes counts through the django-debug-toolbar; Hibernate logs statements with spring.jpa.show-sql=true; Laravel surfaces them through Laravel Debugbar. Repeated, near-identical SELECTs differing only by an id are the signature.

Fail-fast tools turn N+1 into an error during development. The Bullet gem warns on unoptimized Rails associations (dev/test only), Python’s nplusone logs violations, and in Laravel, Model::preventLazyLoading() makes lazy access loud: with it switched on, a relation resolved after the fact raises LazyLoadingViolationException instead of quietly running another query. Gate it to non-production so a missed relation never crashes a live request.

The catch: development query logs only catch N+1 on the code paths you happen to exercise; a production APM catches the ones your local dataset was too small to reveal. Application performance monitors watch every query in every request and background job, flagging repeated patterns with the exact call site. That is coverage dev-only tools like Bullet and the debug toolbar cannot provide.

When is N+1 acceptable?

Not every N+1 needs a fix. When N is small and bounded, say a page that always renders exactly three items, the extra queries may be cheaper than the maintenance cost of a prefetch chain. When the related records are already served from a query or application cache, the “extra” queries may never reach the database. And occasionally an explicit loop with a comment reads more clearly than a nested eager-load. These are the exceptions; treat them as deliberate, documented choices, because N tends to grow over time even when you are sure it will not.

The pattern is one concept with per-framework spellings, so learn it once: spot the relation accessed inside a loop, pick a JOIN or a batched query, and wire up detection so the next N+1 fails on your machine instead of your users’.

FAQs

What is the difference between JOIN-based eager loading and batched-query eager loading?

A JOIN-based fix (eager_load in Rails, select_related in Django, JOIN FETCH in JPA, LEFT JOIN in raw SQL) loads parents and children in one query but can duplicate parent rows and cause a Cartesian explosion across multiple collections. A batched-query fix (preload in Rails, prefetch_related in Django, with() in Laravel) runs a second query using WHERE id IN (...), adding one round trip but returning no duplicate rows. Both collapse N+1 to a constant query count.

Does setting FetchType.EAGER fix the N+1 problem in Hibernate?

No. Under a JPQL query, FetchType.EAGER does not batch associated entities; Hibernate issues a secondary SELECT for every eager association it needs, which reproduces N+1. Eager fetching changes when the extra queries fire, not whether they are batched. To actually load an association in one statement you need JOIN FETCH, an @EntityGraph, or QueryDSL fetchJoin(). This behavior is unchanged through Hibernate 7.

Why do N+1 bugs pass code review and local testing but break in production?

N+1 is invisible in the source because lazy loading fires a query silently on relation access inside a loop, with no keyword or warning to flag it. Query count scales linearly with N, so five seeded rows produce a fast six queries in development while five thousand rows produce five thousand and one in production. Development query logs also only catch N+1 on code paths you happen to exercise, which is why a production APM catches the ones your local dataset was too small to reveal.

Which Django method should I use, select_related or prefetch_related?

Use select_related for ForeignKey and OneToOneField relationships; it performs a SQL JOIN and loads the related objects in the same query. Use prefetch_related for ManyToManyField and reverse foreign keys; it runs a separate lookup per relationship and joins the results in Python. Choosing the wrong one is the most common Django N+1 mistake: prefetch_related cannot be used for single-valued forward relations the way select_related is intended to be.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.