ActiveRecord: Fixing N+1 Query Problems in Ruby on Rails

Complete guide to detecting and fixing N+1 query problems in Rails with ActiveRecord. Master includes, preload, eager_load and automated detection tools.

Fixing N+1 Query Problems with ActiveRecord in Ruby on Rails

N+1 queries represent one of the most common performance problems in Rails applications. A simple loop over records can trigger hundreds of unnecessary SQL queries, drastically slowing down response times. This guide covers detection and resolution techniques to ensure performant Rails applications.

Production Impact

A page displaying 50 articles with their authors can generate 51 SQL queries instead of just one. In production with thousands of users, this problem becomes critical for response times and server load.

Understanding the N+1 Problem

The N+1 problem occurs when code executes one query to retrieve a list of records (1 query), then runs an additional query for each record to access its associations (N queries). The name "N+1" describes exactly this pattern: 1 initial query + N queries for associations.

Consider a concrete example with articles and their authors. Without optimization, each access to an article's author triggers a new SQL query.

ruby
# app/controllers/articles_controller.rb
# Example code generating an N+1 problem
class ArticlesController < ApplicationController
  def index
    # 1 query: SELECT * FROM articles
    @articles = Article.all
  end
end

In the view, each call to article.author triggers an additional query to the database.

erb
<!-- app/views/articles/index.html.erb -->
<!-- This view generates N additional queries -->
<% @articles.each do |article| %>
  <div class="article">
    <h2><%= article.title %></h2>
    <!-- Each call generates: SELECT * FROM users WHERE id = ? -->
    <p>By <%= article.author.name %></p>
  </div>
<% end %>

For 100 articles, this code generates 101 SQL queries. Rails logs clearly show the problem with repetitive queries.

sql
-- Rails logs showing the N+1 problem
-- 1 initial query
SELECT "articles".* FROM "articles"

-- N queries for authors (repeated for each article)
SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1
-- ... 97 more queries

Solving with includes

The includes method is the most common and recommended solution for fixing N+1 problems. It tells ActiveRecord to preload associations in one or two optimized queries.

ruby
# app/controllers/articles_controller.rb
# Solution with includes - preloading authors
class ArticlesController < ApplicationController
  def index
    # Preloads authors with articles
    # Generates only 2 queries instead of N+1
    @articles = Article.includes(:author).all
  end
end

With includes, ActiveRecord executes only two queries regardless of article count. The first retrieves all articles, the second retrieves all relevant authors.

sql
-- Rails logs with includes (only 2 queries)
SELECT "articles".* FROM "articles"
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, 5, ...)

Nested associations can also be preloaded using hash syntax. This approach is essential when views access multiple levels of associations.

ruby
# app/controllers/articles_controller.rb
# Preloading nested associations
class ArticlesController < ApplicationController
  def index
    # Preloads author -> company and all comments
    @articles = Article.includes(author: :company, comments: :user)
  end
end
Golden Rule

If a view accesses an association inside a loop, that association must be preloaded in the controller with includes. Always verify association access patterns in views.

Differences Between includes, preload, and eager_load

Rails provides three methods for association preloading. Each uses a different SQL strategy, with specific use cases.

preload: Separate Queries

The preload method always executes separate queries for each association. It works efficiently when no WHERE conditions filter on associations.

ruby
# app/models/article.rb
# preload always uses separate queries
class Article < ApplicationRecord
  scope :with_authors, -> { preload(:author) }
end

# Usage in controller
@articles = Article.with_authors.limit(20)

# SQL generated:
# SELECT "articles".* FROM "articles" LIMIT 20
# SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, ...)

eager_load: LEFT OUTER JOIN

The eager_load method uses a LEFT OUTER JOIN to load data in a single query. It becomes mandatory when filtering or sorting on association columns.

