N+1 Queries in Rails — How I Find and Fix Them for Good

by Eric Hanson, Backend Developer at Clean Systems Consulting

Why they keep coming back

An N+1 query fires one query to load a collection, then one query per record to load an association. The fix — includes — is well known. The reason N+1 queries keep reappearing in mature codebases is subtler: they're introduced at a distance from where the data is loaded.

A developer adds a method to a serializer that calls order.user.company_name. The association isn't loaded because the serializer isn't where the query is built. The query object that builds the relation doesn't know the serializer exists. The N+1 doesn't show up in development because the seed data has twenty records and the query log isn't open. It shows up in production when the endpoint starts returning 500 records and response time goes from 80ms to 4 seconds.

The structural problem is that includes must be added at the point where the relation is built, but the association is needed at the point where the data is consumed. Those two points are often in different files, written by different developers, at different times.

Finding N+1 queries in development

Query logging. The first tool is the Rails query log, which is on by default in development. An N+1 looks like this in the log:

SELECT * FROM orders WHERE ...
SELECT * FROM users WHERE id = 1
SELECT * FROM users WHERE id = 2
SELECT * FROM users WHERE id = 3
...

One initial query, then the same query pattern repeated with different parameters. The log entry immediately before the repeated queries identifies the code path. Enable verbose query logging with caller context in Rails 7+:

# config/environments/development.rb
config.active_record.verbose_query_logs = true

This adds the file and line number to each query in the log, which eliminates the guesswork about where the query originates.

bullet gem. bullet instruments ActiveRecord and alerts on N+1 queries, unused eager loading, and missing counter_cache columns. Add it to the development group:

# Gemfile
gem "bullet", group: :development

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

Bullet.alert = true raises a JavaScript alert in the browser when an N+1 is detected. This is aggressive but effective — developers notice immediately rather than finding it in a log. In CI, Bullet.raise = true fails the test suite when an N+1 is detected in a request spec.

The caveat: bullet produces false positives. A single-record association access that happens to fire once isn't an N+1 but may be flagged. Review its output critically.

rack-mini-profiler. For request-level investigation, rack-mini-profiler shows total query count, duration, and a query breakdown for every request. A request with 50 queries loading 10 records is the visual that makes N+1 obvious to anyone, including product managers:

gem "rack-mini-profiler", group: :development

No configuration required. A toolbar appears at the top left of every page showing query count and total time.

The three forms of N+1 — and their fixes

Classic association N+1. One query loads a collection; each record triggers a query to load an association:

# N+1 — loads user per order
orders = Order.where(status: :pending)
orders.each { |o| puts o.user.email }

# Fixed
orders = Order.where(status: :pending).includes(:user)
orders.each { |o| puts o.user.email }

includes chooses between preload (two queries: one for orders, one for all users) and eager_load (one JOIN query) based on whether the association is referenced in a where or order. For most cases, preload is the right behavior — two clean queries are more efficient than a large JOIN that duplicates order data per user.

If you need to filter on the association, use eager_load explicitly:

# Filters on association — use eager_load, not includes
Order.eager_load(:user).where(users: { active: true })

Method-based N+1. The association is eager-loaded, but the code calls a method that issues a new query:

orders = Order.includes(:line_items)
orders.each do |order|
  # line_items is preloaded — no N+1
  puts order.line_items.count

  # This calls a new query even though line_items is loaded
  puts order.line_items.where(status: :backordered).count
end

where on an already-loaded association fires a new query instead of filtering in memory. The fix is either to preload the right scope or to filter in Ruby:

# Filter in Ruby on preloaded data
order.line_items.count { |li| li.status == "backordered" }

# Or preload a specific association scope
has_many :backordered_line_items, -> { where(status: :backordered) }, class_name: "LineItem"
Order.includes(:backordered_line_items)

Serializer and presenter N+1. The most common source of production N+1s in mature codebases. A serializer calls a method that wasn't considered when the query was built:

class OrderSerializer < ActiveModel::Serializer
  attributes :id, :total, :user_email, :company_name

  def user_email
    object.user.email  # N+1 if :user not included
  end

  def company_name
    object.user.company.name  # N+1 even if :user is included — :company isn't
  end
end

The fix must happen in the query, not the serializer:

Order.where(status: :pending).includes(user: :company)

The nested hash in includesuser: :company — preloads both levels. For three levels: includes(user: { company: :billing_address }). The includes call must match the association chain the serializer traverses.

