Associations Are Query Interfaces, Not Object Properties
A loaded association target answers one question. It does not make every future association-shaped call memory-only.
When the association target has not been loaded, we know this can run SQL:
account.invoices.to_aThe confusion begins once you assume the association has already loaded.
account = Account.includes(:invoices).find(42)
account.invoices.loaded?
# => true
account.invoices.map(&:id)
# no SELECT
account.invoices.overdue.any?
# Invoice Exists?
# SELECT 1 AS one
# FROM "invoices"
# WHERE "invoices"."account_id" = 42
# AND "invoices"."status" = 'overdue'
# LIMIT 1 The output looks contradictory only if “the association is loaded” means too much.
Rails loaded one target:
The invoices association for this accountThe later line asked a different question:
Does this account have any overdue invoices?Those are related questions, but they are not the same operation.
A loaded association target does not make every future association-shaped call memory-only.
account.invoices feels intuitive because it looks like object navigation, which is the main goal in typical application code. It allows you to navigate from an account to its invoices effortlessly, without concern for foreign keys, relation builders, or object caches.
But Rails is not storing “the invoices” as a plain property on the Account object.
An association reader is a query interface with cache state.
The Association Declaration Is Metadata
The association begins in the model class:
class Account < ApplicationRecord
has_many :invoices
endhas_many does not load invoices. It records association metadata: how invoices relate to accounts, which Rails can inspect later:
reflection = Account.reflect_on_association(:invoices)
reflection.macro
# => :has_many
reflection.klass
# => Invoice
reflection.foreign_key
# => "account_id"The declaration gives class-level information:
Account has many Invoice records
Invoice rows point back through account_idAn individual account supplies instance-level state:
account.id
# => 42Together, those pieces let Rails build a query for this owner:
account.invoices.scope.to_sql
# => SELECT "invoices".*
# FROM "invoices"
# WHERE "invoices"."account_id" = 42No invoice object has to exist yet.
The association reader packages three pieces:
association declaration
owner object state
association scopeFor a collection association, Rails exposes those pieces through a CollectionProxy.
The next operation decides what happens:
build a narrower relation
load the target
read the loaded target
ask SQL for a count or existence answer
construct a new associated objectThe Reader Returns an Interface
For a collection association, account.invoices returns an ActiveRecord::Associations::CollectionProxy.
That proxy can behave like a relation:
account.invoices.where(status: "paid")
account.invoices.order(issued_at: :desc)
account.invoices.limit(10)It can behave like a collection:
account.invoices.each { |invoice| invoice.total_cents }
account.invoices.map(&:id)
account.invoices.lengthIt can also build new associated objects:
invoice = account.invoices.build(total_cents: 5000)
invoice.account_id
# => 42Those calls do not take the same path.
account.invoices.count
# SELECT COUNT(*)
# FROM "invoices"
# WHERE "invoices"."account_id" = 42count asks the database for a number.
account.invoices.each do |invoice|
invoice.total_cents
end
# SELECT "invoices".*
# FROM "invoices"
# WHERE "invoices"."account_id" = 42each needs records, so Rails loads the target.
account.invoices.where(status: "paid")
# no SELECT yetwhere returns a narrower relation from the association scope.
The same reader can lead to relation composition, SQL, target loading, target reading, or object construction.
Reading account.invoices as “the invoices stored on account” eventually breaks down.
Loading Creates a Target
When Rails loads a collection association, it stores the resulting records as the association target for that owner object.
Before loading:
invoices = account.invoices
invoices.loaded?
# => falseloaded? is only a state check. It does not load the association.
After loading:
invoices.to_a
invoices.loaded?
# => trueNow a plain read can use the loaded target:
account.invoices.loaded?
# => true
account.invoices.map(&:id)
# no additional SELECT for the base association targetThat target belongs to this owner object and this association.
first = Account.find(42)
second = Account.find(42)
first.invoices.to_a
first.invoices.loaded?
# => true
second.invoices.loaded?
# => falseTwo Ruby objects can represent the same database row yet have different association states. Loading first.invoices leaves second.invoices untouched.
That association target is also separate from Rails’ SQL query cache.
The association cache lives on the model instance. It remembers the loaded target for account.invoices.
The SQL query cache operates at a lower level, storing repeated SQL result sets for a connection within a request or execution context. It may avoid another database round trip for identical SQL, but Rails can still instantiate separate Ruby objects and maintain separate association targets.
When the target is suspicious, inspect the association object:
association = account.association(:invoices)
association.loaded?
# => true
association.target
# => [#<Invoice id: 1, ...>, #<Invoice id: 2, ...>]target is a diagnostic tool. Application code should rarely need it.
Read it together with loaded? because a target can contain in-memory additions even when the full association has not been loaded.
A Loaded Target Is Not Every Query
Now return to the surprise:
account = Account.includes(:invoices).find(42)
account.invoices.loaded?
# => true
account.invoices.overdue.any?
# SELECT 1 AS one ...The base target is loaded:
All invoices for the account 42The scoped association call composes a new query:
overdue invoices for account 42Rails does not assume that every future scope should filter the loaded array in Ruby. When you chain where, order, limit, or a named scope onto the association proxy, you are usually building a relation from the association scope.
account.invoices.where(status: "overdue").to_sql
# => SELECT "invoices".*
# FROM "invoices"
# WHERE "invoices"."account_id" = 42
# AND "invoices"."status" = 'overdue'That relation is not the already-loaded target. It is a more specific database question.
If you want to filter the loaded target in Ruby, say that:
account.invoices.select { |invoice| invoice.status == "overdue" }That uses the loaded records and moves the filtering work into Ruby.
For a small collection already needed by the page, that's fine. For a large collection, the database is usually the better place to filter.
Do not make that a rule that memory or SQL is always better. Know which one you asked for.
select makes this especially easy to blur:
account.invoices.select(:id, :status)That builds an SQL projection.
account.invoices.select { |invoice| invoice.status == "overdue" }That loads records and filters them in Ruby.
With arguments, select behaves like a relation builder. With a block, it behaves like Enumerable and needs records.
The same method name spans different execution paths depending on how you call it.
Loaded for Which Question?
account = Account.includes(:invoices).find(42)
account.invoices.size
# uses the loaded target
account.invoices.overdue.any?
# SQLThe query itself is not the issue.
The problem is that the application code treated “the association is loaded” as if it answered every association question the code would later ask.
loaded? tells you whether Rails has loaded a target.
It does not certify future association-shaped calls as memory-only.
With Account.includes(:invoices), the code preloads invoices for this account. The next line asks: "Does this account have overdue invoices?"
Rails took a different path because those questions are different.
This can show up in a view:
<%= account.invoices.size %>
<% if account.invoices.overdue.any? %>
Overdue
<% end %>It can show up in a serializer:
class AccountSerializer
def as_json
{
invoice_ids: account.invoices.map(&:id),
has_overdue_invoices: account.invoices.overdue.any?
}
end
endIt can show up in a policy or mailer:
return unless account.invoices.visible_to(user).any?In each case, the question is the same:
Did this code read the loaded target, or did it build another query?Choosing the Right Shape
Once you name the question, the fix becomes a design choice instead of a preload ritual.
If the page already needs all invoices and the collection is reasonably small, read the target:
account.invoices.any?(&:overdue?)If the page needs a scoped collection, model and preload that target:
class Account < ApplicationRecord
has_many :invoices
has_many :overdue_invoices,
-> { overdue },
class_name: "Invoice"
endThen, preload the target you plan to read:
account = Account
.includes(:overdue_invoices)
.find(42)
account.overdue_invoices.loaded?
# => trueIf the page only needs a boolean, ask for it directly, usually through a scope, query object, or database view. Loading another association to answer "yes" or "no" may be the wrong approach.
Be careful with this tempting rewrite:
Account
.includes(:invoices)
.where(invoices: { status: "overdue" })Treat that as more than “the same preload, but filtered.”
Conditions in the included table can move Rails from separate preload queries toward a joined eager-loading shape using LEFT OUTER JOIN. That can change which parent rows appear, which child rows are available through the loaded association, and how much duplicate row data Rails has to process.
It may be the right query, but it is no longer merely a preload decision.
Counts, Existence, and Collection State
Association methods that look similar in Ruby can ask different persistence questions.
Limits, custom scopes, select, distinct, joins, counter caches, and strict loading can still change details.
On an unloaded association, without a usable counter cache, any? and empty? ask existence questions. They do not load the target or run a full count. exists? should be treated as an explicit database check, even when the target is loaded.
size is collection-aware. It can use a loaded target, include built-but-unsaved records, or read a counter cache on the parent. count asks the database.
invoice = account.invoices.build(total_cents: 5000)
account.invoices.size
# includes the built invoice
account.invoices.count
# database countIf Invoice belongs to Account with a counter cache and accounts.invoices_count is present, account.invoices.size can read the count from the already-loaded account instead of issuing SELECT COUNT(*).
Useful, but only as accurate as the counter column. On old tables, failed backfills or manual SQL changes can turn a performance feature into a misleading object state.
When the log feels random, name the operation:
database count
SQL existence check
counter-cache read
target load
target read
new scoped relation
cache resetSingular Associations Have Cache State Too
Collections make the proxy visible, but singular associations share the same basic shape.
invoice = Invoice.find(123)
invoice.association(:account).loaded?
# => false
invoice.account
# Account Load
# SELECT "accounts".*
# FROM "accounts"
# WHERE "accounts"."id" = 42
# LIMIT 1
invoice.association(:account).loaded?
# => trueAfter that, invoice.account can return the cached object.
invoice.account.anem
# no additional SELECT for the cached accountThe foreign key and the associated object are different state:
invoice.account_id
# => 42account_id is an attribute on the invoice snapshot. invoice.account is an association read. One is already present on the loaded invoice. The other may need a query and may then be cached.
Bidirectional associations add one more cache wrinkle. Rails can often connect both sides of an ordinary association in memory, so invoice.account may reuse the already-loaded account object. Custom names, foreign keys, through associations, and scopes can make that inverse relationship ambiguous. inverse_of helps Rails stitch both sides of the object graph together once records are in memory. It is not a preload.
Debugging Association State
When association behavior is surprising, separate the four questions:
What relationship did the model declare?
What query would this owner use?
Has this owner loaded this association target?
Is the code reading the target or composing a new query?For most debugging, the useful handles are small: the association object, the generated scope, the loaded target, and the reset/reload controls.
Start with the association object:
account = Account.find(42)
association = account.association(:invoices)
association.loaded?
# => falseLook at the query shape without loading records:
account.invoices.scope.to_sqlCompare the operations that look similar:
account.invoices.size
account.invoices.length
account.invoices.any?
account.invoices.empty?
account.invoices.exists?
account.invoices.any?(&:overdue?)
account.invoices.overdue.any?
account.invoices.to_aWhen lazy loads are expensive, strict_loading can turn an accidental association read into an explicit failure.
account = Account.strict_loading.find(42)
account.invoices.to_a
# raises ActiveRecord::StrictLoadingViolationErrorRails also supports narrower strict-loading modes for N+1 detection:
account.strict_loading!(mode: :n_plus_one_only)Use that when you want to catch repeated lazy loading across a collection.
Do not treat strict loading as an explanation of association semantics. It is a guardrail. It indicates that the code attempted to load an association through a strict record without loading it in advance. You still need to decide whether the right fix is preloading, a scoped association, or an explicit query defined elsewhere.
The Debugging Reflex
Do not ask only:
Is the association loaded?Ask:
Loaded for which question?account.invoices can expose a loaded target.
account.invoices.where(…) can build a new relation.
account.invoices.size can use memory, SQL, or a counter cache depending on state.
account.invoices.reset can discard the target.
invoices.account can be a cached object or a query waiting for the first read.
Associations are what make Rails models feel connected. The cost is that object navigation and query construction can be hidden behind the same method name.
When the same mistaken assumption recurs throughout a collection, the problem grows louder.
N+1 starts there: association lazy loading repeated once per parent record.

