RoR N+1 Query Problem
The N+1 query problem is one of the most common performance mistakes in Rails applications. It occurs when your app runs one query to fetch a list of records, then runs a separate database query for each record in the list. The result is dozens or hundreds of unnecessary queries that slow your pages down dramatically.
What N+1 Looks Like
You have 50 articles. Each article belongs to a user.
You want to display each article with its author's name.
BAD Code (causes N+1):
@articles = Article.all
View:
<% @articles.each do |article| %>
<p><%= article.title %> by <%= article.user.name %></p>
<% end %>
Queries executed:
Query 1: SELECT * FROM articles
Query 2: SELECT * FROM users WHERE id = 1 ← for article 1
Query 3: SELECT * FROM users WHERE id = 2 ← for article 2
Query 4: SELECT * FROM users WHERE id = 5 ← for article 3
...
Query 51: SELECT * FROM users WHERE id = 8 ← for article 50
Total: 51 queries (1 + N where N=50)
The Fix: Eager Loading with includes
GOOD Code (fixes N+1):
@articles = Article.includes(:user).all
View (same as before):
<% @articles.each do |article| %>
<p><%= article.title %> by <%= article.user.name %></p>
<% end %>
Queries executed:
Query 1: SELECT * FROM articles
Query 2: SELECT * FROM users WHERE id IN (1, 2, 5, 8, ...)
Total: 2 queries regardless of how many articles exist
Nested Eager Loading
Load multiple levels of associations in one shot:
@articles = Article.includes(:user, :comments, :tags) @orders = Order.includes(order_items: :product) @users = User.includes(articles: [:comments, :tags])
Detecting N+1 in Development
Add the bullet gem to detect N+1 queries automatically during development:
group :development do gem "bullet" end bundle install
config/environments/development.rb config.after_initialize do Bullet.enable = true Bullet.alert = true Bullet.rails_logger = true Bullet.add_footer = true ← shows warnings at bottom of each page end
Bullet highlights N+1 problems in the browser footer and prints them to your Rails log. Fix every warning it raises before deploying.
includes vs joins vs preload vs eager_load
includes:
Smart default. Rails chooses between preload and eager_load.
@articles = Article.includes(:user)
preload:
Always runs a separate query per association.
@articles = Article.preload(:user)
eager_load:
Always uses a LEFT OUTER JOIN — one query.
Required when filtering on the associated table.
@articles = Article.eager_load(:user).where(users: { active: true })
joins:
INNER JOIN — fetches articles that have a user, but does NOT load user data.
@articles = Article.joins(:user).where(users: { active: true })
article.user ← this would cause an N+1!
When to Use Each
| Method | Loads Association Data? | Use When |
|---|---|---|
| includes | Yes | You need to access association data in the view |
| eager_load | Yes | You also need to filter/order by the association |
| joins | No | You only need to filter, not access association data |
| preload | Yes | You want explicit separate queries (rarely needed) |
Counter Caching — Avoid Count Queries
Calling article.comments.count runs a COUNT SQL query every time. Add a counter cache column to store the count directly on the parent record:
Migration:
rails generate migration AddCommentsCountToArticles comments_count:integer
rails db:migrate
Model:
class Comment < ApplicationRecord
belongs_to :article, counter_cache: true
end
Reset existing counts:
Article.find_each { |a| Article.reset_counters(a.id, :comments) }
Usage (no SQL query):
article.comments_count ← reads from the articles table directly
select and pluck — Avoid Fetching Full Objects
When you only need one or two columns, avoid fetching entire objects:
BAD — fetches all columns, builds full objects: User.all.map(&:email) GOOD — fetches only email column, returns raw strings: User.pluck(:email) # => ["alice@example.com", "bob@example.com"] GOOD — fetches only needed columns: User.select(:id, :name, :email)
Measuring Query Performance
Use the Rails log to see query counts and timing:
In development, every query appears in your log: Article Load (0.5ms) SELECT * FROM articles User Load (0.3ms) SELECT * FROM users WHERE id IN (1,2,3) Count total queries per request with: ActiveRecord::Base.connection.query_cache
Use the rack-mini-profiler gem to display per-request query counts directly in the browser:
gem "rack-mini-profiler"
A counter appears in the corner of every page showing query count, query time, and total request time. Click it to see each SQL query with its timing.
N+1 Fix Checklist
Before deploying any page that shows a list: ✓ Does the view access any association? → add includes ✓ Does the view count associated records? → add counter_cache ✓ Does the view access nested data? → add nested includes ✓ Has Bullet gem raised any warnings? → fix all of them ✓ Does the query fetch unused columns? → use select or pluck
The N+1 problem is silent in development with small data sets but catastrophic in production with real data. Fixing it with includes is almost always a one-word change that delivers dramatic speed improvements.
