ruby 61 lines · 3 tabs

“Write Amplification” Guard: Only Update Changed Columns

Shared by codesnips Jan 2026
3 tabs
module WriteAmplificationGuard
  extend ActiveSupport::Concern

  def update_if_changed(attrs)
    changed = attrs.each_with_object({}) do |(key, value), acc|
      cast = self.class.type_for_attribute(key.to_s).cast(value)
      acc[key] = value unless attribute_for_database(key.to_s) == self.class.type_for_attribute(key.to_s).serialize(cast)
    end

    return false if changed.empty?

    update(changed)
  end

  def touch_throttled(column = :updated_at, every: 5.minutes, now: Time.current)
    stored = public_send(column)
    return false if stored.present? && stored > (now - every)

    update_column(column, now)
  end
end
3 files · ruby Explain with highlit

This snippet tackles a subtle source of database load in Rails apps: "write amplification" caused by UPDATE statements that touch columns whose values never actually changed. Even when ActiveRecord's dirty tracking prevents a full-row UPDATE, code paths that assign the same value, or that call touch/update_column unconditionally, still generate write traffic — new row versions in Postgres, WAL records, index maintenance, and replication bytes. On hot rows (a User whose last_seen_at is bumped on every request, a counter, a status flag) this churn dwarfs the useful work.

WriteAmplificationGuard concern centralizes the defensive logic. update_if_changed builds a hash of only the attributes whose incoming value differs from the current one, using attribute_for_database so type casting and comparison match what Postgres stores. If nothing differs it returns early without issuing SQL at all. touch_throttled guards the classic timestamp-bump pattern: it only writes when the stored timestamp is older than every, collapsing bursty updates (many requests per second) into at most one write per interval. The guard leans on will_save_change_to_attribute? semantics rather than blindly trusting the caller.

User model shows the concern in use. record_activity funnels through touch_throttled so a chatty endpoint cannot amplify last_seen_at writes, while promote_to uses update_if_changed so re-running a promotion is a genuine no-op. The after_update callback is written to fire side effects only when saved_change_to_role? is true, avoiding cascade work on rows that did not really change.

ActivityController is the trigger. It calls record_activity on every authenticated request but relies on the throttle to keep the write rate bounded, and it uses promote_to idempotently so retries and double-clicks are cheap.

The trade-off is an extra read/compare in Ruby to avoid a write in Postgres, which is almost always favorable because writes are far more expensive and contend on locks and indexes. The main pitfall is stale in-memory objects: comparisons use the loaded attributes, so a caller working from a fresh reload (or accepting a small throttle window) keeps the guard correct under concurrency.


Related snips

Share this code

Here's the card — post it anywhere.

“Write Amplification” Guard: Only Update Changed Columns — share card
Link copied