How to Identify and Eliminate N+1 Queries Before a Major Rails Upgrade
Before 1916, grocery shopping in the United States was entirely a clerk-service experience. A customer would hand a list of items to a clerk behind a counter. The clerk would then walk to the storeroom, retrieve the first item, walk back to the counter, and then repeat the process for the second item. If you had fifty items on your list, the clerk made fifty separate trips to the back room. This was an inefficient use of time and labor.
The solution came when Clarence Saunders opened the first Piggly Wiggly in Memphis, Tennessee. By providing shoppers with a basket and allowing them to gather all their items in a single trip through the store, he revolutionized retail logistics.
That historical inefficiency – the clerk making a separate trip for every single item – is exactly what happens inside a Ruby on Rails application when it suffers from an N+1 query problem. Instead of fetching all the necessary data in a single trip to the database, the application makes one trip to get a list of records, and then N separate trips to fetch the associated data for each one.1
When preparing for a major Ruby on Rails version bump, engineering teams typically focus on deprecation warnings and syntax changes. However, existing performance bottlenecks like N+1 queries represent a significant risk. If you are upgrading your framework, you are effectively moving your application to a new, more modern store. If you bring the inefficient clerk along with you, you will not see the full benefits of the move.
Why We Must Eliminate N+1 Queries Before Upgrading
There are three major reasons to address N+1 queries before attempting a major framework upgrade.
One is establishing a clean performance baseline. As you move between major Rails versions, underlying changes to Active Record or modifications to database connection pooling can amplify the impact of inefficient queries.2 An application that currently functions acceptably might experience severe latency spikes after an upgrade because an existing N+1 query is processed differently by the new framework version. If you eliminate these queries first, you ensure that any post-upgrade performance regressions are genuinely caused by the framework change, rather than pre-existing technical debt.
Another important advantage is reducing database CPU utilization. Each trip to the database incurs network latency and requires the database to parse and execute a query. Reducing fifty queries down to two queries dramatically lowers the load on your database server, freeing up connection pools for other web requests.
You may also wish to address these queries to reduce memory bloat in the Ruby process. While newer Ruby versions allocate memory more efficiently, instantiating thousands of Active Record objects through repetitive queries still creates substantial work for the Ruby garbage collector.
Understanding the Problem
Before we get into that, though, let’s take a step back and examine how Active Record retrieves data.
For example, a naive loop is an imperative approach to data retrieval. You ask Active Record for a collection of posts, and then, as you iterate over that collection, you implicitly ask for each author by invoking the association. Active Record processes this imperatively by firing a new query each time the loop runs.3
Eager loading, on the other hand, is a declarative approach. You declare your data dependencies upfront, telling Active Record exactly what associations you will need before you begin iterating. The framework can then optimize the retrieval into a batch operation.4
Let’s look at a concrete example. Suppose we have a Post model that belongs to an Author. We can boot up the Rails console and run a script to see the N+1 behavior in action:
posts = Post.limit(3)
posts.each do |post|
puts post.author.name
end
If we run this, we will see output that looks like this:
Post Load (0.5ms) SELECT "posts".* FROM "posts" LIMIT 3
Author Load (0.4ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = 1 LIMIT 1
David
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = 2 LIMIT 1
Michael
Author Load (0.4ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = 3 LIMIT 1
John
You also may notice that the initial query fetches the three posts, but then Active Record executes three additional, separate queries to fetch each author. If we had loaded 50 posts, we would see 50 additional queries.
Options for Resolving N+1 Queries
When it comes to eliminating N+1 queries in Rails, there are three major approaches to eager loading your associations. Depending on the particular circumstances you find yourself in, one of them may be more useful than the other two.5
The first is the preload method. This is my preferred method for standard associations. When you use preload with a single association, Active Record executes exactly two queries: one for the primary records, and one for all associated records.
The second is the eager_load method. This approach forces Active Record to use a LEFT OUTER JOIN, loading all associations in a single query.
The third option is the includes method. This is essentially a smart wrapper that delegates to either preload or eager_load depending on the context.
Generally speaking, preload is a safe and memory-efficient choice because it avoids complex database joins that can result in overly large result sets. The eager_load option, though, will make more sense if you need to filter the parent records based on a condition in the associated table.
Let’s update our previous example to use our preferred method, preload:
posts = Post.preload(:author).limit(3)
posts.each do |post|
puts post.author.name
end
When we run this script, the output changes significantly:
Post Load (0.5ms) SELECT "posts".* FROM "posts" LIMIT 3
Author Load (0.6ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (1, 2, 3)
David
Michael
John
We can notice a few things here. Active Record executed the first query to get the posts, and then it executed exactly one more query to fetch all the authors at once using an IN clause. We have successfully eliminated the N+1 query.
When Preload Fails
What happens if we add a condition that references the authors table? Let’s find out by adding a where clause to our query:
posts = Post.preload(:author).where(authors: { active: true }).limit(3)
If you run this, you will encounter an error: SQLite3::SQLException: no such column: authors.active.
This, of course, raises the question of exactly how we filter by associated records if preload runs two separate queries. The answer is straightforward: we must use a JOIN. This is the exact scenario where eager_load is required.
Let’s change our code to use eager_load:
posts = Post.eager_load(:author).where(authors: { active: true }).limit(3)
posts.each do |post|
puts post.author.name
end
Now, Active Record constructs a single query using a LEFT OUTER JOIN:
SQL (1.2ms) SELECT "posts"."id" AS t0_r0, "posts"."title" AS t0_r1, "authors"."id" AS t1_r0, "authors"."name" AS t1_r1 FROM "posts" LEFT OUTER JOIN "authors" ON "authors"."id" = "posts"."author_id" WHERE "authors"."active" = 1 LIMIT 3
David
Michael
John
Strictly speaking, includes could have figured this out for us automatically if we used includes(:author).references(:author), but explicitly choosing preload or eager_load makes your intentions clear to other developers who will read your code in the future.
Enforcing Efficient Queries
Before you can eliminate N+1 queries, you need to find them. While Application Performance Monitoring (APM) tools like New Relic or Datadog are excellent for finding these issues in production,6 we ideally want to catch them during development.
Starting in Rails 6.1, Active Record provides a strict_loading feature.7 When you enable strict loading on a model, Rails will raise an ActiveRecord::StrictLoadingViolationError if you attempt to lazily load an association.
class Post < ApplicationRecord
belongs_to :author
self.strict_loading_by_default = true
end
By enforcing strict loading on critical models, you guarantee that developers must explicitly define how associated data is loaded. This immunizes those code paths against regressions, ensuring that the inefficient clerk never returns to your store.
Eliminating N+1 queries is not a general best practice; it is a vital prerequisite for a major framework upgrade. By proactively addressing these inefficiencies, you allow your engineering team to upgrade confidently, knowing that the application rests on a solid, performant foundation.
Footnotes
-
Rails Guides Team. (2025, March). Active Record Query Interface. Ruby on Rails Guides. https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations ↩
-
Durable Programming. (2026, March). Performance Benchmarks: Quantifying the Impact of N+1 Queries. UpgradeRuby Research. https://upgraderuby.com/articles/performance-benchmarks-n-plus-1-queries ↩
-
Rails Guides Team. (2025, March). Active Record Query Interface: Eager Loading Associations. Ruby on Rails Guides. https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations ↩
-
Rails Guides Team. (2025, March). Active Record Query Interface: Eager Loading Associations. Ruby on Rails Guides. https://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations ↩
-
Rails Guides Team. (2025, March). Active Record Query Interface: Eager Loading Methods. Ruby on Rails Guides. https://guides.rubyonrails.org/active_record_querying.html#methods-for-eager-loading ↩
-
New Relic. (2025). Application Performance Monitoring. https://newrelic.com/products/application-monitoring ↩
-
Rails Guides Team. (2025, March). Active Record Query Interface: Strict Loading. Ruby on Rails Guides. https://guides.rubyonrails.org/active_record_querying.html#strict-loading ↩
You May Also Like
How to Generate a Gemfile.next.lock for Faster Rails Upgrades
A step-by-step technical guide to generating and managing a Gemfile.next.lock file using the bootboot plugin for smoother Rails upgrades.
How to Handle Frozen String Literals When Upgrading Legacy Ruby Apps
A pragmatic guide to addressing FrozenError exceptions, managing memory optimization, and enforcing immutability in legacy Ruby code during an upgrade.
ActiveSupport::Deprecation Guide for Rails Upgrades
Learn how to use ActiveSupport::Deprecation to manage breaking changes, guide users through API transitions, and make Rails upgrades safer.