/ Tags: RAILS / Categories: RAILS

N+1 Queries in ActiveRecord — Diagnosis, Eager Loading, and When Preloading Backfires

N+1 queries are one of those problems that makes perfect sense once you understand them, and that silently degrades production performance until a request that touches 500 records starts taking four seconds. The fix — eager loading — is usually one line. The difficulty is recognizing where the problem lives, knowing which loading strategy to choose, and understanding the edge cases where eager loading itself becomes the bottleneck.

What N+1 Looks Like


An N+1 problem happens when you load a collection of records and then execute an additional query for each record in that collection. One query to get the collection, N queries for the associated data — hence N+1.

Example:

# N+1 in a controller
def index
  @posts = Post.all  # 1 query: SELECT * FROM posts
end
<!-- N+1 in the view — one query per post -->
<% @posts.each do |post| %>
  <h2><%= post.title %></h2>
  <p>By <%= post.author.name %></p>  <!-- SELECT * FROM users WHERE id = ? — fires once per post -->
  <p><%= post.comments.count %> comments</p>  <!-- SELECT COUNT(*) — fires once per post -->
<% end %>

With 100 posts, that view executes 201 queries: 1 for posts, 100 for authors, 100 for comment counts. With eager loading, that drops to 3 queries regardless of post count.

The Bullet Gem — Detecting N+1 Automatically


Before fixing N+1s, detect them. The bullet gem logs warnings (and can raise errors in tests) when N+1 queries occur.

Setup:

# Gemfile
group :development, :test do
  gem "bullet"
end

# config/environments/development.rb
config.after_initialize do
  Bullet.enable        = true
  Bullet.alert         = true
  Bullet.rails_logger  = true
  Bullet.add_footer    = true
end

Bullet logs messages like USE eager loading detected: Post => [:author] in development. Set Bullet.raise = true in test environment to fail fast on N+1 in your test suite.

includes — The Standard Fix


includes performs eager loading. For a has_many or belongs_to, it loads associated records in one or two additional queries rather than one per record.

Example:

# Before: N+1
@posts = Post.all

# After: 2 queries total (posts + authors)
@posts = Post.includes(:author)

# Multiple associations
@posts = Post.includes(:author, :tags, :comments)

# Nested associations
@posts = Post.includes(author: :profile, comments: [:author, :likes])

Example:

# Accessing the count — still an N+1 if you call .count
@posts = Post.includes(:comments)

# In view:
post.comments.count    # => still queries — .count always hits the DB
post.comments.size     # => uses loaded collection if eager-loaded (no query)
post.comments.length   # => uses loaded collection (no query)
post.comments.loaded?  # => true when eager-loaded

Use .size or .length (not .count) when the association is already loaded — .count always executes a SQL COUNT regardless of what’s been loaded.

preload vs eager_load — Understanding the Difference


includes intelligently chooses between preload and eager_load based on whether you need to filter on the association. Understanding the difference matters when includes doesn’t do what you expect.

Example:

# preload — runs a separate query for each association
# ALWAYS loads in a separate query — safe for large datasets
Post.preload(:author)
# SELECT * FROM posts
# SELECT * FROM users WHERE id IN (1, 2, 3, ...)

# eager_load — uses a LEFT OUTER JOIN
# Required when you need to filter/order by the association
Post.eager_load(:author)
# SELECT posts.*, users.* FROM posts LEFT OUTER JOIN users ON users.id = posts.author_id

Example:

# includes auto-selects the strategy
# When no condition on association → preload (separate query)
Post.includes(:author).where("posts.published = true")
# → preload: two separate queries

# When condition references association → eager_load (JOIN)
Post.includes(:author).where("users.active = true")
# → eager_load: JOIN required to filter on users table
# OR be explicit:
Post.eager_load(:author).where("users.active = true")
Method Strategy SQL Use when
preload Separate query WHERE id IN (...) Default, large datasets
eager_load JOIN LEFT OUTER JOIN Filtering/ordering on association
includes Auto-selects Either Let Rails decide

Counter Caches — For Counts at Scale


When you frequently display counts of associations, eager loading still requires loading all records to count them. Counter caches store the count directly on the parent record.

Example:

# Migration
class AddCommentsCountToPosts < ActiveRecord::Migration[7.1]
  def change
    add_column :posts, :comments_count, :integer, default: 0, null: false
    Post.find_each { |p| Post.reset_counters(p.id, :comments) }
  end
end

# Model — tell ActiveRecord to maintain the cache
class Comment < ApplicationRecord
  belongs_to :post, counter_cache: true
end

# Now post.comments.count reads from posts.comments_count — zero queries
@posts.each do |post|
  post.comments.count   # => reads from counter_cache column, no JOIN
end

Counter caches are the right tool when you need counts in list views without loading records. The cost: an extra UPDATE on the parent record every time a child is created or destroyed.

select and pluck — When You Don’t Need Full Records


Sometimes the N+1 fix isn’t eager loading — it’s not loading full records at all.

Example:

# Loading full records when you only need names
User.where(active: true).map(&:name)
# SELECT * FROM users WHERE active = true  (loads all columns)

# pluck — returns an array of values, single query, no model instantiation
User.where(active: true).pluck(:name)
# SELECT name FROM users WHERE active = true

# Multiple columns
User.pluck(:id, :name, :email)
# => [[1, "Ada", "[email protected]"], [2, "Grace", "[email protected]"]]

# select — returns AR objects with only specified attributes
User.select(:id, :name).each { |u| puts u.name }
# Works for view rendering; u.email would raise MissingAttributeError

pluck is the fastest option for extracting a list of values. Use it for building IDs arrays, populating select options, or aggregating values without needing model methods.

Pro-Tip: Run EXPLAIN ANALYZE on your actual production queries, not just on the Rails log. Rails logs query time but not what happens inside the database. A query that returns 10 rows but scans 500,000 rows is not a problem includes solves — it needs an index. N+1 and missing indexes are often confused because both cause slow queries. Fix N+1s first (they’re cheaper to address), then look at query plans for the remaining slow queries.

Conclusion


N+1 queries are fixable with one line of eager loading in most cases, but picking the right tool matters. includes for general association loading, preload when you want explicit control over loading strategy, eager_load when you need to filter on associated columns, counter caches for frequently-displayed counts, and pluck when you only need values. The Bullet gem turns N+1 detection from manual code review into an automated feedback loop that catches problems before they reach production.

FAQs


Q1: Can N+1 happen in background jobs, not just views?
Yes. Any code that loads a collection and then accesses associations record-by-record has the problem. Background jobs that process large datasets are often worse than web requests because there’s no response-time feedback — a job that processes 10,000 records with an N+1 just silently takes 20 minutes.

Q2: Does includes always solve the N+1 problem?
It solves the query count problem, but can introduce a different issue: loading thousands of records into memory when you only needed counts or specific values. For those cases, counter caches or aggregation queries (group, pluck) are better.

Q3: What’s the performance difference between preload and eager_load?
preload (separate queries with IN (...)) is usually faster for large result sets because JOINs multiply rows — a post with 10 comments generates 10 rows in a JOIN result. eager_load (JOIN) is faster when loading a small result set or when filtering by the associated table.

Q4: Does includes work with has_many :through associations?
Yes. Post.includes(:tags) works for has_many :through: :taggings. Rails handles the join table transparently.

Q5: How do I detect N+1 queries in the test suite without Bullet?
Use the db-query-matchers gem or QueryCounter pattern: wrap test assertions in a query count check. expect { action }.to make_database_queries(count: 3) fails if the action executes a different number of queries than expected.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More