Rails N+1 queries, from includes to strict_loading
The N+1 is the Rails performance bug that ships green. Thirty rows in a fixture mean thirty small indexed primary key lookups, and a request spec that asserts on the rendered HTML passes without noticing them. The same page against thirty thousand rows is thirty thousand round trips, and nothing between the two is an error.
So this post is in two halves. The first is what includes, preload and eager_load actually
emit, because the one everybody types is the one with a runtime branch in it. The second is
detection, which is where the framework has more to offer than most people use.
Everything below was run against activerecord 8.1.3.1 on PostgreSQL 17.7, on a two table stand-in
for the LaunchKit boilerplate's referrals and users: a Referral that belongs_to :referrer and
belongs_to :referred, both class_name: "User". Every log line is pasted from that run.
An N+1 query in the log
Referral.order(:id).each { |r| r.referred.email_address } is the shape, and here is what the
adapter sent:
Referral Load SELECT "referrals".* FROM "referrals" ORDER BY "referrals"."id" ASC
User Load SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
User Load SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 3], ["LIMIT", 1]]
User Load SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 4], ["LIMIT", 1]]
One query for the collection, one per row for the association. Three rows here, so four queries.
The tell is not the count, it is the repetition: the same User Load line with the same shape and a
different bind, once per element.
Worth saying what the cost actually is, because "N+1" gets talked about as though it were N times the work. Each of those lookups is a primary key hit and costs almost nothing in the database. What it costs is a network round trip and a result set to instantiate, N times, serially. On the same machine that is a fraction of a millisecond each and the page is merely slow. Across an availability zone at one millisecond of latency, three hundred rows is three hundred milliseconds of a request spent waiting, and the database's own metrics will show nothing wrong at all.
preload writes one query per association
preload is the primitive with no opinion. The guide states it in one line: "With preload, Active
Record loads each specified association using one query per association."
Referral.preload(:referred).order(:id)
Referral Load SELECT "referrals".* FROM "referrals" ORDER BY "referrals"."id" ASC
User Load SELECT "users".* FROM "users" WHERE "users"."id" IN ($1, $2, $3) [["id", 2], ["id", 3], ["id", 4]]
Two queries, always two, whatever else is on the relation. The second is an IN list built from the
ids the first returned, which is why preload cannot be filtered by the parent's conditions: the
guide says so directly, "unlike the includes method, it is not possible to specify conditions for
preloaded associations."
Two queries is usually what you want, and the guide agrees in the includes documentation: "Loading
the associations in a separate query will often result in a performance improvement over a simple
join, as a join can result in many rows that contain redundant data and it performs poorly at
scale." A collection of 200 referrals each pointing at a user means one query returning 200 rows and
one returning at most 200 users, with each user's columns crossing the wire once.
eager_load writes one LEFT OUTER JOIN
eager_load is the other primitive, and it is a join every time:
Referral.eager_load(:referred).order(:id)
SELECT "referrals"."id" AS t0_r0, "referrals"."referrer_id" AS t0_r1, "referrals"."referred_id" AS t0_r2,
"referrals"."status" AS t0_r3, "referrals"."reward_cents" AS t0_r4, "referrals"."created_at" AS t0_r5,
"referrals"."updated_at" AS t0_r6,
"users"."id" AS t1_r0, "users"."email_address" AS t1_r1, "users"."created_at" AS t1_r2,
"users"."updated_at" AS t1_r3
FROM "referrals" LEFT OUTER JOIN "users" ON "users"."id" = "referrals"."referred_id"
ORDER BY "referrals"."id" ASC
The t0_r0 aliases are the signature. Active Record renames every column of every table in the join
so it can slice the flat result back into objects, and once you have seen that prefix in a log you
can identify an eager load at a glance without reading the FROM clause.
Take the join when you need to filter or sort on the associated table in the same statement, which
two queries structurally cannot do. Avoid it on a has_many, where the join multiplies the parent's
columns by the number of children and the guide's own note applies: "Loading the associations in a
join can result in many rows that contain redundant data and it performs poorly at scale." A parent
with a 40KB text column and 50 children ships that column 50 times.
includes vs preload: the branch Active Record picks for you
includes is neither of the two above. includes is a request for the association to be loaded,
and Active Record decides at query build time which strategy that means. The decision is one method
in ActiveRecord::Relation, activerecord 8.1.3.1:
def eager_loading?
@should_eager_load ||=
eager_load_values.any? ||
includes_values.any? && (joined_includes_values.any? || references_eager_loaded_tables?)
end
True picks the join, false picks the two queries. includes(:referred) on its own is false, so it
behaves exactly like preload. Add a condition that names the association's table and
references_eager_loaded_tables? flips it, and the same call becomes the join.
A hash condition does that by itself, because where registers the reference for you:
Referral.includes(:referred).where(users: { email_address: "u1@example.com" })
SELECT "referrals"."id" AS t0_r0, ... "users"."updated_at" AS t1_r3
FROM "referrals" LEFT OUTER JOIN "users" ON "users"."id" = "referrals"."referred_id"
WHERE "users"."email_address" = $1
Same includes, different SQL, and nothing in the source says which one you are getting. That is
the distinction people get wrong, and the reason it matters is not aesthetic. A code change three
files away that adds a condition on the joined table silently converts every list page using that
scope from two queries into a join, and on a has_many that is the row multiplication above.
If you know which shape you want, ask for it. Write preload when you mean two queries and
eager_load when you mean the join. Keep includes for the case where the strategy genuinely
should follow the conditions, and expect to explain it in review.
The SQL fragment that turns the branch into an error
A string condition is where the convenience runs out. where cannot parse a fragment, so it
registers no reference, eager_loading? stays false, and the relation goes out as a plain
two query preload with a WHERE clause naming a table that is not in the FROM:
Referral.includes(:referred).where("users.email_address = ?", "u1@example.com")
Referral Load SELECT "referrals".* FROM "referrals" WHERE (users.email_address = $1)
ActiveRecord::StatementInvalid: PG::UndefinedTable: ERROR: missing FROM-clause entry for table "users"
The guide's instruction is six words: "For SQL-fragments you need to use references to force
joined tables." Adding .references(:users) puts users in references_values, eager_loading?
turns true, and the same statement comes back as the LEFT OUTER JOIN with the fragment interpolated
into its WHERE.
Note the asymmetry, since it is the part that surprises people: references takes the table name
and includes takes the association name. On this model those differ, includes(:referred) and
references(:users), because both associations point at the same table under different names.
This failure is at least loud. A 500 in development with the table name in the message is the best outcome in this whole post, and it is the only one on the page that cannot reach production unnoticed.
strict_loading raises, and the error class is StrictLoadingViolationError
Detection has an answer in the framework, and it predates every gem people reach for. Marking a
relation strict_loading turns a lazy association load from a silent query into an exception:
Referral.strict_loading.first.referred
ActiveRecord::StrictLoadingViolationError: `Referral` is marked for strict_loading.
The User association named `:referred` cannot be lazily loaded.
The message is built by ActiveRecord::Reflection::AssociationReflection#strict_loading_violation_message,
which names the owner class and the association, and says "polymorphic association" instead of the
class name when the reflection is polymorphic. That is the string to grep your logs for.
Four places can turn it on, and they are different sizes of commitment. On one association,
belongs_to :referred, class_name: "User", strict_loading: true, which leaves every other
association on the model lazy. On one record, record.strict_loading!. On one relation,
Model.strict_loading. And on a model, self.strict_loading_by_default = true, the
class_attribute declared in active_record/core.rb, which every subclass inherits.
strict_loading! also takes a mode: record.strict_loading!(mode: :n_plus_one_only) raises only
when the lazily loaded association would itself cause an N+1, which in practice means a has_many
walked from an already preloaded collection. Anything other than :all or :n_plus_one_only raises
ArgumentError, "The :mode option must be one of [:all, :n_plus_one_only] but ... was provided."
Turning strict loading on for the whole application
Application-wide is one line, and the guide names it: "To enable for all relations, change the
config.active_record.strict_loading_by_default flag to true."
# config/environments/development.rb and config/environments/test.rb
config.active_record.strict_loading_by_default = true
Raising is not the only option. ActiveRecord.action_on_strict_loading_violation defaults to
:raise and accepts :log, and the branch in active_record/core.rb is worth reading before you
choose:
def self.strict_loading_violation!(owner:, reflection:)
case ActiveRecord.action_on_strict_loading_violation
when :raise
message = reflection.strict_loading_violation_message(owner)
raise ActiveRecord::StrictLoadingViolationError.new(message)
when :log
name = "strict_loading_violation.active_record"
ActiveSupport::Notifications.instrument(name, owner: owner, reflection: reflection)
end
end
Note that :log does not write a log line. It instruments a notification, and something has to
subscribe to strict_loading_violation.active_record for anything to appear. That is the honest
migration path for an existing application: strict_loading_by_default = true with
action_on_strict_loading_violation = :log in development, a subscriber that counts, and a week of
reading the counts before you switch to :raise.
The cost of turning this on globally is real and worth stating. Every lazy association load in the
codebase becomes a failure, including the ones in a to_json, in a mailer, in an admin page nobody
has opened this quarter. You will spend a day adding preload calls, and one of them will be in a
model callback you did not know existed. There is a carve-out worth knowing about: the Rails
documentation states "Strict loading is disabled during validation in order to let the record
validate its association", so a validates_associated will not blow up under it.
The includes that does nothing, because the view goes another way
The first thing that does not work is an eager load aimed at the wrong association, and it is the
one that survives review. The controller eager loads the association it can see, the view reaches
for a different one, and the includes line reads as though the page is covered:
Referral.includes(:referred).order(:id).each { |r| r.referrer.email_address }
Referral Load SELECT "referrals".* FROM "referrals" ORDER BY "referrals"."id" ASC
User Load SELECT "users".* FROM "users" WHERE "users"."id" IN ($1, $2, $3)
User Load SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
User Load SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
User Load SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Five queries where there were four without any eager loading at all. The preload ran, loaded the
wrong association, and the N+1 happened underneath it. Both associations point at users, so a
reviewer skimming the controller sees includes and a page that renders an email address and
concludes the join is covered.
The nastier version of the same shape needs no second association. Preload a has_many, then let a
partial walk back to the owner:
User.preload(:referrals_made).first.referrals_made.each { |r| r.referrer.email_address }
User Load SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1
Referral Load SELECT "referrals".* FROM "referrals" WHERE "referrals"."referrer_id" = $1
User Load SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
User Load SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
User Load SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
The same user, three times, by primary key, when the object was already in memory as the receiver.
Rails automatic inverse detection matches on names, and these names do not match:
Referral.reflect_on_association(:referrer).inverse_of returns nil, because the has_many is
called :referrals_made and the belongs_to is called :referrer. Declaring
inverse_of: :referrer on the has_many and inverse_of: :referrals_made on the belongs_to
removes all three queries.
Both of these are caught by strict_loading, which is the argument for it in one line. Strict
loading propagates from the relation into the records it preloaded, so
User.strict_loading.preload(:referrals_made).first.referrals_made.each { |r| r.referrer } raises
"Referral is marked for strict_loading. The User association named :referrer cannot be lazily
loaded." on the first row instead of quietly running three lookups.
The count that fires once per row despite the preload
The second dead end is a method call, and it is the one preloading cannot save you from:
User.preload(:referrals_made).order(:id).limit(3).each { |u| u.referrals_made.count }
User Load SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1 [["LIMIT", 3]]
Referral Load SELECT "referrals".* FROM "referrals" WHERE "referrals"."referrer_id" IN ($1, $2, $3)
Referral Count SELECT COUNT(*) FROM "referrals" WHERE "referrals"."referrer_id" = $1 [["referrer_id", 1]]
Referral Count SELECT COUNT(*) FROM "referrals" WHERE "referrals"."referrer_id" = $1 [["referrer_id", 2]]
Referral Count SELECT COUNT(*) FROM "referrals" WHERE "referrals"."referrer_id" = $1 [["referrer_id", 3]]
The preload worked. Every referral is in memory. count asks the database anyway, because
count means SELECT COUNT(*) and always has. Swap it for size and the last three lines vanish:
size returns the length of the loaded array when the association is loaded and counts in SQL when
it is not, which is the behaviour people assume count has.
Now the part that matters for the previous section. strict_loading does not raise here:
User.strict_loading.preload(:referrals_made).first.referrals_made.count
# => Referral Count SELECT COUNT(*) FROM "referrals" WHERE "referrals"."referrer_id" = $1
No exception. The association was loaded, so nothing was lazily loaded, so no violation occurred by
the framework's definition. A calculation on a loaded association is simply not what the check is
looking at. The same hole swallows a scope:
user.referrals_made.where(status: "pending") on a strict_loading record runs a second
Referral Load with the extra condition and raises nothing, because a scoped association proxy
builds a new relation rather than lazily loading the old one. Filter in Ruby with select when the
whole collection is already in memory, and reach for SQL only when it is not.
When the count is what the page actually renders and the collection is not, the answer is neither
size nor a preload, it is a counter cache column, which
counter caches by hand takes apart.
The gems, and whether they are still maintained
Two gems own this space and they detect different things, so "which one" is the wrong question.
Bullet 8.2.0, released 2026-08-29, MIT, and actively maintained: the version bump is the head
commit and the two merges under it are an N+1 false positive fix and Mongoid compatibility. It
watches associations and reports three conditions, each with its own flag: N+1 queries
(Bullet.n_plus_one_query_enable), "when you're using eager loading that isn't necessary"
(Bullet.unused_eager_loading_enable), and "when you should use counter cache"
(Bullet.counter_cache_enable). Unused eager loading is the one nothing else offers, and it is the
direct answer to the section above about the includes that loaded the wrong association.
Prosopite 2.2.0, released 2026-04-16, Apache-2.0, last commit 2026-04-16 and a quieter project than Bullet. Different mechanism entirely: it "monitors all SQL queries using the Active Support instrumentation and looks for the following pattern which is present in all N+1 query cases: More than one queries have the same call stack and the same query fingerprint." Because it reasons about SQL rather than about associations, it catches N+1s that are not association loads at all, which is where the README's claim of "zero false positives / false negatives" comes from. It says nothing about unused eager loading, because it never looks at an association.
Prosopite.raise = true turns its warnings into exceptions and Prosopite.rails_logger = true
sends them to the Rails log, and both a Rack middleware and a Sidekiq middleware ship with it.
What the boilerplate does about N+1
The LaunchKit boilerplate takes the Prosopite route and wires it to fail the suite.
config/environments/test.rb mounts the middleware and arms it:
require "prosopite/middleware/rack"
config.middleware.use(Prosopite::Middleware::Rack)
config.after_initialize { Prosopite.raise = true }
config/environments/development.rb mounts the same middleware with
Prosopite.rails_logger = true instead, so development logs what test raises on. The gem sits in
the :test group next to pg_query, which is what gives it SQL fingerprinting on PostgreSQL, and
is never loaded in production. Because the middleware scans an HTTP request rather than the whole
example, factory setup that creates records in a loop does not trip it and a controller or view that
loops does.
Scope stated plainly: four files in the product call includes, and none calls preload,
eager_load or strict_loading anywhere. strict_loading_by_default is not set. The four are
app/controllers/referrals_controller.rb, app/controllers/admin/referrals_controller.rb,
app/controllers/admin/support_tickets_controller.rb and app/services/email_sequences/deliver.rb.
The one worth reading is the referrals hub, because the two lines after the includes are where
this post's material shows up in real code:
def show
@user = Current.user
@referrals = @user.referrals_made.includes(:referred).order(created_at: :desc)
@converted_count = @referrals.count(&:converted?)
@rewards_cents = @referrals.sum(&:reward_cents)
@referral_url = root_url(ref: @user.referral_code)
end
count(&:converted?) is the block form, and ActiveRecord::Relation#count calls super() into
Enumerable when a block is given, which loads the relation and counts in Ruby. So the includes
fires here, once, and @referrals.sum(&:reward_cents) and the view's @referrals.size both read
the array that is already in memory. Two queries for the list, whatever the referral count is: the
referrals, then the users.
Rewriting either of those two lines into what looks like better Active Record breaks it.
@referrals.converted.count would be a fourth query and, worse, would leave @referrals unloaded
at that point so the view's size becomes a fifth. The spelling that looks least idiomatic is the
one that is correct, and only the query log says so.
The comment in config/environments/development.rb points at spec/support/prosopite.rb for the
raising configuration, and that file does not exist. The configuration is in test.rb, shown above.
Read the environment file, not the comment.
The call, and what would flip it
Set config.active_record.strict_loading_by_default = true in a new application on day one, in
development and test, and ship without a detection gem. Nothing beats an exception at the moment of
the mistake, it costs no dependency, and on an empty codebase the migration cost this post warned
about is zero because there is nothing to migrate.
For an existing application, that order reverses. Add Prosopite first, arm it in test the way the boilerplate does, and let a red suite tell you where the N+1s are before you consider a global flag that will turn every one of them into a failure at once.
Bullet earns its place on a codebase old enough to have accumulated eager loading nobody audits,
because unused eager loading is a real cost and is the one thing neither strict_loading nor
Prosopite can see. Running Bullet and Prosopite together is defensible for a week and tiresome after
that.
What would flip the first recommendation: a strict_loading mode that also covered a calculation on
a loaded association would close the count hole and remove the strongest argument for keeping a
query-log-based tool alongside it. What would flip the second: a Prosopite release that went quiet
for a year, since its whole value is a fingerprinting heuristic that has to keep up with the
adapters.
What this post does not cover
Absent on purpose: ActiveRecord::Associations::Preloader, the object underneath preload, which is
callable directly and is the right tool when the records are already in an array rather than a
relation. Also has_many :through and polymorphic preloading, where preload runs one query per
concrete type and the arithmetic in this post stops holding.
No timings appear anywhere above, only query counts and SQL. A millisecond figure from a local socket would understate the only thing that makes an N+1 expensive, which is latency, and a figure from any particular deployment would not be yours. Count the queries, then measure your own round trip.
Also absent: the GraphQL dataloader pattern, which solves the same problem one layer up and by a different mechanism, and query log analysis in production, where running Rails 8 without Redis is the adjacent decision about what your database is already carrying before you add N more round trips to it.
Comments
No comments yet. Be the first.