The second trap in Active Record starts after the query has already run.
invoice = Invoice.find(42)
same_invoice = Invoice.find(42)
same_invoice.update!(status: "paid")
invoice.status
# => "overdue"
same_invoice.status
# => "paid"
invoice == same_invoice
# => trueThat last line is the surprise.
Both objects represent the same database row. They have the same model class and the same primary key. Active Record can compare them as the same record.
They are not the same Ruby object, and they do not have to carry the same attribute values.
invoice.equal?(same_invoice)
# => falseThe first object did not get a message from the database when the second object saved. It stayed exactly what it was: a Ruby representation of database-backed state as Rails saw it when that object was loaded.
It is not the row.
A loaded Active Record object is not synchronized database truth. It is an in-memory snapshot of the database-backed state at a particular moment.
The last article traced deferred query intent. An ActiveRecord::Relation is not loaded data until something forces execution.
Once execution has happened, the confusion moves from query intent to object state.
From Row to Object
When you call find, Rails does not hand your Ruby process a database row.
It performs a translation.
query intent
->
SQL execution
->
database result row
->
attribute state is built
->
model object is instantiated
->
Ruby code receives an Active Record instanceThe object you receive behaves like the application code wants a domain object to behave.
invoice.id
invoice.status
invoice.due_on
invoice.total_cents
invoice.overdue?The object came from the database, but after materialization, ordinary attribute reads are object reads.
invoice.status
# no SELECTRails already has attribute state for status inside the model instance, backed by Active Record’s type system. It does not need to ask the database again each time you call the method.
Active Record works naturally in controllers, policies, serializers, and views for the same reason stale-object bugs are easy to miss. The syntax does not remind you when the database stopped being involved.
The Snapshot Can Drift
Any other execution path can change the row after your object was loaded.
invoice = Invoice.find(42)
# => #<Invoice id: 42, status: "overdue">
Invoice.where(id: 42).update_all(status: "paid")
invoice.status
# => "overdue"The update_all call changed the database row. It did not mutate the already-loaded invoice object.
update_all issues a direct database update without instantiating model objects or running the normal record lifecycle.
The same drift can come from another request, a background job, a console session, a webhook handler, a migration script, or a direct SQL statement. Rails does not keep a live subscription between every row and every Ruby object that has ever represented it.
If you want a newer database state, you have to cross the boundary again.
invoice.reload
invoice.status
# => "paid"reload is not a cosmetic refresh. It is a database read. It discards the object’s current attribute snapshot and replaces it with a fresh one from the row, or raises ActiveRecord::RecordNotFound if the row is gone.
Active Record Identity Is Not Object Identity
Two Rails concepts often get mixed up:
Ruby object identity
Is this the exact same object allocation?
Active Record identity
Does this model instance represent the same table row?They answer different questions.
first_copy = Invoice.find(42)
second_copy = Invoice.find(42)
first_copy.equal?(second_copy)
# => false
first_copy == second_copy
# => trueequal? is Ruby object identity. These are two different objects.
== is Active Record identity. Once a persisted model has a primary key, Active Record can treat another object of the same model and the same primary key as the same record.
New records, records loaded without their primary key, and unusual primary-key setups have edge cases, but the ordinary persisted-record case behaves this way.
The comparison lets sets, arrays, and application code reason about records by database identity rather than by memory address.
But it is not a freshness check.
second_copy.update!(status: "paid")
first_copy.status
# => "overdue"
second_copy.status
# => "paid"
first_copy == second_copy
# => trueNo contradiction: two Ruby objects can have the same database identity and different attribute snapshots.
The Query Cache Is Not an Identity Map
Rails has a Query cache, but that does not change the object model.
Within a request or execution context, Rails may cache SQL result sets for repeated identical reads on a connection. The cache can avoid duplicate trips to the database for the same SQL.
It does not mean Rails keeps one live Ruby object per database row.
Invoice.find(42).equal?(Invoice.find(42))
# => falseIn ordinary Rails code, separate loads can produce separate objects. Even when a SQL result is cached, Rails can still instantiate model objects from that result. Each instance owns its own attribute state. Updating one instance does not update every other instance with the same primary key.
A method call on invoice reads the invoice’s state. That local ownership keeps Active Record pleasant to work with, but it also means correctness that depends on current database state must be explicit.
The Production Bug
Stale snapshots become production bugs when code makes a later decision from an earlier object.
Consider an overdue reminder job.
class SendOverdueReminderJob < ApplicationJob
def perform(invoice_id)
invoice = Invoice.find(invoice_id)
return unless invoice.overdue?
InvoiceMailer.overdue(invoice).deliver_now
invoice.update!(last_reminder_sent_at: Time.current)
end
endThat code is reasonable in isolation. It loads the invoice, checks whether it is overdue, sends the email, and records that the reminder was sent.
The length of the gap is not the issue. The database read and the Ruby predicate are two separate steps, and another process can commit between them.
Now add production timing:
The method reads the loaded object.
def overdue?
status == “overdue”
endThat method does not secretly issue a SELECT. It asks the object for its current status value. If the object was loaded before the payment was completed, the answer can be stale.
Reloading just before the decision can narrow that window:
invoice.reload
return unless invoice.overdue?But reload is not a concurrency strategy. It narrows one stale-read window; it does not make a read-modify-side-effect sequence automatic. Another process can still change the row after the reload and before the email.
For decisions that must be true at the database boundary, shape the operation as a database question or a database transition.
updated = Invoice
.where(id: invoice_id, status: "overdue")
.update_all(
reminder_state: "queued",
updated_at: Time.current
)
return unless updated == 1
invoice = Invoice.find(invoice_id)
InvoiceMailer.overdue(invoice).deliver_nowHere, the database performs the conditional transition rather than using the loaded object’s old status.
A real reminder system may still need an outbox, a transaction, after_commit, locking, idempotency keys, or a state machine.
The job violated one boundary:
invoice.overdue?It is an object-state question.
Sometimes production code needs a database-state question.
Lifecycle Flags Belong to the Object
Methods like new_record?, persisted?, and destroyed? look like database questions.
They are object-lifecycle questions.
Conceptually, the predicates are close to this:
def new_record?
@new_record
end
def destroyed?
@destroyed
end
def persisted?
!(@new_record || @destroyed)
endThey tell you what this Ruby object believes about its own Active Record lifecycle. They do not perform a fresh database query.
invoice = Invoice.find(42)
Invoice.where(id: 42).delete_all
invoice.persisted?
# => true
invoice.destroyed?
# => false
Invoice.exists?(invoice.id)
# => false
invoice.reload
# raises ActiveRecord::RecordNotFoundThe object did not go through destroy. It was not told that another SQL statement had deleted its row. Its flags still describe the lifecycle of the object you are holding.
The same rule applies when the object itself performs the deletion:
invoice = Invoice.find(42)
invoice.destroy!
invoice.destroyed?
# => true
invoice.id
# => 42
Invoice.exists?(invoice.id)
# => falseA destroyed object remains in memory with its ID, which can be useful for logging, auditing, callbacks, and after-commit work.
Do not ask an object-lifecycle predicate to prove current row existence.
Assignment Changes the Object First
Assignment is another place where object state and database state part ways.
invoice = Invoice.find(42)
invoice.status = "paid"
invoice.status
# => "paid"At this point, no database write has happened.
Dirty tracking describes which attribute changes the next save would persist. For now, the boundary is simpler:
assignment is object mutation
save is persistence orchestration
commit is durable database stateThose steps often appear side by side in Rails code, but they are not the same step. The database only sees writes that actually run, in the order and transaction boundaries it accepts.
reload Replaces the Snapshot
reload is the explicit operation for saying:
discard what this object currently knows
ask the database for this row again
replace this object's attributes with the fresh resultIt modifies the receiver in place.
invoice = Invoice.find(42)
object_id = invoice.object_id
invoice.reload
invoice.object_id == object_id
# => trueExisting references still point to the same Ruby object, but that object now carries a new attribute snapshot. Rails also clears related caches around the object, including association cache state.

