Est.

Detecting and Fixing N+1 Query Problems in Rails and Django ORMs

How lazy loading causes performance disasters and what tools catch them before production.

Editor at Large · · 11 min read
Cover illustration for “Detecting and Fixing N+1 Query Problems in Rails and Django ORMs”
SQL and Query Writing · September 27, 2026 · 11 min read · 2,459 words

N+1 queries are a side effect of a decision both Rails and Django made on purpose. They're a side effect of a decision both Rails and Django made on purpose, and once you see the decision, the whole problem stops feeling like a mystery. Both frameworks default to lazy loading: when you pull a batch of records, the ORM does not go fetch everything connected to them. It waits, only going back to the database when your code actually reaches out and touches a related field. It only goes back to the database when your code actually reaches out and touches a related field.

That looks like this in practice. Then your code loops through them, and the moment it asks for post.author.name, that single line fires off its own trip to the database. Total: N+1 round trips⟧c4⟧.

Why would any framework design it this way? Because the alternative is worse, most of the time. Picture eager loading everything by default: every time you pull a list of posts, the ORM also drags along the full author record, whether you need it or not. That's wasted work on every query where you never touch those fields.

The ORM has no way to know, at the moment it runs your first query, if your code is about to loop through every single row and touch the relationship, or just grab a few records and move on. So it guesses conservatively: wait until asked. That's a reasonable bet in isolation. When it is stacked inside a loop, though, the same bet gets made over and over, and the cost compounds fast.

This is a trade-off made once, at the framework level, that then has to get re-solved by hand, at the application level, on every single query that touches a relationship, by every developer who writes that code. The framework can't fix it for you. It was never going to.

Compounding problem: 2N+1, NM+N+1, and the hidden surfaces developers miss

N+1 is the simple case. It gets worse quickly, and it gets worse in ways that are easy to miss until you're staring at a query log with hundreds of lines in it.

Start with 2N+1. Say your loop touches two separate relationships on the same record, not just one. book.author.name triggers a query. So does book.author.country.name, on the very next line. Now every iteration of the loop costs two round trips instead of one, and your total query count roughly doubles.

Then there's nesting, which multiplies rather than adds. Loop over N books, and for each book loop over M authors, and for each author check a country field, and you're no longer looking at N+1, you're looking at something closer to N times M plus N plus 1. It doesn't take huge numbers for this to get out of hand. If either N or M climbs into the hundreds, the query count climbs right along with it.

What makes this genuinely hard to catch is that the loop causing the damage often isn't sitting in the controller where you'd naturally go looking for it. A few places hide in plain sight:

Serializers are a big one. You check the controller, it looks fine, and the actual damage is happening one layer down, where you weren't looking.

Templates do the same thing.

Background jobs are the surface people forget most often. Sidekiq, Resque, and GoodJob workers do not appear in browser-based profiling tools. N+1s in background jobs are a separate, often overlooked surface.

And GraphQL resolvers might be the trickiest of all. Each field resolver executes on its own, independently of the others, so a query that looks like one clean request from the client side can be triggering a separate lazy load for every single field, on every single object, without any one part of the code looking obviously wrong.

None of this is theoretical. Gold Lapel documented a real production system where a single list view was generating over 400 separate database round trips to render https://goldlapel.com/grounds/query-optimization/n-plus-one-queries. Nobody on the team knew. Users just experienced it as slow, and did what people do with slow pages: waited, or left. Serializers such as DRF, Active Model Serializers, JBuilder, and Blueprinter look clean in the controller but trigger lazy loads during serialization.

Why N+1 problems survive to production

N+1 bugs don't require carelessness. Careful developers ship them constantly, and the reason comes down to something almost boring, which is the size of the data sitting on a laptop during development.

With 5 or 10 test records, an N+1 pattern is genuinely invisible. The extra queries are firing, sure, but they're firing against a tiny local database, and the whole page loads in a blink either way. Nothing about the experience signals a problem. As one source put it, when N is small, things may still seem "fast," but when N=100 you both start to notice the slowdown. Production doesn't run on 10 records. It runs on hundreds or thousands, and that's exactly where the linear scaling of an N+1 pattern turns from harmless to punishing.

Tooling has a matching blind spot. Production traffic doesn't follow the same script your test suite follows. Users click into filters nobody tested, hit edge cases the test data never modeled, and load pages with record counts nobody bothered to simulate locally.

