# 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.
- Published: 2026-02-23
- Updated: 2026-03-31
- Author: SharpSkill
- Tags: ruby on rails, activerecord, performance, n+1 queries, sql optimization
- Reading time: 10 min
---
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
<% @articles.each do |article| %>
<%= article.title %>
By <%= article.author.name %>
<% 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| %>