reload replaces the retained object’s snapshot without changing its Ruby identity.Repeated reloads often signal that the code is mixing object snapshots and database without naming which one each decision needs.
Use reload when you mean it:
invoice.reload.statusDo not use it as a substitute for deciding where the authority should live.
The View Does Not Make Objects Fresher
Controller instance variables can make loaded records feel more official than they are.
class InvoicesController < ApplicationController
def show
@invoice = current_account.invoices.find(params[:id])
end
endBy the time the view renders, @invoice is a model object carrying an attribute snapshot.
<%= @invoice.status %>
<%= number_to_currency(@invoice.total_cents / 100.0) %>Those reads do not ask the database again. They read the object.
A render should be internally consistent. If helpers, decorators, serializers, or partials casually reload records during rendering, the page can become a mix of values loaded at different times.
More freshness does not automatically mean more correctness.
A view should behave like a function of the state the controller chose to render. If the view needs reload to make the page correct, the controller or the surrounding workflow loaded the wrong state for the decision being made.
The controller should decide which state it needs, deliberately load that state, and render from it. If a particular decision needs the current database truth, make that query explicit at the boundary instead of hiding reload in the presentation code.
A Debugging Map
When an Active Record object behaves strangely, separate four things:
Ruby object identity
object_id, equal?
Active Record identity
model class and primary key
object snapshot
the values currently held by the model instance
database truth
what a new database query can observeThen ask the precise question:
The last row is mostly due to dirty tracking, but the object may contain changes that the database has not yet seen.
Use the answer to choose the next move: inspect the object, reload the object, run a fresh query, or move the decision into a database operation.
Where This Lives in Rails
ActiveRecord::Persistence::ClassMethods#instantiate is part of the path that turns a database result into a model instance. It selects the appropriate class for the record and instantiates it from the returned attributes.
ActiveModel::AttributeSet is part of the typed attribute state beneath ordinary model attribute reads and writes.
ActiveRecord::Core#== explains why two different Ruby objects with the same model class and primary key can compare as equal.
ActiveRecord::Persistence provides lifecycle predicates such as new_record?, persisted?, and destroyed?, as well as reload.
The mental model maps to the implementation:
row returned by SQL
->
attribute state built from database values
->
model object allocated
->
object lifecycle flags initialized
->
Ruby object live independently
->
another explicit database operation may refresh or persist itThere is no hidden subscription from the database row back to every Ruby object that has represented it.
The Debugging Reflex
Do not start with:
Why is the database wrong?
Ask:
Which snapshot am I reading?
Two model instances can represent the same row and disagree.
Lifecycle predicates can describe the object without proving current row existence.
An assignment can change the object before any write occurs.
reload can replace the snapshot, but it does not make a multi-step operation concurrency-safe.
None of those are edge cases in the framework. They are consequences of the abstraction.
Active Record gives you Ruby objects because Ruby objects are the right interface for application code. The database gives you rows because rows are the durable shared state. Rails translates between them, but it does not collapse them into the same thing.
That sets up the next abstraction.
A loaded model object owns an attribute snapshot. But when you call this:
invoice.line_itemsyou may not be reading the loaded object state at all.
You may be touching another query interface hidden behind object navigation.
The next boundary is associations: they are not object properties. They are query interfaces with caches. And a loop that reads one of those interfaces lazily, once per record, is how N+1 queries happen.



