Ruby on Rails Module 11 – Active Record in depth
N+1 queries, eager loading and strict loading
Measure how a plain loop runs 10 SELECTs, cut it to 2 with includes and preload or 1 with eager_load, then stop regressions with strict_loading.
What you will learn
- Measure the queries a loop sends by subscribing to sql.active_record
- Choose between includes, preload and eager_load from the shape of the SQL
- Catch regressions with strict_loading, including the n_plus_one_only mode
- Add a query-count assertion to a test, so a fix cannot regress silently
Before you start
What you’ll learn · Before you start
What you will learn
- Measure the queries a loop sends by subscribing to sql.active_record
- Choose between includes, preload and eager_load from the shape of the SQL
- Catch regressions with strict_loading, including the n_plus_one_only mode
- Add a query-count assertion to a test, so a fix cannot regress silently
Before you start
On this page
A teacher opens the Kaksha dashboard, which lists nine open batches with the teacher’s name beside each one. The page
sends ten SELECTs for a list of nine: one for the batches, nine more—one per row—to load batch.teacher.
That is the N+1 query: Reading a list with one query and then one more query per row of it, usually by following an association inside a loop; preloading the association turns the many queries into a few. More in “Associations” query problem, and this lesson measures it on a real schema, cuts the ten
down to 2 or 1 with includes, preload and eager_load, catches the regression at the model with
strict_loading, and ends with a query-count assertion the next pull request has to keep green.
Every number in the text comes from a run on Ruby 4.0.7 and Rails 8.1.4 against SQLite in-memory, so the counts you read are the counts you get when you run the examples.
A loop over nine batches sends 10 queries
The dashboard is three Ruby lines: load the open batches, iterate, print the teacher name of each. On a plain
Batch.all the loop reads one SELECT to fetch the nine rows and then one more SELECT per row to load the belongs_to
association, because batch.teacher is a method that queries the database the first time it is called. Nine batches
make nine extra reads, which is why the pattern is called N+1: one query up front, then N more.
The next run does exactly that against a tiny schema (one teacher who owns nine batches), with a subscriber that
counts every statement the sql.active_record notification announces. The subscriber ignores schema reads, the query
cache and transaction boundaries, so what it counts is the SELECTs the loop sent:
# frozen_string_literal: true
# Reads 9 batches four ways and counts the SELECTs each one sends, by
# subscribing to the "sql.active_record" notification. The four reads build
# the same list on the page but take 10, 2, 2 and 1 query respectively.
require "active_record"
require "active_support/notifications"
require "logger"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = nil
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :teachers do |t|
t.string :name
end
create_table :batches do |t|
t.string :name
t.integer :teacher_id
end
end
class Teacher < ActiveRecord::Base
has_many :batches
end
class Batch < ActiveRecord::Base
belongs_to :teacher
end
teacher = Teacher.create!(name: "Asha")
9.times { |i| Batch.create!(name: "Batch #{i + 1}", teacher: teacher) }
def count_queries(label)
statements = []
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
next if payload[:name] == "SCHEMA" || payload[:cached]
next if payload[:sql].start_with?("PRAGMA") || payload[:sql].start_with?("BEGIN") || payload[:sql].start_with?("COMMIT")
statements << payload[:sql]
end
yield
ensure
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
puts "#{label}: #{statements.size} queries"
statements.each { |s| puts " #{s}" }
puts
end
count_queries("plain .all") do
Batch.all.each { |b| b.teacher.name }
end
count_queries(".includes(:teacher)") do
Batch.includes(:teacher).each { |b| b.teacher.name }
end
count_queries(".preload(:teacher)") do
Batch.preload(:teacher).each { |b| b.teacher.name }
end
count_queries(".eager_load(:teacher)") do
Batch.eager_load(:teacher).each { |b| b.teacher.name }
end Output
plain .all: 10 queries SELECT "batches".* FROM "batches" SELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? LIMIT ? SELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? LIMIT ? SELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? LIMIT ? SELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? LIMIT ? SELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? LIMIT ? SELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? LIMIT ? SELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? LIMIT ? SELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? LIMIT ? SELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? LIMIT ? .includes(:teacher): 2 queries SELECT "batches".* FROM "batches" SELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? .preload(:teacher): 2 queries SELECT "batches".* FROM "batches" SELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? .eager_load(:teacher): 1 queries SELECT "batches"."id" AS t0_r0, "batches"."name" AS t0_r1, "batches"."teacher_id" AS t0_r2, "teachers"."id" AS t1_r0, "teachers"."name" AS t1_r1 FROM "batches" LEFT OUTER JOIN "teachers" ON "teachers"."id" = "batches"."teacher_id"
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby n_plus_one.rb
Reading the recorded output section by section:
plain .allsent 10 queries: oneSELECT "batches".*for the list of nine rows, then nine identicalSELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? LIMIT ?statements, one per batch. Each follow-up read the association from the row’steacher_id, which costs a round trip..includes(:teacher)sent 2 queries: the same list, then oneSELECT "teachers".* ... WHERE "teachers"."id" = ?that loads the teacher once. Nine lazy reads became a bound value filled in from the batches’ ids before the loop runs..preload(:teacher)sent the same 2 queries asincludes; the two methods are the same shape when nothing in the chain references the teacher’s columns..eager_load(:teacher)sent 1 query: aLEFT OUTER JOINwith the aliased columnst0_r0,t0_r1,t1_r0,t1_r1, which rebuilds the two models from one row of the result set.
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Request starts. The dashboard renders. The view asks for every open batch and starts a loop over the rows that come back.
- Batches load. Active Record sends one SELECT and loads the nine open batches as Ruby objects. The counter sits at 1 query so far.
- First row reads b.teacher. The loop reaches row 1 and calls b.teacher. The belongs_to reader is lazy, so Active Record sends a SELECT with the row's teacher_id. The counter climbs to 2.
- Row 2 through 9. Each remaining row runs the same lookup. Nine rows send nine SELECTs to the database, one at a time, each waiting on the previous one.
- Ten SELECTs for a nine-row list. One query for the list and nine for the teachers brings the total to 10. The eager-load forms turn this walk into 2 queries (preload) or 1 (eager_load with a LEFT OUTER JOIN).
The scene walks the plain run in order: a loader asks for the batches, the view iterates, each row’s teacher reader
sees no cached association and sends another SELECT. The counts move from 1 to 10 as the loop goes, which is why
“ten queries for a nine-row page” is the shape of the bug when you spot it in a log.
A page that works in test can still N+1 in production
Batch.all.each { |b| b.teacher.name } returns the right names in every environment. The cost is invisible in a
seeded development database (nine batches load before anyone notices) and shows up only when a page lists 200 rows to many
visitors at once, 201 round trips each. Count the queries, not the result.
Checkpoint
Check: The N+1 pattern
Three plans: includes, preload and eager loading
Three methods on a relation: A lazy query plan on an Active Record model that chains with where, order, group and friends; the SELECT runs only when a loader asks for rows. More in “Querying: relations, scopes and calculations” turn the ten queries into two or one. They all accept the same argument shapes (an association name, an array of names, a hash for nested associations), and they differ only in how Active Record builds the SQL:
includes(:teacher)is the one to reach for first. It picks between preload: An Active Record query method that loads an association with a second SELECT and an IN list of the parents' ids; it cannot filter or sort by the children's columns because the first SELECT has no join. (two SELECTs) and eager_load: An Active Record query method that loads an association through a LEFT OUTER JOIN, so a where or an order can use the children's columns; a condition on a child column also decides which children are loaded. (one SELECT with a LEFT OUTER JOIN) by looking at the rest of the relation: a plain chain uses preload; a chain that references the association’s columns inwhere,orderor a joins clause switches to eager_load, because the database needs the join to apply the condition.preload(:teacher)always sends two SELECTs: one for the parents, one withWHERE "teachers"."id" IN (...)for the children. It cannot filter or sort by the children’s columns (there is only one query for them, and the parents’ SELECT does not know about them), so apreload(:teacher).where("teachers.name = ?")is an error.eager_load(:teacher)sends a SELECT with a LEFT OUTER JOIN and aliased columns. It can filter and sort by the association’s columns, and it is the right call for a report that picks parents by a child column. On a has_many the join returns one row per child, and Active Record folds those rows back into one parent with its children.
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Form | Queries sent | Filter by child column | Children stay complete | Fits a small parent set |
|---|---|---|---|---|
| No eager load What it reads: parents row by row | Poor 1 + N on a list of N parents (10 queries for 9 batches here) | Good Any where works, but each child is still a lazy read | Good Each parent lazily reads all of its children | Mixed Fine on 1 or 2 rows; the pattern slips in later |
| includes(:assoc) What it reads: picks preload or eager_load | Good 2 as preload, 1 as eager_load; Rails picks on its own | Good Hash condition parses; a SQL fragment needs references | Mixed Complete unless a where on the children turns it into a join | Good Works for any parent count; reach for this first |
| preload(:assoc) What it reads: parents, then IN list | Good Always 2 SELECTs, one per association listed | Poor No join on the second SELECT, so a where on child errors | Good Every child of each parent, from a second SELECT | Good Two round trips, both small and bound by id |
| eager_load(:assoc) What it reads: parents LEFT JOIN children | Good 1 SELECT with LEFT OUTER JOIN and column aliases | Good where and order on child columns both apply | Mixed A where on child columns trims the children each parent gets | Mixed Join grows with children; costly on wide has_many |
How the answers decide
- “Does the where or order mention a column of the child association?” Yes, I filter or sort by a child column: rules out preload(:assoc). preload sends a separate SELECT for the children with no join, so a where on a child column errors (Rails guide §16.3).
- “Does the where or order mention a column of the child association?” No, I only touch the parent columns: adds a caveat to eager_load(:assoc). A LEFT OUTER JOIN works, but preload is lighter when nothing filters by the children's columns.
- “Does the where or order mention a column of the child association?” Yes, I filter or sort by a child column and “Must the page show every child of each parent it lists?” Yes, all of their children: adds a caveat to eager_load(:assoc) and includes(:assoc). A where on the children trims what the join loads, so each parent comes back with only the matching children; pick the parents with joins and preload their children.
- “Is the association a has_many that may return many children per parent?” No, belongs_to or has_one: adds a caveat to eager_load(:assoc). belongs_to and has_one join one row per parent, so nothing is multiplied; two bound SELECTs do the same job with simpler SQL.
- “Is the association a has_many that may return many children per parent?” Yes, has_many of children and “Does the where or order mention a column of the child association?” Yes, I filter or sort by a child column: adds a caveat to eager_load(:assoc). eager_load is the only form that loads and filters by a child column in one plan, and it loads only the children that match.
The table answers “which one fits my chain?” from three questions: do I filter by the child’s columns, is it a has_many, and must every child of each parent appear? Row by row it reads the Rails Query Interface guide back as behaviour: the preload row’s “cannot filter by children” is section 16.3 of the guide, and the eager_load row’s “can specify conditions” is section 16.4.
What references adds is small enough to be worth seeing: when the chain mentions the child’s columns in a SQL
fragment (not a hash), Rails has to be told to join. The guide shows the two forms side by side:
# frozen_string_literal: true
# Two forms of a filter on a child's column, so includes knows what to do.
# Show-only snippet; see examples/n_plus_one.rb for a runnable file.
# Hash form: Active Record parses it and switches includes to eager_load.
Batch.includes(:teacher).where(teachers: { name: "Asha" })
# SQL fragment: Active Record cannot see the reference, so .references(:teacher)
# is needed to force the join; without it, the preload shape runs and the SQL
# fragment misses the join entirely.
Batch.includes(:teacher).where("teachers.name = ?", "Asha").references(:teacher) Line 1 is a hash condition, which Rails parses, so it switches to eager_load on its own. Line 2 is a SQL string, so
.references(:teacher) is needed; without it, Rails runs the preload shape and the SQL fragment misses the join. The
includes method is a convenience: it gets the common cases right, and in the rare chain it does not, the two
primitives (preload, eager_load) are a line away.
A has_many raises two questions about eager_load: does a limit count parents or joined rows, and what does a
condition on the children do to the loaded association? The next run asks both, with three teachers who own three
batches each:
# frozen_string_literal: true
# eager_load on a has_many: does a LIMIT count teachers or joined rows, and what does a
# condition on the batches' columns do to the loaded association? Three teachers own three
# batches each. A subscriber on sql.active_record prints the statements Active Record sends.
require "active_record"
ActiveRecord::Schema.verbose = false
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.define do
create_table(:teachers) { |t| t.string :name, null: false }
create_table(:batches) do |t|
t.integer :teacher_id, null: false
t.string :name, null: false
end
end
class Teacher < ActiveRecord::Base
has_many :batches
end
class Batch < ActiveRecord::Base
belongs_to :teacher
end
%w[Asha Ravi Meena].each do |name|
teacher = Teacher.create!(name: name)
%w[Science Maths English].each { |subject| teacher.batches.create!(name: "#{name} #{subject}") }
end
log = []
ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
log << payload[:sql] unless payload[:name] == "SCHEMA"
end
show = lambda do |title, relation|
log.clear
teachers = relation.to_a
puts "#{title}: #{teachers.size} teachers, #{teachers.sum { |t| t.batches.size }} batches"
log.each { |sql| puts " #{sql}" }
end
show.call("eager_load(:batches).limit(2)", Teacher.eager_load(:batches).order(:id).limit(2))
show.call("preload(:batches).limit(2)", Teacher.preload(:batches).order(:id).limit(2))
filtered = Teacher.eager_load(:batches).where(batches: { name: ["Asha Science", "Ravi Maths"] }).order(:id).to_a
puts "eager_load + where on batches: " +
filtered.map { |t| "#{t.name} #{t.batches.size} of #{Batch.where(teacher_id: t.id).count}" }.join(", ") Output
eager_load(:batches).limit(2): 2 teachers, 6 batches SELECT DISTINCT "teachers"."id" FROM "teachers" LEFT OUTER JOIN "batches" ON "batches"."teacher_id" = "teachers"."id" ORDER BY "teachers"."id" ASC LIMIT ? SELECT "teachers"."id" AS t0_r0, "teachers"."name" AS t0_r1, "batches"."id" AS t1_r0, "batches"."teacher_id" AS t1_r1, "batches"."name" AS t1_r2 FROM "teachers" LEFT OUTER JOIN "batches" ON "batches"."teacher_id" = "teachers"."id" WHERE "teachers"."id" IN (?, ?) ORDER BY "teachers"."id" ASC preload(:batches).limit(2): 2 teachers, 6 batches SELECT "teachers".* FROM "teachers" ORDER BY "teachers"."id" ASC LIMIT ? SELECT "batches".* FROM "batches" WHERE "batches"."teacher_id" IN (?, ?) eager_load + where on batches: Asha 1 of 3, Ravi 1 of 3
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby eager_load_limit.rb
With limit(2), eager_load sent two queries, not one: a SELECT DISTINCT "teachers"."id" ... LIMIT ? to pick two
teachers, then the join for those two ids, so the limit counted teachers and all six of their batches arrived. The
condition is the real trap: where(batches: { name: [...] }) kept Asha and Ravi but loaded only 1 of their 3 batches
each, because the WHERE filters the joined rows the association is built from.
A condition on the children trims what eager_load loads
Teacher.eager_load(:batches).where(batches: { name: ... }) picks the right teachers and then shows them with only
the matching batches. When a page must list every batch of those teachers, filter the teacher ids with joins
first, as the guide recommends, then run preload(:batches) against that id list in a second query.
Checkpoint
Check: includes, preload and eager_load
Strict loading stops regressions at the model
Eager loading removes an N+1 only where you remembered to add it. The call three months later—a presenter that
reads batch.teacher.phone on a page the designer forgot about—goes back to one query per row, and no one
notices until the page gets slow. strict_loading: A mode that raises ActiveRecord::StrictLoadingViolationError on any lazy association read, so a page that forgot an includes stops at the first lazy call rather than at the first slow user; set on a relation, a record, an association or an environment. turns the lazy load into an error, so the regression stops at the
first missed call rather than at the first slow user.
Four places to turn it on, each a line:
Batch.strict_loading.find_each { ... }on a relation.record.strict_loading!on an already loaded record (strict_loading!(mode: :n_plus_one_only)for the softer mode below).has_many :batches, strict_loading: trueon an association, so a lazy read of that one association raises on every record, wherever the record came from.config.active_record.strict_loading_by_default = truein an environment, so every relation is strict unless the code opts out. Setconfig.active_record.action_on_strict_loading_violation = :logto send violations to the log instead of raising, while the app is being made safe one page at a time.
The strict mode :n_plus_one_only is the softer rule: the record itself may still load its associations lazily, but
every record that one of its has_many associations brings back is marked strict, so lazily loading anything from those
children raises. It is the one to reach for first, because it rules out the pattern that actually hurts (one query
per child) without flagging a single lazy load on the parent.
The recorded run shows both modes:
# frozen_string_literal: true
# Shows ActiveRecord::StrictLoadingViolationError raised on a lazy load,
# and the :n_plus_one_only mode that lets a single collection read go through
# but still raises when the loop reaches a child association. Each error is
# caught, so the script exits 0.
require "active_record"
require "logger"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = nil
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :teachers do |t|
t.string :name
end
create_table :batches do |t|
t.string :name
t.integer :teacher_id
end
create_table :students do |t|
t.string :name
t.integer :batch_id
end
end
class Teacher < ActiveRecord::Base
has_many :batches
end
class Batch < ActiveRecord::Base
belongs_to :teacher
has_many :students
end
class Student < ActiveRecord::Base
belongs_to :batch
end
teacher = Teacher.create!(name: "Asha")
3.times do |i|
batch = Batch.create!(name: "Batch #{i + 1}", teacher: teacher)
2.times { |j| Student.create!(name: "Student #{i + 1}-#{j + 1}", batch: batch) }
end
puts "1. .strict_loading relation: a lazy belongs_to raises"
begin
Batch.strict_loading.find_each { |b| b.teacher.name }
rescue ActiveRecord::StrictLoadingViolationError => e
puts " #{e.class}"
puts " #{e.message}"
end
puts
puts "2. strict_loading!(mode: :n_plus_one_only): one collection read is allowed"
teacher_rec = Teacher.first
teacher_rec.strict_loading!(mode: :n_plus_one_only)
puts " teacher.batches.length = #{teacher_rec.batches.length}"
puts
puts "3. :n_plus_one_only still raises on the child collection"
begin
teacher_rec.batches.first.students.to_a
rescue ActiveRecord::StrictLoadingViolationError => e
puts " #{e.class}"
puts " #{e.message}"
end Output
1. .strict_loading relation: a lazy belongs_to raises ActiveRecord::StrictLoadingViolationError `Batch` is marked for strict_loading. The Teacher association named `:teacher` cannot be lazily loaded. 2. strict_loading!(mode: :n_plus_one_only): one collection read is allowed teacher.batches.length = 3 3. :n_plus_one_only still raises on the child collection ActiveRecord::StrictLoadingViolationError `Batch` is marked for strict_loading. The Student association named `:students` cannot be lazily loaded.
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby strict_loading.rb
Reading the trace in order:
Batch.strict_loading.find_each { |b| b.teacher.name }raisedActiveRecord::StrictLoadingViolationErrorwith the message “Batchis marked for strict_loading. The Teacher association named:teachercannot be lazily loaded.” The relation marks every record it loads, and the first lazyb.teacheris the one that fails. In a test suite the error points at the exact line that forgot anincludes.teacher.strict_loading!(mode: :n_plus_one_only)thenteacher.batches.lengthreturned 3: the teacher’s own lazy read is allowed, because loading its batches once is not an N+1.teacher.batches.first.students.to_araised the violation again, because the batches came back through a has_many and are marked strict, and.studentson one of them would be the second step of the N+1 chain. The error names the child association and the model that owns it, so a stack frame reaches straight to the view that pulled it.
Key idea
strict_loading moves the N+1 bug from an operations problem into a code problem: the test suite that would have
shipped a slow page now stops at the line that forgot to eager-load.
Checkpoint
Check: strict_loading at the model
A query budget your tests enforce
A fix stays a fix only when a test counts the queries. ActiveSupport::Notifications.subscribed is the one to reach
for: it attaches a subscriber only while a block runs and unsubscribes on the way out, so a leftover subscriber from
one test cannot colour the counts of the next. Wrap it in a Minitest assertion for a query budget: A test assertion that counts SELECTs sent during a block, usually with ActiveSupport::Notifications.subscribed on sql.active_record, so a page that regresses from two queries to many fails CI before a user sees it., and a page that
lists batches with teachers is one budget away from a regression that fails CI instead of slowing production:
# frozen_string_literal: true
# A query-budget helper written as a Minitest assertion, built on
# ActiveSupport::Notifications.subscribed, which attaches a subscriber only
# while the block runs. The script runs two assertions: one that passes
# (the block uses .includes(:teacher)) and one that fails on purpose. The
# failure is caught and printed so the script exits 0.
require "active_record"
require "active_support/notifications"
require "minitest"
require "logger"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = nil
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :teachers do |t|
t.string :name
end
create_table :batches do |t|
t.string :name
t.integer :teacher_id
end
end
class Teacher < ActiveRecord::Base
has_many :batches
end
class Batch < ActiveRecord::Base
belongs_to :teacher
end
teacher = Teacher.create!(name: "Asha")
9.times { |i| Batch.create!(name: "Batch #{i + 1}", teacher: teacher) }
class QueryBudget
include Minitest::Assertions
attr_accessor :assertions
def initialize
@assertions = 0
end
def assert_queries_at_most(budget, &block)
count = 0
callback = ->(*, payload) do
next if payload[:name] == "SCHEMA" || payload[:cached]
next if payload[:sql].start_with?("PRAGMA", "BEGIN", "COMMIT")
count += 1
end
ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block)
assert count <= budget,
"Expected at most #{budget} SELECTs, saw #{count}."
end
end
budget = QueryBudget.new
puts "Case A: .includes(:teacher) under a budget of 3"
begin
budget.assert_queries_at_most(3) do
Batch.includes(:teacher).each { |b| b.teacher.name }
end
puts " passed"
rescue Minitest::Assertion => e
puts " failed: #{e.message}"
end
puts
puts "Case B: naive loop under the same budget of 3"
begin
budget.assert_queries_at_most(3) do
Batch.all.each { |b| b.teacher.name }
end
puts " passed"
rescue Minitest::Assertion => e
puts " failed: #{e.message}"
end Output
Case A: .includes(:teacher) under a budget of 3 passed Case B: naive loop under the same budget of 3 failed: Expected at most 3 SELECTs, saw 10.
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby query_budget_test.rb
The recorded run calls the helper twice. Case A wraps Batch.includes(:teacher).each { |b| b.teacher.name } in
assert_queries_at_most(3): the subscriber counts the two SELECTs the eager load sends, the assertion holds, and the
case prints “passed”. Case B calls the same assertion around the naive loop; the subscriber counts ten SELECTs
and the assertion fails with the message “Expected at most 3 SELECTs, saw 10.” which is the exact line a failing
test prints when a page starts N+1-ing on main.
Rails has query assertions of its own, which rails/test_help adds to every test case of an app:
assert_queries_count(2) { ... } passes only when the block sends exactly two queries, and assert_no_queries
expects none. An exact count fails on a harmless extra query as readily as on an N+1, and neither one takes an upper
bound, so a budget of “at most 3” needs a small helper like this one.
The subscriber ignores three kinds of events on purpose: payload[:name] == "SCHEMA" for queries Rails sends about
the catalogue (not the app), payload[:cached] for reads served from the query cache (they take no round trip), and
statements starting with PRAGMA, BEGIN or COMMIT, which are configuration or transaction boundaries. That
leaves the actual SELECTs the application sent, which is what the budget is about.
Three rules keep a query-budget test honest:
- Budget per request, not per query: a page is one assertion of “at most 5 SELECTs end to end”, not “at most 1 SELECT for the batches and 1 for the teachers”. A number per query turns into bookkeeping; a number per request names the invariant.
- Count what the user pays for: a background job that fills a cache may send many queries, but the request that reads from the cache sends few. The assertion sits where the latency sits.
- Fail with the SQL on screen, not with a number alone. The helper can print the statements it counted; a
reader who sees “saw 10 SELECTs” and the ten
SELECT "teachers".* ... WHERE "teachers"."id" = ?lines reaches the fix in seconds. The query cache is a trap here: a cached read is not a round trip, but it is still an entry in the log. The subscriber filterscached: trueso the budget counts real reads only.
Checkpoint
Check: A query budget in tests
Spot the N-plus-one pattern in the logs
The query-budget assertion catches regressions in CI; development has two cheaper tools that catch them while the
page is open. The first is the Rails log itself. Every SELECT the app sends is printed with its duration and, in
development, with the file and line that sent it, because config.active_record.verbose_query_logs is on there by
default. A new app’s development config also sets config.active_record.query_log_tags_enabled = true, which
appends a comment that names the application, the controller and the action. A page that logs ten SELECT "teachers" reads in a row—each tagged with the same controller
action—is an N+1 you can read off the console without any extra tool.
Two gems extend that view. Prosopite says it detects N+1 queries “with zero false positives / false negatives”,
prints the repeated statements with the call stack that sent them, and can raise in tests with Prosopite.raise = true. Bullet says it helps “kill N+1 queries and unused eager loading”, and shows an alert, a page footer or a
console line only for the notifiers you switch on. Both are a line in the Gemfile; neither is needed here, because
the query budget and strict_loading are enough on their own. The five tools of this lesson catch the same bug at
different moments:
| Tool | Where it runs | What an N+1 does there |
|---|---|---|
| Log with query log tags | development | ten tagged SELECT "teachers" lines in a row |
| Query budget | test suite, CI | the test fails with the count it saw |
strict_loading |
any environment | the lazy read raises or is logged |
| Prosopite | development, test | statements and call stack, or a raise |
| Bullet | development | an alert, a footer or a console line |
A different form of parallelism helps when the queries themselves cannot be joined: load_async on a relation runs
the SELECT on a background thread, so a controller that needs two independent lists starts both at once and waits
for them together. It is the right call for a dashboard that reads batches and announcements from the same
database, each with its own where; it does nothing for an N+1 (that pattern is already one query waiting on the
previous one). The Relation API notes that config.active_record.async_query_executor must be configured, for example
:global_thread_pool, or the query runs in the foreground anyway.
Three places to look, in order
Open the development log and search for repeated SELECTs with the same shape (one per row of the page). If none, run
the page under rails test with a query-budget assertion. If the budget passes but the page still feels slow, the
bug is not an N+1; look for the slow step elsewhere (a cache miss, a serialisation step, a view partial that
re-renders).
Checkpoint
Check: Spotting N+1 in logs
Practise: cut a dashboard from 10 queries to 2
Copy n_plus_one.rb, which already seeds one teacher and nine batches, and extend it:
- Add a
has_many :announcementsto Teacher and abelongs_to :teacherto Announcement, with two announcements per teacher. Build a page that renders every batch with its teacher’s name and the first announcement by that teacher. Count the queries of a plain loop, then of an eager load that reaches both associations in oneincludes(teacher: :announcements). Expect 1 + 9 + 9 = 19 under the plain loop, 3 under the eager load (batches, teachers, announcements). - Add
strict_loadingto the Batch class’ belongs_to, so every batch raises on a lazy.teacherread. Keep the eager-loaded page green and watch a one-line change on a different page (the one that forgot the include) raise. - Turn the counting block of step 1 into an
assert_queries_at_most(5)assertion inside a Minitest test, and watch it fail when the include is removed.
The questions below reuse the teachers and batches of the recorded runs, and every wrong answer is one of the slips named above:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: how do you spot an N+1 while a page is open in development?
Open the terminal running the server and read the SELECTs the request logged. An N+1 shows up as a cluster of
identical statements with different bound values, one per row of the list on the page: SELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? LIMIT ? nine times in a row, each with a different ?, right after the one
SELECT that read the batches. With config.active_record.query_log_tags_enabled on (a new app sets it in
development), each SELECT carries the controller and action that sent it, which turns a cluster of reads into one
line you can point at (same tag, repeated).
In a test, wrap the request in ActiveSupport::Notifications.subscribed(..., "sql.active_record") and count: a page
that was 2 queries this morning and is 11 queries now has a new lazy read a reader should name.
Two harder questions from the module bank start where this section ends: one asks when includes takes the preload
shape and when the eager_load shape, the other weighs a query budget against strict loading on a model and strict
loading by default in a growing app.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A teammate's pull request keeps swapping
includes(:teacher)forpreload(:teacher)"to be explicit about the two SELECTs." Walk through when the swap is a no-op, when it is wrong, and what the SQL shape would be in each case. -
Mid to senior
An app with 400 pages wants N+1 regressions to stop at the model. How would you roll out strict_loading without surfacing errors to users, and when would you reach for
:n_plus_one_onlyinstead of the strict default? -
Senior to staff
A teacher list reads
teacher.batches.sizethousands of times a minute, and a nightly import writes 40,000 rows withinsert_all. Decide wheresize's numbers should come from: a counter cache, a cached SELECT, a materialised view, or a preloaded association, and say what each one costs when the import runs.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
A page loops over nine batches and prints each batch's teacher. How many queries does it send, and how many with eager loading?
Ten: one for the batches and one more per batch for its teacher. includes, preload or eager_load bring that down to two queries, or one.
-
How does includes decide between two SELECTs and one join?
It sends the preload shape, a second SELECT, unless the chain refers to the association's columns; then it sends one LEFT OUTER JOIN, the eager_load shape. references(:assoc) is the hint when a SQL fragment hides that reference.
-
Why can preload not filter parents by a child's column, while eager_load can?
preload reads the children in a separate SELECT, so the first query has no child columns to test. eager_load joins them in, so it can filter and sort on them, and such a condition also trims the children each parent comes back with.
-
What does strict_loading do, and what does the n_plus_one_only mode still allow?
It raises ActiveRecord::StrictLoadingViolationError on any lazy load, so a forgotten eager load fails in tests instead of slowing a page. In n_plus_one_only mode the record itself may still load lazily, but the records its has_many returns are marked strict, so the next step, one query per child, raises.
-
How can a test fail the day a page starts sending more queries?
Wrap the request in ActiveSupport::Notifications.subscribed on sql.active_record, count the SELECTs, and assert at most the number the page should need.
-
When does load_async help a page, and why does it not cure an N+1?
It runs independent SELECTs side by side, which helps a dashboard that reads two unrelated lists. An N+1 sends its queries one at a time from inside a loop, so there is nothing for load_async to run side by side.
-
How does an N+1 look in the development log?
A run of identical SELECTs with a different bound value each, one per row of the list, right after the single query that read the list.
Key takeaways
- A plain loop over N parents that reads a belongs_to on each one sends N+1 queries; the recorded run on nine batches
showed 10 SELECTs where
includes,preloadoreager_loadsends 2 or 1. includes(:assoc)picks between two SELECTs (preloadshape) and one LEFT OUTER JOIN (eager_loadshape) by looking at whether the chain references the association’s columns;references(:assoc)is the hint when a SQL fragment hides the reference.preloadcannot filter by the children’s columns because it sends a separate SELECT for them;eager_loadcan filter and sort there, and a condition on a child column also trims the children each parent comes back with.strict_loadingraisesActiveRecord::StrictLoadingViolationErroron any lazy load; mode:n_plus_one_onlylets the record itself load lazily but marks the records its has_many associations return as strict, so the second step, one query per child, still raises.ActiveSupport::Notifications.subscribedwraps a block in a temporary subscriber onsql.active_record, so a Minitest assertion can enforce “at most N SELECTs” per request and fail CI the day a page regresses.load_asyncruns independent SELECTs in parallel; it helps a dashboard that reads two unrelated lists and does nothing for an N+1, which is already serial by construction.
Terms in this lesson
- eager_load
- An Active Record query method that loads an association through a LEFT OUTER JOIN, so a where or an order can use the children's columns; a condition on a child column also decides which children are loaded.
- preload
- An Active Record query method that loads an association with a second SELECT and an IN list of the parents' ids; it cannot filter or sort by the children's columns because the first SELECT has no join.
- query budget
- A test assertion that counts SELECTs sent during a block, usually with ActiveSupport::Notifications.subscribed on sql.active_record, so a page that regresses from two queries to many fails CI before a user sees it.
- strict_loading
- A mode that raises ActiveRecord::StrictLoadingViolationError on any lazy association read, so a page that forgot an includes stops at the first lazy call rather than at the first slow user; set on a relation, a record, an association or an environment.
References
- Active Record Query Interface — Eager loading, strict_loading (Ruby on Rails)
- ActiveRecord::QueryMethods (Ruby on Rails)
- ActiveRecord::Relation (Ruby on Rails)
- ActiveSupport::Notifications (Ruby on Rails)
- Debugging Rails Applications — Verbose query logs and SQL query comments (Ruby on Rails)
- Configuring Rails Applications: config.active_record.query_log_tags_enabled (Ruby on Rails)
- ActiveRecord::Assertions::QueryAssertions (Ruby on Rails)
- charkost/prosopite — Rails N+1 queries auto-detection (Prosopite)
- flyerhzm/bullet — N+1 queries and unused eager loading (Bullet)
Report a problem with this lesson
Kept only in this browser. Your Learn progress