The discipline: whenever you add a method to a serializer or presenter that traverses an association, you must update the query that loads the collection. If the query lives in a different class, that's the dependency to track.

Preload vs eager_load vs joins — the differences that matter

Three methods that eager-load associations, with different SQL and different use cases:

preload — two queries, always. One for the primary collection, one for all associated records. No JOIN. Best when you don't filter on the association:

Order.preload(:user)
# SELECT * FROM orders
# SELECT * FROM users WHERE id IN (1, 2, 3, ...)

eager_load — one JOIN query. Required when you filter or sort by association columns in the same query:

Order.eager_load(:user).where(users: { active: true })
# SELECT orders.*, users.* FROM orders LEFT OUTER JOIN users ON ...

The JOIN duplicates order data for users with multiple orders. For associations with high cardinality (many records per parent), this produces large result sets.

joins — adds a JOIN but does not load the association. Records are not available as objects; only the primary model is loaded. Use for filtering only:

Order.joins(:user).where(users: { active: true })
# SELECT orders.* FROM orders INNER JOIN users ON ...
# user attributes NOT loaded — accessing order.user triggers a new query

The common mistake: using joins when you intend eager_load. The query runs without N+1 in isolation, but accessing the association on any record triggers a query. Bullet won't catch this in all cases because the JOIN exists even if the association isn't loaded.

Counter caches for association counts

Counting an association fires a SELECT COUNT(*) query per record:

orders.each { |o| puts o.line_items.count }  # COUNT query per order

includes(:line_items) loads all line items to count them — wasteful if you only need the count. The correct solution is a counter_cache:

class LineItem < ApplicationRecord
  belongs_to :order, counter_cache: true
end

This adds a line_items_count column to orders that Rails keeps updated automatically via callbacks when line items are created or destroyed. Accessing order.line_items.count then reads the cached column with no query:

orders.each { |o| puts o.line_items_count }  # no query — reads cached column

Two caveats: the counter cache is maintained by Rails callbacks, so bulk operations (update_all, delete_all, raw SQL) bypass it and leave the count stale. Use Order.reset_counters(order.id, :line_items) to recalculate when needed. And counter caches don't support scoped counts — you can't have a backordered_line_items_count without implementing it manually.

Preventing N+1 in CI

The most effective long-term control is failing CI when N+1 queries are introduced. Configure bullet in the test environment to raise on detection:

# config/environments/test.rb
config.after_initialize do
  Bullet.enable  = true
  Bullet.raise   = true
end

With Bullet.raise = true, any request spec or system spec that triggers an N+1 fails with a Bullet::Notification::UnoptimizedQueryError. The test failure appears at the point of the request, not hours later in a production APM alert.

This generates false positives that need investigation. The workflow: when a test fails due to Bullet, either fix the N+1, add includes to the query, or add Bullet.whitelist for a case where the "N+1" is actually a deliberate single-record access. Document whitelisted cases.

The fix that compounds over time

The structural fix — beyond includes on specific queries — is making the association dependency explicit at the point of data consumption. Serializers and presenters should document which associations they require:

class OrderSerializer < ActiveModel::Serializer
  # Required associations: :user, user: :company
  attributes :id, :total, :user_email, :company_name
end

This is documentation, not enforcement. The enforcement is Bullet in CI. The combination — documented requirements plus automated detection — catches N+1s at the point they're introduced rather than the point they cause a production incident.

N+1 queries are never fully eliminated from a growing codebase. New serializer methods, new associations, new code paths all introduce them. The goal is detection at development or CI time, not production. That requires instrumentation, not just awareness.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Recovering From a Failed Software Project

“So… what now?” After the dust settles, this is the question every team has to face.

Read more

Why Boston Tech Startups Struggle to Hire Backend Engineers Despite the University Pipeline

Boston mints engineers at an extraordinary rate. The startups trying to hire them are still coming up empty.

Read more

Java Optional — What It's For, What It's Not For, and How to Use It Well

Optional is a return type that signals absence explicitly. It's not a null replacement, not a container to store in fields, and not a way to avoid NullPointerException everywhere. Used correctly, it improves API clarity. Used incorrectly, it adds allocation and verbosity without benefit.

Read more

Austin's Backend Developer Boom Is Cooling — What Startups Are Doing to Keep Shipping

The hiring market that made Austin feel like anything was possible has shifted. Here's how founders are staying lean without stalling out.

Read more