Skip to content

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

FixDevs · (Updated: )

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 seconds

Why 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
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

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

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

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

strict_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
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: 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)
end

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

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

Reset incorrect counter cache values:

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

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

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

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

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