Fix: Rails N+1 Query Problem — Too Many Database Queries
Part of: Database Errors
Quick Answer
How to fix Rails N+1 queries — includes vs joins vs preload vs eager_load, Bullet gem detection, avoiding N+1 in serializers and views, and counter caches.
The Problem
A Rails controller action that should run a handful of queries runs hundreds:
# Controller
def index
@posts = Post.all
end<!-- View -->
<% @posts.each do |post| %>
<p><%= post.author.name %></p> <!-- Runs a query for EACH post -->
<% end %> Post Load (1.2ms) SELECT "posts".* FROM "posts"
User Load (0.8ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1
User Load (0.7ms) SELECT "users".* FROM "users" WHERE "users"."id" = 2
User Load (0.9ms) SELECT "users".* FROM "users" WHERE "users"."id" = 3
... (one query per post)Or the New Relic / Skylight dashboard shows an endpoint making 150+ queries for a single page load.
Or in the development log:
Completed 200 OK in 4521ms (Views: 4100ms | ActiveRecord: 380ms)
# A page that should load in ~50ms takes 4.5 secondsWhy This Happens
The N+1 problem occurs when code loads N records from the database (1 query) and then loads an associated record for each of the N records (N additional queries). Total: 1 + N queries instead of 2 queries.
Rails’ lazy loading makes N+1 easy to introduce accidentally. post.author triggers a database query every time it’s called — if called inside a loop over 100 posts, that’s 100 queries. ActiveRecord does not automatically batch these. The association looks like a simple attribute access in Ruby, so there is no visual signal in the code that a SQL query is being executed. This is a deliberate design choice: lazy loading keeps simple cases simple, but it means performance problems are invisible until you check the query log or an APM tool.
Common sources beyond simple associations include serializers calling post.comments.count for each record (each .count is a separate SELECT COUNT(*) query), callbacks that touch associated records, views calling .count instead of .size (which uses the cached collection), and nested N+1 chains like posts -> authors -> companies that multiply into N x M queries.
Fix 1: Use includes to Eager Load Associations
includes is the primary fix for N+1 — it loads the association for all records in one or two queries:
# WRONG — N+1: 1 query for posts + 1 query per post for author
@posts = Post.all
# CORRECT — 2 queries total: 1 for posts, 1 for all authors
@posts = Post.includes(:author)
# Multiple associations
@posts = Post.includes(:author, :comments, :tags)
# Nested associations
@posts = Post.includes(author: :company, comments: :author)
# Association with conditions (still eager loaded)
@posts = Post.includes(:published_comments)
.where(published: true)After using includes, accessing the association doesn’t run a query:
@posts = Post.includes(:author)
@posts.each do |post|
post.author.name # No query — author already loaded
post.author.email # No query
endCheck if an association is already loaded:
post.association(:author).loaded? # Returns true if eager loaded
post.author_loaded? # Rails 7.1+ convenience methodFix 2: Choose the Right Loading Strategy
Rails has four methods for loading associations — each has different behavior:
# includes — Rails chooses between preload and eager_load based on the query
@posts = Post.includes(:author)
# preload — always uses separate queries (one per association)
# Best for: simple cases, no WHERE on the association
@posts = Post.preload(:author)
# Runs: SELECT * FROM posts
# Then: SELECT * FROM users WHERE id IN (1, 2, 3, ...)
# eager_load — always uses a LEFT OUTER JOIN
# Required when: filtering or ordering by the association
@posts = Post.eager_load(:author).where(users: { active: true })
# Runs: SELECT posts.*, users.* FROM posts LEFT OUTER JOIN users ON ...
# joins — SQL INNER JOIN (does NOT eager load — still causes N+1 if you access the association)
# Use for: filtering by association, not loading it
@posts = Post.joins(:author).where(users: { active: true })
# Does NOT load author — accessing post.author still runs a queryWhen to use which:
| Method | Use when |
|---|---|
includes | Default — let Rails decide |
preload | Many records, no conditions on association |
eager_load | Filtering or ordering by association columns |
joins | Only filtering, don’t need association data |
Common mistake — using joins then accessing the association:
# WRONG — joins doesn't load the association
@posts = Post.joins(:author).where(users: { active: true })
@posts.each do |post|
post.author.name # Still runs a query per post!
end
# CORRECT — use eager_load when filtering AND accessing
@posts = Post.eager_load(:author).where(users: { active: true })
@posts.each do |post|
post.author.name # Loaded from JOIN result — no extra query
endPostgreSQL vs MySQL query plan differences: preload generates WHERE id IN (...) queries. PostgreSQL handles large IN lists well (up to thousands of IDs), but MySQL has a limit on the number of placeholders in a prepared statement (65535 by default). For very large result sets on MySQL, consider eager_load (which uses a JOIN) or paginate the result set. You can check which strategy Rails chose by calling .to_sql and examining the generated query.
Fix 3: Detect N+1 Queries Automatically
Multiple tools detect N+1 queries. Each works differently.
Bullet gem (development alerts):
# Gemfile
gem 'bullet', group: :development# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.alert = true # Browser popup for N+1 detected
Bullet.rails_logger = true # Log to Rails logger
Bullet.add_footer = true # Show N+1 count in page footer
# Also detect unused eager loading (includes that aren't needed)
Bullet.unused_eager_loading_enable = true
# Counter cache suggestions
Bullet.counter_cache_enable = true
endBullet outputs warnings like:
GET /posts
USE eager loading detected
Post => [:author]
Add to your query: .includes([:author])Prosopite (alternative to Bullet, works in production):
# Gemfile
gem 'prosopite'# config/initializers/prosopite.rb
Prosopite.raise = true # Raise an error on N+1 (good for tests)
Prosopite.min_n_queries = 2 # Minimum duplicate queries to flag
# In tests
Prosopite.scan do
get '/posts'
end
# Raises Prosopite::NPlusOneQueriesError if N+1 detectedstrict_loading (Rails 6.1+, per-record enforcement):
# Raise an error if a lazy-loaded association is accessed
@posts = Post.strict_loading.includes(:author)
@posts.each do |post|
post.author.name # OK — author was eager loaded
post.comments.count # ActiveRecord::StrictLoadingViolationError!
# comments were not included, so strict_loading prevents the N+1
end
# Enable globally for an association
class Post < ApplicationRecord
has_many :comments, strict_loading: true
# Any lazy load of comments raises an error — forces eager loading
end
# Enable for all associations on a model
class Post < ApplicationRecord
self.strict_loading_by_default = true
endstrict_loading is especially useful in CI — it turns silent N+1 bugs into hard failures. Combine it with includes to ensure all accessed associations are explicitly declared.
Fix 4: Fix N+1 in Serializers
API serializers are a common source of N+1 — each serialized record calls associations independently:
# WRONG — N+1 in serializer
class PostSerializer < ActiveModel::Serializer
attributes :id, :title, :author_name, :comment_count
def author_name
object.author.name # N queries if posts not preloaded with author
end
def comment_count
object.comments.count # N COUNT queries (even if comments preloaded, .count hits DB)
end
end
# Controller — fix by eager loading everything the serializer needs
def index
@posts = Post.includes(:author, :comments)
render json: @posts
endUse .size instead of .count when the association may be loaded:
# .count always runs COUNT query
post.comments.count # SELECT COUNT(*) FROM comments WHERE post_id = 1
# .size uses the loaded collection if available
post.comments.size # No query if comments already included
post.comments.length # Also uses loaded collection (loads all records first if not loaded)With fast_jsonapi / jsonapi-serializer:
class PostSerializer
include JSONAPI::Serializer
attribute :author_name do |post|
post.author.name # N+1 if not eager loaded
end
end
# Controller
@posts = Post.includes(:author)
render json: PostSerializer.new(@posts).serializable_hashFix 5: Fix N+1 in Turbo Streams and Hotwire Partials
Turbo Streams and Hotwire render partials that access associations. Each partial render can trigger its own N+1 chain.
<!-- app/views/posts/_post.html.erb -->
<%= turbo_frame_tag post do %>
<h2><%= post.title %></h2>
<p>By <%= post.author.name %></p> <!-- N+1 if author not loaded -->
<p><%= post.comments.size %> comments</p> <!-- N+1 if comments not loaded -->
<% end %># Controller — must eager load for partials
def index
@posts = Post.includes(:author, :comments).order(created_at: :desc)
endTurbo Streams broadcast N+1: When broadcasting model changes via Turbo Streams, the broadcast callback renders a partial for a single record. If that partial accesses associations, each broadcast triggers separate queries:
class Post < ApplicationRecord
# WRONG — broadcasts render the partial without eager loading
after_create_commit { broadcast_prepend_to "posts" }
# BETTER — use a custom broadcast that eager loads
after_create_commit do
broadcast_prepend_to "posts",
target: "posts",
partial: "posts/post",
locals: { post: Post.includes(:author, :comments).find(id) }
end
endPro Tip: For Turbo Stream broadcasts of single records, the N+1 cost is one extra query per association per broadcast (not N queries), since only one record is rendered. The real danger is in batch operations like Post.create!([...]) inside a loop, where each record triggers a separate broadcast with its own queries.
Fix 6: Add Counter Caches for Association Counts
Repeatedly counting associations (e.g., showing “42 comments” on each post) is a common N+1 variant. A counter cache stores the count in the parent record:
# Migration — add counter cache column
class AddCommentsCountToPosts < ActiveRecord::Migration[7.1]
def change
add_column :posts, :comments_count, :integer, default: 0, null: false
# Backfill existing counts
Post.find_each do |post|
Post.reset_counters(post.id, :comments)
end
end
end# Model — enable counter cache on the belongs_to side
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
# counter_cache: true automatically maintains posts.comments_count
# on create and destroy of comments
end
# Custom counter cache column name
belongs_to :post, counter_cache: :total_comments# Now accessing comment count doesn't run a query
post.comments.size # Reads posts.comments_count — no COUNT query
post.comments_count # Direct column access — no query at allReset incorrect counter cache values:
Post.find_each do |post|
Post.reset_counters(post.id, :comments)
endFix 7: Use select to Load Only Needed Columns
When eager loading associations, fetching all columns for a large association adds unnecessary memory and data transfer. Use select to limit columns:
# Load posts with author's name only (not all user columns)
@posts = Post.includes(:author)
.select('posts.*, users.name AS author_name')
.joins(:author)
# Access via post.author_name — no additional query
# Or use eager_load with select on the association
@posts = Post.eager_load(:author)
.select('posts.id, posts.title, users.name')
# Preloading with specific columns on the association
@posts = Post.preload(:author)
# Specify columns on the preloaded scope via a scope on the model:
# has_many :authors -> { select(:id, :name) }Avoid loading entire associated objects when only one attribute is needed:
# WRONG — loads the entire User object just to get the name
@posts.each { |p| puts p.author.name }
# BETTER — use pluck for simple attribute extraction without N+1
Post.joins(:author).pluck('posts.id', 'users.name')
# Returns array of [post_id, author_name] — one query, no model instantiationFix 8: Identify N+1 in Production
In production, use query logging or APM tools to find N+1 queries.
Log slow queries and query counts:
# config/initializers/query_logger.rb
ActiveSupport::Notifications.subscribe('sql.active_record') do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
# Log queries over 100ms
Rails.logger.warn("SLOW QUERY (#{event.duration.round}ms): #{event.payload[:sql]}") if event.duration > 100
endUse explain to understand query performance:
# In Rails console
Post.includes(:author).explain
# Shows the query plan — look for sequential scans on large tablesRack::MiniProfiler for development:
# Gemfile
gem 'rack-mini-profiler', group: :development
gem 'flamegraph', group: :development
gem 'stackprof', group: :development
# Shows query count and timing on every page as an overlay
# Red badge = high query count, indicates N+1Structured approach to fixing N+1:
- Enable Bullet in development and fix all detected N+1 issues
- Write a request spec that asserts query count:
# spec/requests/posts_spec.rb
it 'loads posts without N+1 queries' do
create_list(:post, 10, :with_author)
expect {
get '/posts'
}.to make_database_queries(count: 2..5)
# Uses db-query-matchers gem
endRSpec vs Minitest N+1 detection:
# RSpec — use db-query-matchers or prosopite
# spec/spec_helper.rb
if Bullet.enable?
config.before(:each) do
Bullet.start_request
end
config.after(:each) do
Bullet.perform_out_of_channel_notifications if Bullet.notification?
Bullet.end_request
end
end
# Minitest — use prosopite directly
class PostsControllerTest < ActionDispatch::IntegrationTest
setup do
Prosopite.scan
end
teardown do
Prosopite.finish
end
test "index does not have N+1" do
10.times { create(:post, :with_author) }
get posts_url
assert_response :success
# Prosopite raises if N+1 detected
end
endStill Not Working?
N+1 through polymorphic associations — includes works with polymorphic associations, but Rails must load each type separately:
# Polymorphic: comments can belong to Post or Video
@comments = Comment.includes(:commentable)
# Rails runs separate queries for each commentable_typeN+1 in after_* callbacks — callbacks that load associations run once per record being saved. Move bulk operations to service objects that operate on all records at once.
where on includes causes extra queries — when you filter by an included association’s columns, Rails may switch from preload to eager_load automatically. Verify with to_sql:
# Check the generated SQL
Post.includes(:author).where(users: { active: true }).to_sqlScoped associations still cause N+1 — a has_many :active_comments, -> { where(active: true) } scoped association loads fine with includes(:active_comments), but mixing it with unscoped queries on the same records may cause duplicate queries.
Sequel ORM users — if your project uses Sequel instead of ActiveRecord, the eager loading methods are different. Sequel uses eager (equivalent to preload) and eager_graph (equivalent to eager_load). Sequel does not have an equivalent to includes that auto-selects the strategy:
# Sequel
Post.eager(:author).all # Separate queries (like preload)
Post.eager_graph(:author).all # JOIN (like eager_load)N+1 in GraphQL resolvers — if your Rails API serves GraphQL, field resolvers trigger individual queries per record. Use dataloader (built into graphql-ruby 2.0+) or batch-loader to batch and deduplicate association loads across resolvers.
For related issues, see Fix: GraphQL N+1 Query Problem, Fix: Prisma N+1 Query Problem, Fix: PostgreSQL Slow Query, and Fix: MySQL Index Not Used.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Ruby Bundler Version Conflict — Gemfile Requirements Could Not Be Resolved
How to fix Ruby Bundler gem version conflicts — Gemfile.lock resolution, platform-specific gems, bundle update strategies, conflicting transitive dependencies, and Bundler version issues.
Fix: MySQL Index Not Being Used — Query Optimizer Skipping Indexes
How to fix MySQL indexes not being used by the query optimizer — EXPLAIN output, implicit conversions, function on columns, composite index order, cardinality issues, and forcing indexes.
Fix: MySQL Slow Query — Diagnosis and Optimization with EXPLAIN
How to diagnose and fix slow MySQL queries — enabling the slow query log, reading EXPLAIN output, adding indexes, fixing N+1 queries, and optimizing JOINs and ORDER BY.
Fix: PostgreSQL Slow Query — Finding and Optimizing with EXPLAIN ANALYZE
How to diagnose and fix slow PostgreSQL queries — reading EXPLAIN ANALYZE output, adding the right indexes, fixing N+1 queries, optimizing joins, and using pg_stat_statements.