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:
def index
@products = Product.limit(20)
endAnd a simple view that loops through products and displays their brand name:
<% @products.each do |product| %>
<h3><%= product.name %></h3>
<p>Brand: <%= product.brand.name %></p>
<% end %>When Rails renders this view, the log shows:
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:
def index
@products = Product.includes(:brand).limit(20)
endNow the log shows:
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:
@products = Product.includes(:reviews, :brand).limit(20)You can also nest includes for deeper associations:
@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:
class Product < ApplicationRecord
has_many :reviews
belongs_to :brand
scope :with_associations, -> { includes(:brand, reviews: :author) }
endThen in your controller:
@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:
def index
@products = Product.includes(:brand).strict_loading.limit(20)
endNow if a view or helper tries to access product.reviews without including it, Rails raises an error during development:
ActiveRecord::StrictLoadingViolationError: Product#reviews was not loadedThis 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:
config.active_record.strict_loading_by_default = trueWith 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:
group :development do
gem 'bullet'
endRun bundle install and initialize it in config/initializers/bullet.rb:
if defined?(Bullet)
Bullet.enable = true
Bullet.alert = true
Bullet.rails_logger = true
Bullet.console = true
endNow when you load a page with an N+1 query, Bullet logs a warning and optionally shows a browser alert:
[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:
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
includesto 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_loadingin 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.






Comments
Loading comments…