Why N+1 Queries Are a Natural Result of Lazy Loading
Why loading the parents doesn't load their associations — and why preloading doesn't fix every repeated query.
An N+1 often arrives as the kind of change nobody is nervous about.
A serializer grows one nested field. The review focuses on the JSON shape, not on database access. Staging has five accounts. Every individual query is fast, and the diff ships without drama.
Then a larger customer requests a full page of fifty accounts, and the same response-building loop turns one request into fifty-one query executions.
The code did not add a visible query object. It added object navigation inside a loop.
account = Account.active.limit(50)
accounts.map do |account|
{
name: account.name,
invoices: account.invoices.map do |invoice|
{
number: invoice.number,
status: invoice.status
}
end
}
endRead it quickly, and it looks like formatting: get the active accounts, include a little invoice data for each one, return an array of hashes.
The SQL log tells a different story. First Rails loads the parents:
SELECT "accounts".*
FROM "accounts"
WHERE "accounts"."active" = TRUE
LIMIT 50Then the invoice query repeats for one owner at a time:
SELECT "invoices".*
FROM "invoices"
WHERE "invoices"."account_id" = 12
SELECT "invoices".*
FROM "invoices"
WHERE "invoices"."account_id" = 19
SELECT "invoices".*
FROM "invoices"
WHERE "invoices"."account_id" = 27And so on.
Nothing in the loop looks like a query builder because the query is hidden behind account.invoices. That call uses the same association reader from Associations Are Query Interfaces, Not Object Properties: it feels like a property, but it is a query interface with cache state.
Lazy loading can feel wonderfully convenient for one object; across a collection, the same convenience becomes a scaling rule.
N+1 is not Rails forgetting to be efficient. It is lazy loading repeated across a collection.
Loaded for Which Question?
The misleading assumption is small: I loaded the accounts, so this loop is now memory-only Ruby work.
But the loaded state belongs to a specific object and a specific question.
Before execution, accounts is still a relation. Once the loop begins, Rails has to load that outer relation, but the association targets remain separate:
relation = Account.active.limit(50)
relation.loaded?
# => false
accounts = relation.to_a
relation.loaded?
# => true
accounts.first(3).map do |account|
account.association(:invoices).loaded?
end
# => [false, false, false]The parent relation loaded its account records, but it did nothing about the invoice association target on each account.
Those are separate pieces of state:
relation.loaded?describes the parent relation.account.association(:invoices).loaded?describes one owner’s association target.
When the serializer calls account.invoices.map(&:number), the association proxy has to produce invoice records. For one account, that path is reasonable: account.invoices returns a CollectionProxy, Rails sees that the target is unloaded, builds an owner-scoped relation, runs a query for that account ID, and stores the returned rows on that account object’s association target.
Put the same association read inside a loop, and Rails repeats the same lazy-loading path for every owner:
accounts.each do |account|
account.invoices.map(&:number)
endThe +1 is the parent query. The N is the repeated association query. In this example, one account query is followed by fifty invoice queries.
Each query is fast. The problem is that the query count scales with the parent collection: 50 accounts mean 50 invoice queries, and the endpoint becomes more expensive with every record the page adds.
Nothing changed in the body of the loop. Only the size of the parent collection changed.
Move the Association Load Outside the Loop
If the response needs invoice records for every account, load those records as a set:
accounts = Account
.active
.preload(:invoices)
.limit(50)
accounts.map do |account|
{
name: account.name,
invoices: account.invoices.map do |invoice|
{
number: invoice.number,
status: invoice.status
}
end
}
endNow Rails can fetch invoices for the parent set instead of asking each owner separately:
SELECT "invoices".*
FROM "invoices"
WHERE "invoices"."account_id" IN (12, 19, 27, ...)The association targets still belong to individual account objects. Preloading leaves those targets in place; it changes when they are filled. With lazy loading, each owner fills its target when the loop touches it. With preloading, Rails collects the parent IDs, fetches the associated rows using a set-based query, and attaches the correct invoice records to the correct account targets before the loop asks for them.
Rails offers three related loading APIs. preload loads each requested association with a separate query. Internally, ActiveRecord::Associations::Preloader groups owners and assigns the fetched child records back to the correct association targets.
eager_load loads parents and associations through a LEFT OUTER JOIN.
includes normally uses separate queries, but conditions or references involving the associated table can make Rails use a LEFT OUTER JOIN.
The first debugging question is: will this association be loaded once for the parent set, or lazily once per parent object?
Use that question before stopping at includes.
Preloading Has to Match the Question
Preloading associated records fixes repeated record loading. Calls that compose a scoped relation or request a calculation can still go back to SQL.
The first surprise comes from scoped relations. Suppose Invoice defines an unpaid scope:
class Invoice < ApplicationRecord
scope :unpaid, -> { where(status: "unpaid") }
endaccounts = Account
.active
.preload(:invoices)
.limit(50)
accounts.each do |account|
account.invoices.unpaid.each do |invoice|
# ...
end
endThe base invoices target may be loaded, but account.invoices.unpaid composes a new owner-scoped relation. That relation asks a different database question:
account.invoices
# read the loaded base target
account.invoices.unpaid
# build a scoped relation for unpaid invoices owned by this accountIf unpaid invoices are the records the response needs, preload that target directly:
class Account < ApplicationRecord
has_many :invoices
has_many :unpaid_invoices, -> { unpaid }, class_name: "Invoice"
end
accounts = Account
.active
.preload(:unpaid_invoices)
.limit(50)Now the loop can read account.unpaid_invoices as the preloaded association target, instead of composing a new scoped relation from account.invoices.
One caveat: invoices and unpaid_invoices are separate association targets. Preloading one does not populate the other, so preload the association that the loop actually reads.
Counts create a different version of the same mistake:
accounts = Account
.active
.preload(:invoices)
.limit(50)
accounts.map do |account|
{
name: account.name,
invoice_count: account.invoices.count
}
endThe loop can still issue one count query per account. The code asked a different question.
account.invoices.map needs records, so preloading records helps.
account.invoices.count requests a count from the database. On an association collection, count is an SQL calculation. It does not have to instantiate the target first.
If the target is already loaded and you want the number of loaded records, use the collection-aware method:
account.invoices.sizesize can use the loaded target when one is present. It can also use a counter cache or issue a count when the target is not loaded.
The method names look interchangeable in Ruby. They are not interchangeable at the persistence boundary.
Separate the question before choosing the fix:
Need the preloaded records? Read the association target.
Need filtered subset? Preload a matching named association or answer it set-wise.
Need a count? Use a grouped aggregate, a counter cache, or
sizeon an intentionally loaded target.Need a boolean? Prefer a set-based existence query over one
exists?call per parent.
For counts, a grouped query may be the right shape:
accounts = Account.active.limit(50).to_a
account_ids = accounts.map(&:id)
counts_by_account_id = Invoice
.where(account_id: account_ids)
.group(:account_id)
.countgenerating one grouped query for the whole page:
SELECT COUNT(*) AS "count_all", "invoices"."account_id" AS "invoices_account_id"
FROM "invoices"
WHERE "invoices"."account_id" IN (12, 19, 27, ...)
GROUP BY "invoices"."account_id"Then the loop can read from a hash instead of asking each association for its own account:
accounts.map do |account|
{
name: account.name,
invoice_count: counts_by_account_id[account.id] || 0
}
endBooleans follow the same pattern. Instead of one exists? query per account, ask once which parent IDs have invoices at all:
ids_with_invoices = Invoice
.where(account_id: account_ids)
.distinct
.pluck(:account_id)
.to_set
accounts.map do |account|
{
name: account.name,
has_invoices: ids_with_invoices.include?(account.id)
}
endOne set-based query answers the existence question for the whole page.
Preload the association when the loop needs records. Choose a different query when the loop needs an answer about those records.
Why This Survives Code Review
N+1 problems survive code review because each line looks reasonable in isolation.
The controller owns the parent relation, such as @accounts = Account.active.limit(50). The serializer owns the association read, such as account.invoices.map.
Neither file says “run one query per account.” The database cost emerges only when those layers execute together.
The controller chooses the parent relation, the serializer decides which associations are touched, and the database sees the combined runtime behavior. In a mature app, that cost may show up far away from the serializer: higher latency, more checked-out connections, or a database that suddenly looks busy even though no single query looks frightening.
Small test fixtures hide it too. With three accounts, the endpoint returns the expected JSON. With a full page of fifty accounts, the same code path can execute fifty-one queries before the application has done anything else interesting.
Detecting and Enforcing the Boundary
The first diagnostic tool is still the SQL log.
Do not read it only as noise. Look for the same query template repeated with a different owner ID: WHERE "invoices"."account_id" = 12, then 19, then 27, and so on. The changing ID is the clue that Rails is loading the same association once per parent.
Then inspect association state for one object:
account = accounts.first
account.association(:invoices).loaded?
# => falseIf the code is meant to read preloaded records, that answer matters.
After a successful preload, each parent should have that association target ready:
accounts = Account.active.preload(:invoices).limit(50).to_a
accounts.first.association(:invoices).loaded?
# => trueRails also gives you a guardrail: strict_loading.
accounts = Account
.strict_loading
.active
.limit(50)
.to_a
accounts.first.invoices.to_a
# raises ActiveRecord::StrictLoadingViolationErrorThe trigger is retrieving associated records, not merely to touch the reader. Calling accounts.first.invoices returns the association proxy without complaint. Methods such as to_a, each, and map load the collection target; a finder such as first may issue a narrower association query. Either way, Rails has crossed from returning the proxy to lazily retrieving associated records.
Strict loading is not an explanation of N+1. It is enforcement: it turns unexpected association lazy loading into a visible failure.
The guardrail does not have to stay local to one relation. Setting config.active_record.strict_loading_by_default = true in development makes every relation strict, so a new lazy load fails on your machine instead of on a customer’s page. If raising is too aggressive for an existing codebase, config.active_record.action_on_strict_loading_violation = :log reports violations without breaking requests, which turns cleanup into a backlog you can work through instead of an incident.
Keep the boundary clear. Strict loading governs association lazy loading; it will not explain every repeated query. A loop that calls account.invoices.count is issuing explicit aggregate SQL, so it still needs log reading, query instrumentation, or a different query shape.
The Debugging Reflex
When you see N+1 behavior, do not treat includes as the diagnosis.
Ask what the outer query loaded, which association each parent loaded lazily, and whether the loop needs records, a filtered subset, a count, or a boolean. Then decide whether the associated data should be loaded as a set or the question answered with a different query.
An association reader is not a plain object property. Through the association proxy, the reader can load or read a target, compose a relation, ask SQL for a count, or ask SQL for existence.
Put an operation that needs those records or asks a database question through the association inside a loop, and the distinction becomes visible in the log.
Lazy loading is useful because Rails can wait until the code needs associated records. N+1 is the cost of making that decision one owner at a time.
Loading the right records resolves one boundary. The next confusion begins when changing a Ruby object feels like changing the database.
