Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 013 / 100

Fixing N+1 queries in Rails for Ruby on Rails developers

The most common Rails performance problem.

Expona Admin6 min read

The N+1 query problem: Why your Rails app feels slow

You've just deployed a new feature to production. Users load a page listing 20 products, and your database suddenly shows 21 queries running in series—one to fetch the products, then one for each product's reviews and brand information. This is the N+1 problem, and it's the most common Rails performance issue we see in production. Fixing N+1 queries in Rails for Ruby on Rails developers should be one of your first optimization strategies, not your last.

The N+1 problem sneaks up because it works fine during local development with small datasets. A page that queries the database 21 times instead of 2 or 3 doesn't fail—it just runs slowly, and that slowdown compounds as your product scales. The good news is that Rails gives you multiple straightforward tools to eliminate these queries entirely.

Spot it—Logs show many similar queries for one page

The first step is learning to recognize N+1 queries in the wild. When you load a page and open your Rails log file, you should see a clear pattern: one query that returns multiple rows, followed by many identical queries that look almost the same.

Here's what it looks like in practice. Say you have a controller action:

ruby
def index
  @products = Product.limit(20)
end

And a simple view that loops through products and displays their brand name:

ruby
<% @products.each do |product| %>
  <h3><%= product.name %></h3>
  <p>Brand: <%= product.brand.name %></p>
<% end %>

When Rails renders this view, the log shows:

ruby
SELECT "products".* FROM "products" LIMIT 20
SELECT "brands".* FROM "brands" WHERE "id" = 1
SELECT "brands".* FROM "brands" WHERE "id" = 5
SELECT "brands".* FROM "brands" WHERE "id" = 3
SELECT "brands".* FROM "brands" WHERE "id" = 2
... (16 more identical queries)

That first query fetches 20 products (N queries). Then accessing product.brand.name on each product triggers one additional query per product (the +1 problem). You've gone from a single query to 21 queries, all for the same page load.

To spot these in your own logs, look for repetition. If you see a query template repeated 5, 10, or 20 times in quick succession for one controller action, that's an N+1 query. In development, Rails shows you the log in the terminal. In production, use your APM tool (New Relic, Datadog, Scout) to see slow queries grouped by template.

includes—Eager load associations

The includes method tells Rails to load associated data upfront in a single query per association, rather than one query per row. It's the primary tool for fixing N+1 queries in Rails.

Modify your controller to use includes:

ruby
def index
  @products = Product.includes(:brand).limit(20)
end

Now the log shows:

ruby
SELECT "products".* FROM "products" LIMIT 20
SELECT "brands".* FROM "brands" WHERE "id" IN (1, 5, 3, 2, ...)

That's 2 queries total. Rails batches all the brand IDs from the 20 products into a single IN clause and fetches them all at once. When you access product.brand.name` in the view, the data is already in memory.

If you need multiple associations, chain them:

ruby
@products = Product.includes(:reviews, :brand).limit(20)

You can also nest includes for deeper associations:

ruby
@products = Product.includes(:brand, reviews: :author).limit(20)

This loads products, their brands, their reviews, and each review's author in just 4 queries. Without includes, accessing product.reviews.first.author.name would trigger queries for every product.

A common pattern in our team is to define includes as scopes on the model itself:

ruby
class Product < ApplicationRecord
  has_many :reviews
  belongs_to :brand

  scope :with_associations, -> { includes(:brand, reviews: :author) }
end

Then in your controller:

ruby
@products = Product.with_associations.limit(20)

This makes it easy to reuse the same eager-loading pattern across multiple controllers and tests.

strict_loading—Raise errors on lazy loading in development

Even with includes in place, it's easy to accidentally introduce new N+1 queries when you add a feature later. The strict_loading method makes development safer by raising an error the moment you try to access an association that wasn't eager-loaded.

Add it to your controller:

ruby
def index
  @products = Product.includes(:brand).strict_loading.limit(20)
end

Now if a view or helper tries to access product.reviews without including it, Rails raises an error during development:

ruby
ActiveRecord::StrictLoadingViolationError: Product#reviews was not loaded

This prevents accidental N+1 queries from slipping into production. We typically enable strict_loading in development and test environments, not production, since the error overhead adds up on high-traffic pages.

You can enable it globally in config/environments/development.rb:

ruby
config.active_record.strict_loading_by_default = true

With this enabled, every ActiveRecord query runs in strict mode. Any lazy-loaded association raises an error. It's a powerful safety net, especially for teams where multiple developers touch the same code.

Bullet gem—Alerts you to N+1 queries automatically

Manually reviewing logs and adding includes works, but it's easy to miss cases. The Bullet gem automates the detection by monitoring your queries and alerting you to N+1 problems in real time.

Add Bullet to your Gemfile in the development group:

ruby
group :development do
  gem 'bullet'
end

Run bundle install and initialize it in config/initializers/bullet.rb:

ruby
if defined?(Bullet)
  Bullet.enable = true
  Bullet.alert = true
  Bullet.rails_logger = true
  Bullet.console = true
end

Now when you load a page with an N+1 query, Bullet logs a warning and optionally shows a browser alert:

ruby
[Bullet] N+1 Query: Product => [:brand]
[Bullet] Add to your finder: .includes(:brand)

Bullet tells you exactly which association to include and in which model. It catches most N+1 problems automatically as you develop. We run Bullet in CI on our integration tests as well, failing the build if N+1 queries are detected on any page we test.

One note: Bullet can generate false positives. If you find Bullet flagging a query that actually fires only once, you can whitelist it:

ruby
Bullet.add_safelist(type: :n_plus_one_query, class_name: 'Product', association: :reviews)

Common mistakes

Forgetting to include nested associations: If you include reviews but forget to include the author of each review, you'll still have N+1 queries when rendering the author's name. Always trace through your view to see what nested associations you're accessing.

Using includes with where clauses on the association: This can cause unexpected behavior. If you do Product.includes(:reviews).where(reviews: { rating: 5 }), Rails may fall back to a JOIN and change your result set. Use eager_load or preload explicitly in these cases.

Including associations you don't use: Every included association loads data into memory. If you include :reviews but don't access reviews in your view or controller, you're wasting memory and query time. Be intentional about what you load.

Assuming includes always works: Some complex queries with custom SQL or multiple aggregations may not benefit from includes. If you're still seeing unexpected query patterns after adding includes, inspect the generated SQL directly with .explain.

Key takeaways

  • N+1 queries are the most common Rails performance problem. Look for repeated, similar queries in your logs—one for each row in a loop.
  • Use includes to eager-load associations. It reduces multiple queries per row to a single batched query per association.
  • Define common includes patterns as scopes on your models so they're reusable and consistent across controllers.
  • Enable strict_loading in development to catch lazy-loaded associations immediately, before they reach production.
  • Use the Bullet gem to detect N+1 queries automatically as you develop, and run it in CI to prevent regressions.
  • Trace through your views to understand which associations you're actually accessing, then include only what you need.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles