How Rails knows what changed
Why the same callback can read the right value at the wrong point in an Active Record object's lifecycle.
Changing an Active Record object can feel like one operation: assign a value, call save, and expect the row to change.
Rails sees two separate moments. Before the save, it needs to know what the next write would change. After the save, callbacks and application code may need to know what that write changed.
Confusing those questions can produce a bug even when the update itself succeeds.
A plan-change audit starts recording the wrong transition.
The account update succeeds, and the callback creates the audit row. Nothing raises, and the result looks close enough to correct:
class Account < ApplicationRecord
before_update :record_plan_change
private
def record_plan_change
AuditEvent.create!(
account: self,
from: plan_before_last_save,
to: plan
)
end
endThe same Account instance has already been saved once, moving from trial to starter. Application code now changes that instance again, from starter to growth.
The audit should record:
start -> growthInstead, it records:
trial -> growthThe callback asked Rails for a real value. It was the wrong value for that point in the record’s lifecycle.
plan_before_last_save returns the value before the most recent completed save. The before_update callback needs the change waiting to be written by the current save:
plan_change_to_be_saved
# => ["starter", "growth"]Active Record exposes both answers because dirty tracking maintains two different comparisons. Before persistence, it compares the object’s database-driven value with its current in-memory value. After persistence, it preserves the transition made by the last save.
The similar method names are easier to reason about once the snapshots are explicit.
Assignment creates a pending change
Start with one loaded account:
account = Account.find(42)
account.plan
# => "starter"A loaded Active Record object owns an in-memory snapshot. Reading account.plan reads the object’s attribute state. It does not query the row again.
Assignment changes that in-memory state:
account.plan = "growth"
account.plan
# => "growth"No UPDATE has happened. Rails now has two relevant values for plan:
account.plan_in_database
# => "starter"
account.plan
# => "growth"Despite its name, plan_in_database does not issue a fresh SELECT. It returns the database-derived value that this object uses as its baseline.
The pending-change APIs describe the difference:
account.will_save_change_to_plan?
# => true
account.plan_change_to_be_saved
# => ["starter", "growth"]
account.changes_to_save
# => {
# "plan" => ["starter", "growth"]
# }These methods inspect the proposed write, making them useful for validation and before callbacks. Assignment changed the Ruby object; dirty tracking describes what persistence would need to write to make the row match it.
Dirty tracking is not assignment history
Dirty tracking does not record every setter call.
account.plan
# => "starter"
account.plan = "growth"
account.plan = "starter"
account.will_save_change_to_plan?
# => false
account.plan_change_to_be_saved
# => nilThe setter ran twice, but the current value once again matches the database-derived baseline. There is no set attribute change for the next save.
Type casting is part of the same comparison. Suppose retry_limit is an integer column:
account.retry_limit
# => 5
account.retry_limit = "5"
account.retry_limit
# => 5
account.will_save_change_to_retry_limit?
# => falseThe assigned input was a string. The value exposed by the model is the integer 5.
Active Record’s attribute types participate in dirty detection. The type-level changed? comparison receives the old and new values after type casting. For an ordinary value, the default question is equivalent to:
old_value != new_valueBoth sides are 5, so Rails has no integer change to save.
This matters in request code because form parameters arrive as strings. A request that assigns "5" to an integer attribute already holding 5 has invoked a setter, but it has not proposed a different database value.
Dirty tracking is only meaningful when you name both sides of the comparison.
A save moves the comparison
Return to the pending plan change:
account.plan = "growth"
account.plan_change_to_be_saved
# => ["starter", "growth"]Now save it:
account.save!After the write, the pending-change APIs no longer describe that transition:
account.will_save_change_to_plan?
# => false
account.plan_change_to_be_saved
# => nil
account.plan_in_database
# => "growth"The object’s current value and its database-derived baseline now agree.
Rails has not forgotten the transition. It moved it into the last-save comparison:
account.saved_change_to_plan?
# => true
account.saved_change_to_plan
# => ["starter", "growth"]
account.plan_before_last_save
# => "starter"
account.saved_changes
# => {
# "plan" => ["starter", "growth"],
# "updated_at" => [...]
# }The lifecycle now looks like this:
Inside Rails, ActiveRecord::AttributeMethods::Dirty delegates the pending comparison to a mutation tracked called mutations_from_database. After Active Record creates or updates the row, changes_applied moves that tracker into mutations_before_last_save.
mutations_from_database
|
| changes_applied
v
mutations_before_last_saveThat handoff is why the public API has two families. The pending tracker answers what the next save would change. The saved tracker answers what the last save changed.
Callbacks ask different temporal questions
The original callback mixed those two families.
Before an update, inspect the change about to be written:
class Account < ApplicationRecord
before_update :record_plan_change
private
def record_plan_change
return unless will_save_change_to_plan?
from, to = plan_change_to_be_saved
AuditEvent.create!(
account: self,
from: from,
to: to
)
end
endAfter the update, a plan-history record needs the transition made by that save:
class Account < ApplicationRecord
after_update :append_plan_history
private
def append_plan_history
return unless saved_change_to_plan?
from, to = saved_change_to_plan
PlanHistory.create!(
account: self,
from: from,
to: to
)
end
endRails introduced this split to make callback timing explicit: the will_save_* family describes the pending write, while the saved-change family describes the completed save.
Older code may express these checks through *_changed?, *_was?, or previous_changes. Do not translate those methods by name alone. First decide whether the callback needs the pending write (will_save_change_to_* and *_change_to_be_saved) or the completed save (saved_change_to_* and saved_changes).
saved_change_to_plan? says that the update completed within the model’s save lifecycle, not that an enclosing transaction committed. An after_update callback can run before the outer transaction commits or rolls back; work that depends on committed visibility belongs at the commit boundary.
Validations and before callbacks inspect pending changes. After callbacks inspect the changes made by the save that triggered them.
The baseline belongs to this object
plan_in_database sounds authoritative. Its authority is local to the model instance.
account = Account.find(42)
# account.plan => "starter"
# Another process updates account 42 to "enterprise".
account.plan_in_database
# => "starter"That call does not ask PostgreSQL or MySQL what the row contains now. It asks the object’s mutation tracker for the database-driven value it has been comparing against.
The distinction from An Active Record Object Is a Snapshot, Not the Row still applies:
database row now: "enterprise"
this object's baseline: "starter"
this object's current plan: "starter"From the object’s perspective, plan is unchanged:
account.will_save_change_to_plan?
# => falseThat answer is internally consistent. It is not a freshness check.
Now assign another value:
account.plan = "growth"
account.plan_change_to_be_saved
# => ["starter", "growth"]Rails can accurately describe the change this object proposes relative to its own baseline, even though another process has already changed the row to enterprise.
If the question is what the database contains now, cross the database boundary again:
account.reloadreload does more than check the row. It replaces the object’s attribute state and clears its dirty-tracking comparisons:
account.plan
# => "enterprise"
account.will_save_change_to_plan?
# => false
account.saved_changes
# => {}Any unsaved growth assignment is gone. The last-save history associated with the old object state is gone too.
Reloading replaces the baseline rather than preserving the old comparison. If application code needs the pending values, it must capture them before reloading or query through a separate object.
Dirty tracking describes one object’s relationship to the database-derived state that object knows. It does not continuously synchronize that relationship with the live row.
Mutable values need their type
Scalar assignment makes the comparison easy to see:
account.plan = "growth"Suppose settings is backed by a JSON column. Its hash can change without calling the attribute setter:
account.settings["digest"] = "weekly"For ordinary database-backed attributes, Active Record can detect many in-place changes through its attribute type:
account.will_save_change_to_settings?
# => trueThe type API provides changed_in_place? for values changed without assignment. Rails’ JSON type deserializes the original stored JSON and compares it with the current hash. Its string type compares the original stored string with the current string. A custom type that returns a mutable object must provide an equivalent comparison, or Rails may not know that the attribute needs to be written.
Plain ActiveModel::Dirty requires manual marking for in-place mutation.
The debugging question is “what attribute type owns this value, and can it detect the way the value was changed?”
Inspect the right comparison
When a callback, audit, or synchronization path reports the wrong change, inspect the record at the point where the code makes its decision. Ask:
Am I before the save or after it?
Am I asking about the next write or the last completed write?
Am I comparing this object's snapshots,
or do I need fresh database state?Those questions are more useful than starting with changed?, because they identify the temporal comparison the code needs.
Dirty tracking can describe the proposed write. It cannot tell us whether validations, callbacks, or persistence will allow that write to happen.
That is the work coordinated by save.