The cost of all this is concrete. That's not a rounding error.

That latency costs money even though the page eventually loads. It's money. In the Gold Lapel example, at 1.2ms, 201 queries for 200 orders produces 241ms of pure overhead before rendering begins, whereas that same operation should take roughly 3ms with a single JOIN.

Detecting N+1 queries during development in Rails

The cheapest possible first move costs nothing and requires no new dependency. Set config.active_record.verbose_query_logs = true in config/environments/development.rb, and Rails 5.2 and later will print the file and line number responsible for every single query it runs. No gem, no setup beyond one line.

Past that, Bullet is the standard tool most Rails developers reach for. It supports ActiveRecord 4.0 and up, and Mongoid 4.0 and up. And it's flexible about how it tells you: a JavaScript alert in the browser, an entry in the Rails logger, or a quiet annotation in the page footer, depending on how loud you want the warning to be.

Bullet's real limitation is that it only catches N+1s that the current request or test exercises.

That's where Prosopite earns its place as a second layer. If more than one query shares the same call stack and the same query fingerprint, that's a flag. That approach catches things Bullet structurally can't, including issues buried in scope_chain, polymorphic associations, and Single Table Inheritance setups. Some teams just run both, side by side, since they cover different blind spots and neither one is expensive to add.

Rounding out the toolkit, rack-mini-profiler gives you a request-level view, an overlay that shows slow requests and their query counts directly, which is useful for catching the pattern in context rather than line by line. It analyzes queries in real time, detecting N+1 queries, unused eager loading, and counter-cache opportunities. It works at the SQL level rather than the ORM level, monitoring all SQL via Active Support instrumentation.

Detecting N+1 queries during development in Django

Django's cheapest option mirrors Rails almost exactly. Turn on django.db.backends logging at the DEBUG level in your LOGGING settings dict, and every SQL statement Django runs prints straight to your console. Zero new packages, one settings change, and suddenly you can watch, in real time, what your view is asking the database to do.

For a proper visual layer, django-debug-toolbar is the standard pick. Its SQL panel lists every query a request generated, and the pattern you're hunting for jumps out fast: a long run of nearly identical `SELECT... The toolbar surfaces repeated similar queries, which is the fingerprint of an N+1, without any guesswork.

The nplusone library takes a more automated approach: it watches for the pattern directly and logs a warning the moment it spots one. It's built for development use, and it can be wired into a test suite too, but it's explicitly not something to run in production.

And for teams who'd rather close the gap without auditing every queryset by hand, django-auto-prefetch offers something closer to a default fix: it automatically prefetches ForeignKey values as they're needed, without requiring a developer to go add select_related calls one at a time across the codebase. It makes repeated SELECT....

Enforcing N+1 prevention in CI so regressions don't reach production

Catching an N+1 in development is good. Making sure it can never quietly reappear is better, and that's a CI problem, not a development-tooling problem.

For Rails, the single highest-value setup is Bullet configured to raise instead of just warn. In config/environments/test.rb, set Bullet.enable = true and Bullet.raise = true. From that point on, any N+1 that shows up during a test run doesn't get logged and ignored, it fails the build. For codebases that already have existing N+1s, Bullet supports an allowlist, so the rule can be enforced for new code while the backlog is worked down.

For assertion-level enforcement rather than blanket detection, rspec-sqlimit, built by Andrew Kozin (also known as nepalez), gives you an RSpec matcher that checks the exact number of SQL queries a block of code executes. That's a useful complement to Bullet: instead of catching any N+1 anywhere, it lets you pin an exact query budget to a specific piece of behavior and catch the moment that budget gets blown.

The n_plus_one_control gem takes a more rigorous approach than a fixed query count. It provides RSpec and Minitest matchers, but instead of asserting a fixed query count, it evaluates the code under test at multiple scale factors to confirm the query count is O(1), not O(N). That's a meaningfully stronger guarantee: a fixed-count assertion can pass today and still hide an N+1 that only becomes visible once your table has real volume. As of this writing, the gem is at version 0.8.0, released January 28, 2026, and has accumulated 3,030,919 downloads total https://rubygems.org/gems/n_plus_one_control.

On the Django side, the built-in tool is assertNumQueries(N), which counts the queries executed inside a block and fails the test if the count doesn't match. Expected counts shift depending on your Django version, your database backend, and whatever middleware sits in the request path, so a number that's correct on one stack won't necessarily hold on another. Calibrate it against your own setup rather than copying a number from a tutorial written against a different one.