ruby
# app/controllers/articles_controller.rb
# eager_load allows filtering on associations
class ArticlesController < ApplicationController
  def verified_authors
    # Filters articles by author status
    # Requires eager_load because WHERE references users
    @articles = Article.eager_load(:author)
                       .where(users: { verified: true })
                       .order("users.name ASC")
  end
end

# SQL generated (single query with JOIN):
# SELECT "articles"."id", "articles"."title", ...
# FROM "articles"
# LEFT OUTER JOIN "users" ON "users"."id" = "articles"."author_id"
# WHERE "users"."verified" = TRUE
# ORDER BY "users"."name" ASC

includes: Smart Behavior

The includes method automatically chooses the best strategy. It uses preload by default, but switches to eager_load if a WHERE clause references the association.

ruby
# app/controllers/articles_controller.rb
# includes adapts automatically to context
class ArticlesController < ApplicationController
  def index
    # No condition on association: uses preload (2 queries)
    @articles = Article.includes(:author).all
  end

  def by_verified_authors
    # With condition on association: uses eager_load (JOIN)
    @articles = Article.includes(:author)
                       .where(users: { verified: true })
  end
end

The following table summarizes differences between the three methods.

| Method | SQL Strategy | Use Case | |--------|--------------|----------| | preload | Separate queries | Simple preloading, no filtering | | eager_load | LEFT OUTER JOIN | Filtering/sorting on associations | | includes | Automatic | General use, recommended default |

Automated N+1 Detection

Manual detection of N+1 problems is tedious and error-prone. Several tools automate this detection in development and CI.

Bullet: Real-Time Detection

The Bullet gem analyzes SQL queries in real-time and alerts on detected N+1 problems. It also suggests appropriate fixes.

ruby
# Gemfile
# Bullet detects N+1 in development
group :development do
  gem 'bullet'
end

Configuration in the development environment enables various alert modes.

ruby
# config/environments/development.rb
# Bullet configuration to detect N+1
Rails.application.configure do
  config.after_initialize do
    Bullet.enable = true
    # Display JavaScript alert in browser
    Bullet.alert = true
    # Add footer with details
    Bullet.bullet_logger = true
    # Display in Rails logs
    Bullet.rails_logger = true
    # Raise exception (useful in CI)
    Bullet.raise = false
  end
end

When an N+1 problem is detected, Bullet displays an explicit message with the recommended solution.

text
# Example Bullet alert in logs
USE eager loading detected
  Article => [:author]
  Add to your query: .includes([:author])
Call stack:
  /app/views/articles/index.html.erb:5
Bullet in CI

In continuous integration, enabling Bullet.raise = true causes tests to fail if an N+1 problem is detected. This prevents performance regressions.

Prosopite: Lightweight Alternative

The Prosopite gem offers a lighter alternative to Bullet, with minimal configuration and test compatibility.

ruby
# Gemfile
# Prosopite as an alternative to Bullet
group :development, :test do
  gem 'prosopite'
end
ruby
# config/environments/development.rb
# Prosopite configuration
Rails.application.configure do
  config.after_initialize do
    Prosopite.rails_logger = true
    Prosopite.raise = Rails.env.test?
  end
end

Advanced Optimization Techniques

Beyond basic methods, several techniques allow fine-tuned ActiveRecord query optimization.

Strict Loading: Default Prevention

Rails 6.1+ provides strict loading mode that raises an exception if a non-preloaded association is accessed. This preventive approach forces N+1 resolution during development.

ruby
# app/models/article.rb
# Enable strict loading by default on the model
class Article < ApplicationRecord
  # Any non-preloaded association access raises an exception
  self.strict_loading_by_default = true

  belongs_to :author
  has_many :comments
end

Strict loading can also be enabled on a specific query basis.

ruby
# app/controllers/articles_controller.rb
# Strict loading on a specific query
class ArticlesController < ApplicationController
  def index
    # Raises StrictLoadingViolationError if a non-included
    # association is accessed
    @articles = Article.strict_loading.includes(:author)
  end
end

Select and Pluck for Partial Data

