Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

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:

Counting the SELECTs behind four reads of batch.teacher Ruby · n_plus_one.rb
# 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 .all sent 10 queries: one SELECT "batches".* for the list of nine rows, then nine identical SELECT "teachers".* FROM "teachers" WHERE "teachers"."id" = ? LIMIT ? statements, one per batch. Each follow-up read the association from the row’s teacher_id, which costs a round trip.
  • .includes(:teacher) sent 2 queries: the same list, then one SELECT "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 as includes; the two methods are the same shape when nothing in the chain references the teacher’s columns.
  • .eager_load(:teacher) sent 1 query: a LEFT OUTER JOIN with the aliased columns t0_r0, t0_r1, t1_r0, t1_r1, which rebuilds the two models from one row of the result set.
Interactive · stage by stage Ten queries behind a nine-row page Walk one plain Batch.all.each loop and watch the count climb from 1 to 10.
Ten queries behind a nine-row page, step 5 of 5. Showing Dashboard renders, SELECT batches (1 query so far), Loop reads b.teacher, SELECT teacher by id (9 queries total), Ten SELECTs sent (10 SELECTs).next rowDashboard rendersLists 9 open batchesSELECT batches1 query so farLoop reads b.teacherRow at a timeSELECT teacher by id9 queries totalTen SELECTs sent10 SELECTs

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Request starts. The dashboard renders. The view asks for every open batch and starts a loop over the rows that come back.
  2. Batches load. Active Record sends one SELECT and loads the nine open batches as Ruby objects. The counter sits at 1 query so far.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 The recorded run read nine batches four ways. How many SELECTs did the plain Batch.all.each loop send?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 10

    Nine rows plus one query up front make 10. The run also showed 2 for includes, 2 for preload and 1 for eager_load.

  2. Question 2 of 2 A controller runs batches = Batch.all and the view calls b.teacher.name on each row. When does the teacher SELECT run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Once per row, the first time b.teacher is called on that row

    That is why the pattern is called N+1: one SELECT for the parents, then one more per row the first time the association is read.

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 in where, order or 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 with WHERE "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 a preload(: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.
Interactive · answer and compare Which eager-loading form fits your chain? Answer the three questions below the table to see which forms still fit.
Does the where or order mention a column of the child association?
Is the association a has_many that may return many children per parent?
Must the page show every child of each parent it lists?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four forms against four criteria from the Rails Query Interface guide (§16).
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:

How includes picks preload or eager_load Ruby · snippets/references_switch.rb
# 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:

eager_load on a has_many: a limit and a condition on batches Ruby · eager_load_limit.rb
# 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

  1. Question 1 of 2 Batch.includes(:teacher).where("teachers.name = ?", "Asha") sends the wrong SQL. What is Rails doing, and what fixes it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: includes picked the preload shape (no join), so the SQL fragment misses the teachers table; add .references(:teacher) to force the join.

    Prefer a hash condition when you can (where(teachers: { name: "Asha" })): Rails sees the column reference and switches to eager_load on its own.

  2. Question 2 of 2 Why does Batch.preload(:teacher).where("teachers.name = ?", "Asha") raise an error?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: preload sends two separate SELECTs with no join; the first SELECT (on batches) has no teachers table for the condition to apply to.

    When a filter touches the children's columns, use includes with a hash (or eager_load directly). The guide names this behaviour in §16.3.

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: true on 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 = true in an environment, so every relation is strict unless the code opts out. Set config.active_record.action_on_strict_loading_violation = :log to 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:

Lazy loads raise under strict_loading; n_plus_one_only lets one through Ruby · strict_loading.rb
# 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 } raised ActiveRecord::StrictLoadingViolationError with the message “Batch is marked for strict_loading. The Teacher association named :teacher cannot be lazily loaded.” The relation marks every record it loads, and the first lazy b.teacher is the one that fails. In a test suite the error points at the exact line that forgot an includes.
  • teacher.strict_loading!(mode: :n_plus_one_only) then teacher.batches.length returned 3: the teacher’s own lazy read is allowed, because loading its batches once is not an N+1.
  • teacher.batches.first.students.to_a raised the violation again, because the batches came back through a has_many and are marked strict, and .students on 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

  1. Question 1 of 2 Batch.strict_loading.find_each { |b| b.teacher.name } raised ActiveRecord::StrictLoadingViolationError. Where does the error point in the stack trace?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: At the line b.teacher.name, inside the loop body.

    That is the whole point: the stack trace names the view or presenter line that forgot an includes, so the fix is one line away.

  2. Question 2 of 2 With teacher.strict_loading!(mode: :n_plus_one_only), which call raises?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: teacher.batches.first.students.to_a

    :n_plus_one_only lets the record load lazily but marks the records a has_many returns as strict, so the second step of the chain, where an N+1 would otherwise hide, still raises.

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:

A Minitest assertion that counts SELECTs on sql.active_record Ruby · query_budget_test.rb
# 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 filters cached: true so the budget counts real reads only.

Checkpoint

