‹ Back to Blog

N+1 Queries in Rails: A Guide to Detection and Prevention

Rails Ruby Performance

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.