When only certain columns are needed, select and pluck reduce the amount of data transferred from the database.

ruby
# app/controllers/reports_controller.rb
# Optimization with select and pluck
class ReportsController < ApplicationController
  def titles_only
    # select returns Article objects with only id and title
    @articles = Article.select(:id, :title)
  end

  def title_array
    # pluck returns an Array of values, not AR objects
    # More performant when only values are needed
    @titles = Article.pluck(:title)
    # => ["First article", "Second article", ...]
  end
end

Counter Cache for Counts

Association counts (article.comments.count) generate a SQL query on each call. Counter cache stores this count directly in the parent table.

ruby
# app/models/comment.rb
# Counter cache configuration
class Comment < ApplicationRecord
  # Rails automatically maintains the counter in articles.comments_count
  belongs_to :article, counter_cache: true
end

The migration adds the count column with a default value.

ruby
# db/migrate/20260223_add_comments_count_to_articles.rb
# Migration to add counter cache
class AddCommentsCountToArticles < ActiveRecord::Migration[7.1]
  def change
    add_column :articles, :comments_count, :integer, default: 0, null: false

    # Initialize counters for existing data
    Article.find_each do |article|
      Article.reset_counters(article.id, :comments)
    end
  end
end

After this configuration, article.comments_count reads the column directly without additional SQL queries.

ruby
# app/views/articles/index.html.erb
# Using counter cache (no SQL query)
<% @articles.each do |article| %>
  <p><%= article.title %> - <%= article.comments_count %> comments</p>
<% end %>

Ready to ace your Ruby on Rails interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Best Practices and Checklist

A systematic approach prevents N+1 problems in new development and progressively fixes existing code.

View Analysis Before Coding

Before writing controller code, analyze the view to identify all accessed associations. This anticipation prevents omissions.

ruby
# app/controllers/articles_controller.rb
# Pre-analyze view to identify required includes
class ArticlesController < ApplicationController
  def show
    # View accesses: author, author.company, comments, comments.user
    # All these must be preloaded
    @article = Article.includes(
      author: :company,
      comments: :user
    ).find(params[:id])
  end
end

Reusable Scopes

Centralizing frequent includes in scopes simplifies maintenance and ensures consistency.

ruby
# app/models/article.rb
# Reusable scopes for preloading
class Article < ApplicationRecord
  # Scope for list display
  scope :with_author, -> { includes(:author) }

  # Scope for detailed display
  scope :with_full_details, -> {
    includes(
      author: :company,
      comments: { user: :avatar_attachment },
      tags: []
    )
  }

  # Scope for admin with all relations
  scope :for_admin, -> {
    includes(:author, :comments, :tags, :category)
      .with_attached_cover_image
  }
end

Prevention Checklist

This checklist summarizes essential verification points to avoid N+1 problems:

  • Install and configure Bullet or Prosopite in development
  • Enable Bullet.raise in CI to block regressions
  • Analyze views to identify associations before writing controllers
  • Use includes by default, eager_load if filtering on associations
  • Create reusable scopes for frequent preloading patterns
  • Use strict loading on sensitive models
  • Add counter caches for frequent counts
  • Regularly check SQL logs in development
Over-Preloading Warning

Preloading too many associations consumes memory unnecessarily. Only preload what the view actually uses. Tools like Bullet also detect "unused eager loading".

Conclusion

N+1 queries represent a major performance problem that is easily preventable in Rails applications. A combination of automated detection tools and development best practices eliminates this issue effectively.

Key takeaways:

  • includes solves most N+1 cases by preloading associations
  • eager_load is required when filtering or sorting on associations
  • Bullet and Prosopite automatically detect problems in development
  • Strict loading prevents N+1 by raising exceptions
  • Counter caches optimize frequent counts
  • Analyzing views before writing controllers prevents omissions

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Tags

#ruby on rails
#activerecord
#performance
#n+1 queries
#sql optimization

Share

Related articles