Key takeaways
- An N 1 query runs one query for a parent list plus one per row, so query count grows linearly with result size.
- The fix shape is identical across ORMs: Rails uses includes, Hibernate uses JOIN FETCH, Django uses select_related or prefetch_related, Prisma uses include.
- Detection works best with Sentry performance traces in production plus query-count assertions in CI.
- Eager loading is wrong when you touch few of the association or need aggregates, and JOIN-based loads of two collections can produce cartesian products.
- Database indexes make each query faster but do not reduce the round trip count, so they never fix an N 1 query.
An n 1 query happens when your code issues one query to fetch a list, then one more query per row to fetch a related record, so 100 posts become 101 queries. The failure, the detection signal, and the fix follow the same pattern in ActiveRecord, Hibernate, Django ORM, and Prisma, and this guide walks all four side by side.
What is an N+1 query and what does N+1 actually mean?
An N+1 query is a database access pattern where your application runs one query to fetch a list of parent rows, then runs one additional query per parent to fetch its children. Fetch 50 orders and their line items, and you issue 51 queries instead of 2.
The N is the parent count, so the cost grows with your data. A page that feels fast with 10 records falls over at 10,000, because query count scales linearly with result size while a single joined query does not. This is why N+1s often pass code review and staging, then surface in production as slow endpoints and stack traces full of repeated query frames.
Query-span tools like Sentry's performance monitoring make the pattern visible by grouping those repeated spans into a single detectable issue.
What does an N+1 query look like in real code?
Here's the classic Rails version: you load a list of orders, then loop through them to print each customer's name.
<% Order.all.each do |order| %>
<p><%= order.customer.name %></p>
<% end %>
Order.all runs one query. Then order.customer.name fires a separate SELECT * FROM customers WHERE id = ? for every single order. 100 orders means 101 queries.
The log makes it obvious:
Order Load (0.4ms) SELECT "orders".* FROM "orders"
Customer Load (0.2ms) SELECT "customers".* FROM "customers" WHERE "customers"."id" = 1
Customer Load (0.2ms) SELECT "customers".* FROM "customers" WHERE "customers"."id" = 2
Customer Load (0.2ms) SELECT "customers".* FROM "customers" WHERE "customers"."id" = 3
...
Each query is fast, so nothing times out locally. In production, the latency stacks up per row, and the pattern shows up in Sentry as a slow transaction rather than an error. The root cause is always the same: a loop body touching an association that wasn't loaded up front.
The same N+1 in four ORMs: Rails, Hibernate, Django, Prisma
The failure is identical in every ORM: you loop over a list of records and touch a relation inside the loop, and the ORM fires one query per record. Four stacks, four syntaxes, one bug.
Rails (ActiveRecord)
Order.all.each { |o| o.customer.name }
Each iteration loads customer lazily, so 200 orders means 201 queries. The fix is Order.includes(:customer), and Rails 6.1+ lets you make the lazy path a hard error with strict_loading. The ActiveRecord eager loading guide covers both.
Java (Hibernate)
orders.forEach(o -> o.getCustomer().getName());
With default FetchType.LAZY on the @ManyToOne, Hibernate issues a separate SELECT per order. JOIN FETCH in JPQL or an @EntityGraph collapses it into one query.
Python (Django ORM)
for o in orders: print(o.customer.name)
Django's QuerySet is lazy, so the loop triggers one query per order. Order.objects.select_related("customer") resolves it with a single join.
TypeScript (Prisma)
for (const o of orders) console.log(o.customer.name);
If orders came from prisma.order.findMany() without include, each o.customer is a separate round trip. include: { customer: true } fixes it.
| ORM | Lazy trigger | Fix |
|---|---|---|
| ActiveRecord | each over relation | includes(:customer) |
| Hibernate | lazy @ManyToOne access | JOIN FETCH / @EntityGraph |
| Django ORM | attribute access in loop | select_related("customer") |
| Prisma | relation access without include | include: { customer: true } |
The stack trace differs; the query log pattern is always 1 + N.
How do you detect an N+1 query in production?
Three signals catch an n 1 query before users feel it: Sentry performance traces, ORM-level logging, and query-count assertions running in CI.
Sentry spans and stack traces
Open a slow Sentry issue and look at the performance trace. An N+1 shows up as one parent span (a controller action or resolver) followed by dozens of nearly identical child spans, one per row, each a fraction of the parent's duration. The stack trace on any child span points at the exact line that triggered the loop, usually an association access inside a template or serializer. Sort spans by count rather than duration; a query taking 4ms is invisible until it runs 200 times.
Query-count assertions in CI
Sentry tells you about regressions after deploy. Query-count assertions catch them before. Wrap your request specs or integration tests with a counter that fails when a single request issues more than a set number of SQL queries, say ten. Rails has assert_queries, Django exposes CaptureQueriesContext, and Prisma logs query events you can count in a test harness. When a Dependabot PR or a refactor pushes a view from 3 queries to 40, CI goes red instead of your p95 latency.
The honest gap: counts are brittle. A legitimate dashboard endpoint may exceed a low threshold, so teams either raise it until it means nothing or exclude specific routes. Start with a generous cap on your highest-traffic endpoints and tighten from there.
How do you fix an N+1 query in Hibernate, Rails, Django, and Prisma?
The fix is the same shape everywhere: tell the ORM to fetch the relation in the same query, or in one batched query, instead of lazily per row. What changes is the keyword.
In Rails, swap Post.all for Post.includes(:comments). ActiveRecord issues one query for posts and one batched WHERE id IN (...) query for comments, so 101 queries become 2. In Hibernate, the equivalent is JOIN FETCH in JPQL: SELECT p FROM Post p JOIN FETCH p.comments. Django splits the job in two: select_related uses a SQL join for foreign keys, while prefetch_related does the batched second query for reverse relations and many-to-many fields. Prisma uses include: { comments: true } in the query payload.
The distinction worth holding onto is join versus batch. A join fetches parent and child rows in one round trip but can multiply rows, so paginating a joined collection in Hibernate or select_related risks pulling back far more data than you intended. A batched second query keeps each query small and predictable, at the cost of one extra round trip. Hibernate documents this trade-off directly in its fetch strategies guidance: descriptive anchor. Rails' includes and Django's prefetch_related default to the batched form for exactly this reason. Pick joins for to-one relations, batches for to-many collections.
When is eager loading the wrong fix for N+1?
Eager loading fixes the query count but can make each query worse. If a list view only shows three columns from a related table, includes pulls every column of every row, turning one fast query into one slow, wide one. When the association is rarely touched, lazy loading the few records that actually need it beats preloading all of them.
The harder failure is the cartesian product. Eager-load two collections with includes in ActiveRecord and Rails splits them into separate queries, but a single JOIN-based eager load (Hibernate's default with join fetch, or select_related plus prefetch_related confusion in Django) can multiply rows: 100 posts × 20 comments = 2,000 rows shipped and hydrated. Multiple prefetch_related calls in Django or includes in Rails avoid this; a JOIN across two has_many associations does not.
Batching is the middle path. If you need aggregates, a grouped query (Comment.group(:post_id).count) or a window function beats loading every child row. If you need only existence, EXISTS beats both.
The honest rule: eager loading is right when you will touch most of the association, wrong when you touch little of it or need an aggregate. Check the row count and payload, not just the query count, before you call a fix green.
How do N+1 queries differ from 1+N and deep N+1 variants?
The classic N+1 is one query for the parent list plus one per child row. 1+N is the same shape seen from the other side: a single parent fetch, then N child fetches triggered lazily as your template or serializer iterates. Detection tools label them differently because of where the query volume originates: strict per-request counters flag the parent fetch, while query-log analysis flags the repetition.
Deep N+1 nests the pattern: children fetch their own children, so a two-level association can produce N×M queries. Django's docs on query optimization describe this directly and recommend select_related for one-to-one and foreign-key paths and prefetch_related for reverse or many-to-many relations, which collapses the second level into a fixed query count.
What tools and logging reveal N+1 queries before users feel them?
Each ORM has a guard you can turn on today. Rails ships bullet and strict_loading, which raise or log the moment a lazy association loads. Django has django-debug-toolbar for local work and nplusone for test suites. Prisma exposes query logging so you can count SELECTs per request. Sentry's performance module flags N+1 patterns in production spans, so the stack trace and the culprit endpoint arrive together.
CI-level guards catch what code review misses: run a test that asserts query count per endpoint, and a new lazy association fails the build before it merges.
Where automated fixing falls short: tools detect reliably, but generated eager-loading fixes can be wrong when the association is conditionally used, and an LLM can churn round by round on a pseudo-fix. Verify the regression test yourself, or have someone stay on the pull request until checks are green.
FAQ
Is an N 1 query always a performance problem, or only at scale?
Only at scale. With 10 rows you issue 11 fast queries and nobody notices. The problem is that query count grows linearly with result size, so the same code that passes staging at 10 records collapses at 10,000. That is why N 1 queries typically surface in production as slow endpoints rather than in code review.
Does adding a database index fix an N 1 query?
No. Indexes make each individual query faster, but you still pay the round trip count: 101 queries stay 101 queries. The fix is structural, meaning eager loading or batching so the ORM fetches the relation in one query or one batched WHERE id IN (...) instead of once per row.
Can an N 1 query cause timeouts or errors in Sentry, or only slowness?
Usually slowness first. Each query is fast, so nothing times out locally, and the pattern shows up in Sentry as a slow transaction with repeated query spans rather than an exception. At sufficient scale, the accumulated latency stacks up per row and can push requests past timeouts, turning a performance issue into an error.