Check: A query budget in tests

  1. Question 1 of 2 Why use ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block) in a query-budget test, instead of .subscribe(...) once at startup?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: subscribed attaches the callback only for the block, so one test cannot count the queries of the next.

    subscribed is the right form inside a test: register, run the block, then unsubscribe, whatever happens.

  2. Question 2 of 2 In the recorded run of query_budget_test.rb, Case B failed with what message?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: "Expected at most 3 SELECTs, saw 10."

    The assertion fails with "saw 10", and a reader glancing at CI can read "that is one SELECT per row of the batches list" from the number alone.

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

  1. Question 1 of 2 Which patterns in the Rails development log are a clue that a page is sending an N+1? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • Nine identical SELECTs with a different bound value each, right after one SELECT that returned nine rows.
    • A cluster of SELECTs that all carry the same query-log-tag (controller and action) in their SQL comment.

    With config.active_record.query_log_tags_enabled on (a new app sets it in development), every SQL comment names the controller and action; a cluster of same-tagged reads is an N+1 reading itself out loud.

  2. Question 2 of 2 A dashboard reads batches and announcements, each with their own where. The second read waits on the first. Which call helps, and which does not?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: load_async on each relation runs the two SELECTs in parallel when an async executor is configured.

    load_async pairs with config.active_record.async_query_executor = :global_thread_pool, which gives the app threads to run the SELECTs on.

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:

  1. Add a has_many :announcements to Teacher and a belongs_to :teacher to 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 one includes(teacher: :announcements). Expect 1 + 9 + 9 = 19 under the plain loop, 3 under the eager load (batches, teachers, announcements).
  2. Add strict_loading to the Batch class’ belongs_to, so every batch raises on a lazy .teacher read. Keep the eager-loaded page green and watch a one-line change on a different page (the one that forgot the include) raise.
  3. 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.

  1. Question 1 of 10 Which event name does ActiveSupport::Notifications.subscribed pass to count every SQL statement Active Record sends?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: "sql.active_record"

    The payload also carries :cached and :name; a budget filters on both before counting the statement.

  2. Question 2 of 10 A page loops over 50 orders and reads order.customer.name. What does Order.includes(:customer).limit(50) send?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: Two SELECTs: one for the 50 orders, one for the customers with id in the orders' customer_ids.

    The recorded run on nine batches showed the same shape: 1 SELECT for the parents, 1 for the children, 2 round trips total.

  3. Question 3 of 10 A report lists ten books that have a verified review, each with its verified reviews only. What is the right call?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: Book.eager_load(:reviews).where(reviews: { verified: true }).limit(10)

    eager_load filters and loads in one plan, and a condition on the children decides which children each book comes back with. When a page needs every review of the chosen books, pick the books first and preload their reviews instead.

  4. Question 4 of 10 A growing app wants to make strict_loading the default without breaking every page at once. Which step comes first?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: Set config.active_record.action_on_strict_loading_violation = :log with strict_loading_by_default = true, fix pages one by one, then flip back to :raise.

    Setting strict_loading_by_default = true together with action_on_strict_loading_violation = :log (both under config.active_record) is a safe migration path: violations reach the log, not the user. The Query Interface guide shows both settings in its strict_loading section (§16.5).

  5. Question 5 of 10 A development log shows one SELECT "batches".* and then nine SELECT "teachers".* ... WHERE "teachers"."id" = ? LIMIT ? lines, each with a different bound id, all tagged with the same controller action. What is the fix?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: Load the batches with includes(:teacher), so the nine reads become one

    The log reads the bug out loud: same shape, different bound value, one per row of the page. Eager load the association the loop reads, and the cluster becomes a single statement.

  6. Question 6 of 10 In "N+1 queries", what does N count?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: The parent rows whose association the loop reads lazily, one extra query each

    Nine batches gave 1 + 9 = 10 SELECTs in the recorded run; 200 batches would give 201.

  7. Question 7 of 10 How many SELECTs does Batch.includes(:teacher).where(teachers: { name: "Asha" }) send, and why?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: One, with a LEFT OUTER JOIN, because the hash condition references teachers and includes switches to eager_load

    A hash condition needs no references call; a SQL string fragment does, because Rails cannot see inside it.

  8. Question 8 of 10 Which setting makes every relation of an environment strict unless the code opts out?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: config.active_record.strict_loading_by_default = true

    Four places turn strict loading on: a relation, a record, an association and the environment default. The default reaches the most code with one line.

  9. Question 9 of 10 A team writes "at most 1 SELECT for batches" and "at most 1 SELECT for teachers" as two separate assertions on one page. What does the lesson suggest instead?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: One budget for the whole request, such as at most 5 SELECTs end to end

    Budget per request, count what the user pays for, and fail with the SQL on screen: the three rules of the lesson's query-budget section.

  10. Question 10 of 10 When does load_async on a relation help, and when does it do nothing?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: Helps when two independent relations each run their own SELECT; does nothing on an N+1, where each read waits on the previous.

    Pair load_async with config.active_record.async_query_executor = :global_thread_pool so the app has threads to run the SELECTs on.

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.

  1. Fresher to mid

    A teammate's pull request keeps swapping includes(:teacher) for preload(: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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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_only instead of the strict default?

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    A teacher list reads teacher.batches.size thousands of times a minute, and a nightly import writes 40,000 rows with insert_all. Decide where size'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.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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, preload or eager_load sends 2 or 1.
  • includes(:assoc) picks between two SELECTs (preload shape) and one LEFT OUTER JOIN (eager_load shape) by looking at whether the chain references the association’s columns; references(:assoc) is the hint when a SQL fragment hides the reference.
  • preload cannot filter by the children’s columns because it sends a separate SELECT for them; eager_load can filter and sort there, and a condition on a child column also trims the children each parent comes back with.
  • strict_loading raises ActiveRecord::StrictLoadingViolationError on any lazy load; mode :n_plus_one_only lets 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.subscribed wraps a block in a temporary subscriber on sql.active_record, so a Minitest assertion can enforce “at most N SELECTs” per request and fail CI the day a page regresses.
  • load_async runs 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.