Fixing N+1 queries in Rails: includes, preload, and eager_load compared

Every fix, in both frameworks, boils down to the same one idea: stop waiting for the relationship to get touched, and load it upfront instead. The words differ. The concept doesn't.

includes is the sensible default for most situations. Rails looks at the rest of your query and decides for you if a JOIN or a separate query makes more sense. Write Post.includes(:author) over a set of 100 posts, and instead of 101 queries, you get 2. One for the posts, one for all the authors at once. If you later add a where clause that filters on a column from the associated table, Rails notices and automatically switches its strategy to a JOIN, because a separate query wouldn't be able to filter on that data.

preload is more rigid, by design. It always runs a separate query, never a JOIN, no matter what else is in your code. That rigidity is exactly the point in many-to-many relationships, or any case where a JOIN would return duplicate rows and mess up your result count. If you've worked with Django's prefetch_related or SQLAlchemy's selectinload, this is the same idea under a different name: one clean, additional query.

eager_load sits at the other end. It always uses a LEFT OUTER JOIN, full stop, no context-based decision-making.

The practical rule of thumb: reach for includes first, since it adapts. Reach for eager_load when your query needs to filter or order by the associated table directly.

Diagram: 201 Queries vs. 1 JOIN: The Real Cost of N+1. Visualizes: Show a stark before/after magnitude contrast between two approaches to fetching 200 orders with their customer names.

Django splits the same idea across two methods, and which one applies depends entirely on the type of relationship you're dealing with.

select_related() uses a SQL JOIN, and it's the right tool for ForeignKey and OneToOneField relationships. It pulls the related object in the same query as the parent, so once you've called it, accessing book.author later on costs nothing extra, no additional trip to the database. The tradeoff is duplication: if a parent record has many children, that parent's data gets repeated across multiple rows in the raw result set. Django handles the reassembly back into Python objects for you, under the hood.

prefetch_related() takes the opposite approach and is the correct choice for ManyToManyField relationships and reverse ForeignKey lookups. One extra round trip, but a clean one.

The combined effect of using both correctly is well documented. One case study on listing 100 orders found the naive approach generating 302 separate database hits https://medium.com/@yashmarathe21/django-orm-mastering-performance-with-select-related-and-prefetch-related-48c0958fe8be. Applying select_related and prefetch_related together brought that down to 4, a 15x improvement in response time https://medium.com/@yashmarathe21/django-orm-mastering-performance-with-select-related-and-prefetch-related-48c0958fe8be.

There's a sharper edge case that can cost you an afternoon of debugging. The Prefetch() object supports a to_attr argument, which lets you store prefetched results under a custom attribute name instead of the default manager. What you get back is a plain Python list, not a QuerySet. Call .filter() on it expecting normal queryset behavior, and you'll get an AttributeError. This trips people up specifically when refactoring from a plain prefetch_related() call to a custom Prefetch() object: the change looks harmless on the surface, but it swaps the return type underneath.

Less commonly known but genuinely useful: prefetch_related_objects() lets you prefetch related data onto objects that are already loaded in memory, something the standard prefetching methods can't do since they only work at query time. That matters when your objects didn't come from a normal queryset in the first place, for instance, when they've been deserialized out of a cache rather than pulled fresh from the database. In that situation, there's no queryset to attach a prefetch to. This function fills that specific gap. The tradeoff is that it may return duplicate data when the parent has many children, for example Arthur Conan Doyle appearing once. Every 100ms delay in page load time reduces conversions by roughly 1 percent https://medium.com/@vasu_ghanta/solving-the-n-1-database-query-problem. There is pure overhead at 1.2ms per query for 201 queries to fetch 200 orders with their customer names, amounting to 241ms https://goldlapel.com/grounds/query-optimization/n-plus-one-queries. A single optimized query using JOIN instead of N+1 queries for the same result takes 3ms https://goldlapel.com/grounds/query-optimization/n-plus-one-queries. The per-query execution time assumption for a local database is 1.2ms https://goldlapel.com/grounds/query-optimization/n-plus-one-queries.

Sources

  1. The N+1 Query Problem: A Matter Requiring Immediate Attention | Gold Lapel
  2. medium.com
  3. evilmartians.com

More in SQL and Query Writing