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 includes — user: :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.