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

MethodLoads Association Data?Use When
includesYesYou need to access association data in the view
eager_loadYesYou also need to filter/order by the association
joinsNoYou only need to filter, not access association data
preloadYesYou 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.

Leave a Comment

Your email address will not be published. Required fields are marked *