In Active Record, how do changed?, saved_change_to_price? and previous_changes differ before and after a save, and which belongs in after_save code?
answer
- pending changes versus last save
- changes are applied inside save
- will_save_change_to_x? before
- saved_change_to_x? after
- x_before_last_save, previous_changes
basics
~10 schanged? and price_changed? describe edits not yet saved; once the row is written they are cleared. saved_change_to_price?, saved_changes and previous_changes describe what the last save wrote, so after_save and after_commit code reads those.
solid answer
~30 sActive Record tracks two sets of changes. **Pending** changes: `changed?`, `price_changed?`, `changes`, and the clearer aliases `will_save_change_to_price?` and `price_in_database`; they answer "what will the next save write?". **Last save**: `saved_change_to_price?`, `saved_change_to_price` (an `[old, new]` pair), `price_before_last_save`, `saved_changes` and its ActiveModel name `previous_changes`. The switch happens inside the save itself: the write applies the changes before `after_save` and `after_update` callbacks run, so in those callbacks and in `after_commit`, `price_changed?` is already `false`. Code that reacts to a price drop must ask `saved_change_to_price?`, optionally with `from:`/`to:`. `reload` clears both sets, and `update_columns` writes without leaving a saved change behind.
code
ruby · 13 lineslisting.price_cents # => 29_000
listing.price_cents = 25_000
listing.will_save_change_to_price_cents? # => true
listing.save
listing.price_cents_changed? # => false
listing.saved_change_to_price_cents # => [29_000, 25_000]
listing.price_cents_before_last_save # => 29_000
listing.previous_changes.keys # => ["price_cents", "updated_at"]
class Listing < ApplicationRecord
after_update_commit :notify_watchers, if: :saved_change_to_price_cents?
endgo deeper
Recall that changed? and attribute_changed? describe unsaved edits, and that they read false once save has written the row.
Explain the pending versus last-save method families and when inside save the switch happens relative to after_save.
Write post-save and commit logic with saved_change_to_x?, and spot the two-saves-per-transaction and update_columns blind spots.
Decide when change-driven side effects belong in model callbacks keyed on dirty state versus explicit service code that knows the intent.
## Two questions about change When a seller lowers a listing's price, the site notifies everyone who saved that listing. The notification code must know **whether the price just changed**. Active Record answers two different questions about change, and mixing them up is the bug interviewers are looking for: 1. **What will the next save write?** (pending changes) 2. **What did the last save write?** (saved changes) ## Pending changes After `listing.price_cents = 25_000` on a loaded listing: - `listing.changed?` is `true`; `listing.changed` is `["price_cents"]`; - `listing.price_cents_changed?` is `true`; `listing.price_cents_was` is the old value; - `listing.changes` is `{"price_cents" => [29_000, 25_000]}`; - the names that say exactly what they mean: `will_save_change_to_price_cents?`, `price_cents_change_to_be_saved` and `price_cents_in_database`. These are the right tools in `before_validation` and `before_save` code, which runs before the write. ## Saved changes When the save writes the row, Active Record **applies** the changes: pending changes become the record of the last save, and the pending set empties. From then on: - `saved_change_to_price_cents?` is `true`; - `saved_change_to_price_cents` is `[29_000, 25_000]`; - `price_cents_before_last_save` is `29_000`; - `saved_changes` is `{"price_cents" => [29_000, 25_000], "updated_at" => [...]}`; `previous_changes` is the ActiveModel name for the same hash; - `price_cents_previously_changed?` and `price_cents_previously_was` are the ActiveModel spellings. `saved_change_to_attribute?` accepts `from:` and `to:`, which makes intent explicit: `saved_change_to_status?(from: "draft", to: "published")`. ## Why after_save sees changed? as false Inside `save`, Active Record runs the `before_*` callbacks, writes the row, **applies the changes**, and only then runs `after_create`/`after_update` and `after_save`. Commit callbacks run later still. So: | Where the code runs | `price_cents_changed?` | `saved_change_to_price_cents?` | |---|---|---| | before the save (`before_save`) | `true` | value from the previous save | | `after_save` / `after_update` | `false` | `true` | | `after_commit` | `false` | `true` (for the last save) | | after `reload` | `false` | `false` | Code written as `after_save :notify_watchers, if: :price_cents_changed?` therefore never fires. The fix is `if: :saved_change_to_price_cents?`. (The callback machinery itself belongs to the callbacks topic; the point here is which dirty method describes which moment.) ## Edge cases worth knowing - **Two saves in one transaction.** `saved_changes` describes only the **most recent** save. If a job saves twice before commit, an `after_commit` sees only the second save's changes. - **No-op saves.** Assigning the same value is not a change, so `saved_change_to_price_cents?` stays false and no UPDATE is sent. - **`reload`** clears pending changes and the last-save record alike. - **`update_columns`** writes the value and clears that attribute's pending change without creating a saved change, so dirty-driven callbacks never see it. - **`restore_attributes`** undoes pending edits in memory; `clear_changes_information` forgets both sets. ## Why it matters Dirty tracking is how models decide whether to recompute a search column, purge a cache, enqueue a notification or write an audit row. Reading the pending set after the write, or the saved set before it, silently disables that behaviour, and tests that only check the happy path rarely notice. ## Testing dirty-driven behaviour Because the bug is about timing, a test should cross the save boundary: 1. Load a listing, change `price_cents`, call `save`. 2. Assert the side effect happened (a notification enqueued, an audit row written). 3. Save again changing **only the title** and assert the side effect did **not** repeat. Step 3 catches the opposite bug: code that checks `saved_changes.any?`, which is true for any save that wrote something (including `updated_at`), instead of the specific attribute. Asserting on `saved_change_to_price_cents?` directly in a model test is also cheap: it documents which moment the code is meant to observe.
- A job saves a listing twice inside one transaction: first the price, then the status. What does saved_changes show in after_commit?Only the second save's changes, the status and `updated_at`. `saved_changes` always describes the most recent save, so the price change from the first save is no longer visible when the commit callback runs.
- How would you run code only when a listing's status goes from draft to published?Use the options on the saved-change predicate: `saved_change_to_status?(from: "draft", to: "published")` after the save. Before the save, `will_save_change_to_status?(from: "draft", to: "published")` asks the same about the pending change.
saying these in an interview costs you the question
- Using price_cents_changed? as the condition on an after_save callback.
- Saying previous_changes still holds every change made earlier in the transaction.
- Believing changes are applied only when the transaction commits.
- Expecting update_columns to leave a saved change that after_save can see.
- Thinking reload keeps the record of the last save's changes.