Skip to content

Fix: Rails N+1 Query Problem — Too Many Database Queries

FixDevs ·

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 seconds

Why This Happens

The N+1 problem occurs when code:

  1. Loads N records from the database (1 query)
  2. 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 associationsActiveModel::Serializers or fast_jsonapi calling post.comments.count for each record.
  • Callbacks querying associationsbefore_save callbacks that touch associated records.
  • Views calling .count vs .sizepost.comments.count always runs a COUNT query; .size uses 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
end

Check if an association is already loaded:

post.association(:author).loaded?   # Returns true if eager loaded
post.author_loaded?                 # Rails 7.1+ convenience method

Fix 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 query

When to use which:

MethodUse when
includesDefault — let Rails decide
preloadMany records, no conditions on association
eager_loadFiltering or ordering by association columns
joinsOnly 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
end

Fix 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
end

Bullet 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
end

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
end

Use .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_hash

Fix 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 all

Reset incorrect counter cache values:

Post.find_each do |post|
  Post.reset_counters(post.id, :comments)
end

Fix 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 instantiation

Fix 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
end

Use explain to understand query performance:

# In Rails console
Post.includes(:author).explain
# Shows the query plan — look for sequential scans on large tables

Rack::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+1

Structured approach to fixing N+1:

  1. Enable Bullet in development and fix all detected N+1 issues
  2. 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
end

Still Not Working?

N+1 through polymorphic associationsincludes 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_type

N+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_sql

Scoped 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles