Fix: Rails N+1 Query Problem — Too Many Database Queries
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)
- 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 doesn’t automatically batch these.
Common sources beyond simple associations:
- Serializers calling associations —
ActiveModel::Serializersorfast_jsonapicallingpost.comments.countfor each record. - Callbacks querying associations —
before_savecallbacks that touch associated records. - Views calling
.countvs.size—post.comments.countalways runs a COUNT query;.sizeuses the cached collection if loaded. - Nested N+1 — loading posts → authors → companies adds another layer: N × 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
endFix 3: Detect N+1 Queries with the Bullet Gem
The Bullet gem automatically detects N+1 queries and alerts you during development:
# 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])Run Bullet in RSpec tests to catch N+1 in CI:
# 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
endFix 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: 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 6: 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 7: 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
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 issues.
For related issues, see Fix: GraphQL N+1 Query Problem and Fix: Prisma N+1 Query Problem.
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.