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.

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.
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.
# 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
endIn the view, each call to article.author triggers an additional query to the database.
<!-- 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.
-- 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 queriesSolving 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.
# 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
endWith includes, ActiveRecord executes only two queries regardless of article count. The first retrieves all articles, the second retrieves all relevant authors.
-- 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.
# 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
endIf 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.
# 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.
# 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" ASCincludes: 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.
# 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
endThe 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.
# Gemfile
# Bullet detects N+1 in development
group :development do
gem 'bullet'
endConfiguration in the development environment enables various alert modes.
# 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
endWhen an N+1 problem is detected, Bullet displays an explicit message with the recommended solution.
# 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:5In 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.
# Gemfile
# Prosopite as an alternative to Bullet
group :development, :test do
gem 'prosopite'
end# config/environments/development.rb
# Prosopite configuration
Rails.application.configure do
config.after_initialize do
Prosopite.rails_logger = true
Prosopite.raise = Rails.env.test?
end
endAdvanced 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.
# 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
endStrict loading can also be enabled on a specific query basis.
# 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
endSelect and Pluck for Partial Data
When only certain columns are needed, select and pluck reduce the amount of data transferred from the database.
# 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
endCounter 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.
# 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
endThe migration adds the count column with a default value.
# 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
endAfter this configuration, article.comments_count reads the column directly without additional SQL queries.
# 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.
# 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
endReusable Scopes
Centralizing frequent includes in scopes simplifies maintenance and ensures consistency.
# 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
}
endPrevention 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
includesby default,eager_loadif 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
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:
includessolves most N+1 cases by preloading associationseager_loadis 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
Share
Related articles

Ruby on Rails Interview Questions: Top 25 in 2026
The 25 most asked Ruby on Rails interview questions. MVC architecture, Active Record, migrations, RSpec testing, REST APIs with detailed answers and code examples.

Ruby on Rails 7: Hotwire and Turbo for Reactive Applications
Complete guide to Hotwire and Turbo in Rails 7. Learn to build reactive applications without writing JavaScript using Turbo Drive, Frames, and Streams.

Action Cable and WebSockets in Rails: Complete Guide for Technical Interviews 2026
Deep dive into Action Cable and WebSockets in Ruby on Rails. Covers connections, channels, broadcasting, Solid Cable in Rails 8, scaling with Redis, and common interview questions with code examples.