N+1 queries are the most common performance problem in Rails applications. ActiveRecord’s lazy loading means every belongs_to, has_many, and has_one association is a potential N+1 waiting to happen. The good news is that Rails gives you multiple ways to fix them, and tools like Scout can find them automatically.
This guide covers everything a Rails developer needs to know about N+1 queries: what they are, how to fix them, how to prevent them in CI, and how to detect them in production.
For a framework-agnostic overview, see Understanding N+1 Database Queries. For other frameworks, see our Django and Elixir/Ecto guides.
The N+1 Pattern in Rails
Here is the classic example. You have posts with authors:
class Post < ApplicationRecord
belongs_to :author
end
And a controller that lists them:
class PostsController < ApplicationController
def index
@posts = Post.all
end
end
When iterating in the view or a serializer, each post.author triggers a separate query:
# In a Jbuilder view, serializer, or anywhere you access the association:
@posts.each do |post|
puts "#{post.title} by #{post.author.name}"
end
This generates 1 query to load all posts, then N queries to load each author individually. With 100 posts, that is 101 database queries.
The SQL looks like this:
-- 1 query for posts
SELECT * FROM posts;
-- N queries for authors (one per post)
SELECT * FROM authors WHERE id = 1;
SELECT * FROM authors WHERE id = 2;
SELECT * FROM authors WHERE id = 3;
-- ... repeated for every post
Fixing N+1 Queries in ActiveRecord
Rails provides three methods for eager loading associations. All three eliminate N+1 queries, but they work differently under the hood.
includes
This is a great default choice. Rails decides whether to use a JOIN or a separate query based on context.
@posts = Post.includes(:author)
This reduces the query count from N+1 to 2 (one for posts, one for authors). If you add a where clause that references the association, Rails automatically switches to a JOIN.
# Rails uses a JOIN here because the where clause references authors
@posts = Post.includes(:author).where(authors: { active: true })
preload
This always uses a separate query. Never uses a JOIN.
@posts = Post.preload(:author)
Best for many-to-many associations and cases where a JOIN would produce duplicate rows.
eager_load
Always uses a LEFT OUTER JOIN.
@posts = Post.eager_load(:author)
Best when you need to filter or order by the associated table’s columns.
Comparison
| Method | SQL Strategy | When to Use |
|---|---|---|
includes |
Rails chooses | Default choice for most cases |
preload |
Separate query | has_many, has_and_belongs_to_many, avoiding duplicates |
eager_load |
LEFT OUTER JOIN | Filtering/ordering by the association |
Nested Associations
All three methods support nested eager loading:
# Load posts, their authors, and each author's publisher
@posts = Post.includes(author: :publisher)
# Multiple associations at once
@posts = Post.includes(:author, :comments, tags: :category)
N+1 Queries in Background Jobs
N+1 patterns are not limited to controllers and views. They commonly appear in Sidekiq workers, Resque jobs, and other background processors. These are often harder to catch because they do not appear in browser-based tools like the Rails performance bar.
class WeeklyDigestJob < ApplicationJob
def perform
# N+1: each user triggers a query for their posts
User.active.each do |user|
posts = user.posts.recent
DigestMailer.weekly(user, posts).deliver_later
end
end
end
Fix:
class WeeklyDigestJob < ApplicationJob
def perform
User.active.includes(:posts).each do |user|
posts = user.posts.recent
DigestMailer.weekly(user, posts).deliver_later
end
end
end
Scout monitors Sidekiq, Resque, DelayedJob, GoodJob, Shoryuken, Sneakers, and Solid Queue with the same N+1 detection that works in controller actions. If a background job has an N+1 pattern, it shows up in your dashboard with the exact code location.
N+1 Queries in Serializers and APIs
JSON API responses are another common source. If you use Active Model Serializers, JBuilder, or Blueprinter, the serialization layer can trigger lazy loads:
# This looks clean but creates N+1 queries in the serializer
class PostSerializer < ActiveModel::Serializer
attributes :id, :title
belongs_to :author
end
# Fix: eager load in the controller
class Api::PostsController < ApplicationController
def index
@posts = Post.includes(:author).all
render json: @posts
end
end
Preventing N+1 Queries in CI
The Bullet Gem
Bullet detects N+1 queries during development and testing. Add it to your Gemfile:
group :development, :test do
gem 'bullet'
end
Configure it to raise errors in your test environment so CI fails on N+1 patterns:
# config/environments/test.rb
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.raise = true
end
end
This catches new N+1 queries before they reach production. If your codebase already has N+1 patterns, Bullet supports an allowlist so you can enforce the rule for new code while addressing existing issues over time.
Prosopite
Prosopite is a newer alternative to Bullet that works at the SQL level rather than the ORM level. It catches N+1 patterns that Bullet might miss, including those from raw SQL or lesser-used ActiveRecord methods.
Detecting N+1 Queries in Production with Scout
Development tools like Bullet only catch N+1 queries that your test suite exercises. Production traffic often follows different code paths, with different data volumes, and exposes N+1 patterns that testing misses.
Scout’s Ruby agent monitors every database query in every request and background job. When it detects repeated query patterns (the signature of an N+1), it flags them automatically with:
- The exact line of code causing the repeated queries
- The generated SQL so you can see what is being repeated
- The query count and total time so you can prioritize by impact
- The request or job context so you know which endpoint or worker is affected
This works without configuration. Install the gem, set your Org key, deploy. N+1 patterns appear in your Scout dashboard alongside errors, logs, and traces.
Scout also detects memory bloat at the transaction level, which is often related to N+1 patterns. Fetching thousands of records in a loop does not just slow down queries; it can also grow memory in long-running processes like Sidekiq workers.
Common Rails N+1 Traps
Counter Caches vs. N+1 for Counts
If you display the number of comments on each post, a naive approach creates an N+1:
# N+1: one COUNT query per post
@posts.each do |post|
puts post.comments.count
end
Fix with a counter cache. Add a comments_count integer column to posts (default 0), then tell Comment to maintain it:
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
Now post.comments.size reads the cached column instead of issuing a COUNT query.
Or with a subquery:
@posts = Post.select("posts.*, (SELECT COUNT(*) FROM comments WHERE comments.post_id = posts.id) AS comments_count")
Scopes That Break Eager Loading
Custom scopes on associations can bypass your includes call. Say you have an approved scope on Comment:
class Comment < ApplicationRecord
belongs_to :post
scope :approved, -> { where(approved: true) }
end
Calling that scope after preloading triggers a new query for each post, because the scope adds a WHERE clause that the preloaded data does not match:
# This eager loads all comments...
@posts = Post.includes(:comments)
# ...but the scope triggers a new query per post.
@posts.each do |post|
post.comments.approved # New query! The scope is not part of the preload.
end
The fix is to preload the scoped association directly. Define a separate association with the scope baked in, then eager load that one:
class Post < ApplicationRecord
has_many :comments
has_many :approved_comments, -> { approved }, class_name: 'Comment'
end
@posts = Post.includes(:approved_comments)
@posts.each do |post|
post.approved_comments # No extra queries.
end
If the comments are already in memory and you do not need to filter at the database level, post.comments.select(&:approved?) avoids the extra query at the cost of loading every comment.
N+1 in has_many :through
class Doctor < ApplicationRecord
has_many :appointments
has_many :patients, through: :appointments
end
class Appointment < ApplicationRecord
belongs_to :doctor
belongs_to :patient
end
# N+1: each doctor triggers queries for appointments and then patients.
Doctor.all.each { |d| d.patients.each { |p| puts p.name } }
# Fix: preload the join model and the target association.
Doctor.includes(appointments: :patient).each do |d|
d.patients.each { |p| puts p.name }
end
You can also Doctor.includes(:patients) to preload the through association directly. Use the nested form when you also need data from the join model (e.g. the appointment time).
Frequently Asked Questions
What is the best way to fix N+1 queries in Rails?
Use includes as your default. It tells ActiveRecord to eager load the association, and Rails chooses the optimal strategy (JOIN or separate query) based on context. For specific cases, preload forces a separate query and eager_load forces a JOIN. The fix is almost always a one-line change on the query.
How do I find N+1 queries in a large Rails codebase?
Use Scout in production and Bullet in development/CI. Scout automatically detects N+1 patterns across all requests and background jobs, showing the exact code location and time impact. Bullet catches N+1 patterns during testing and can fail your CI build when new ones are introduced. Together they cover both new and existing code.
Do N+1 queries happen in Sidekiq jobs?
Yes, and they are often harder to catch because browser-based tools cannot see background jobs. Scout monitors Sidekiq, Resque, DelayedJob, GoodJob, Shoryuken, Sneakers, and Solid Queue with the same N+1 detection that works in controller actions. If a worker has an N+1 pattern, Scout flags it with the code location and query details.
Can I prevent N+1 queries in CI?
Yes. Add the Bullet gem to your test group and configure Bullet.raise = true in your test environment. This raises an exception whenever a test triggers an N+1 pattern, failing the CI build. Bullet also supports allowlisting existing N+1 patterns so you can enforce the rule for new code while addressing the backlog over time.
Does Scout detect N+1 queries without configuration?
Yes. Install the scout_apm gem, set your API key, and deploy. Scout’s Ruby agent monitors every ActiveRecord query in every request and background job. When it sees repeated query patterns, it flags the N+1 automatically with the code location, query count, SQL, and time impact. No Bullet configuration, no custom instrumentation, no code changes. See how it works for Rails teams or start a free trial with no credit card required. Our free tier is always available.
For application monitoring with errors, logs, and traces, Scout Monitoring provides the fastest insights without the bloat. See what we offer for Rails teams, compare Ruby monitoring tools, or start a free trial with no credit card required.