ruby 82 lines · 3 tabs

Versioning Rails Records with a JSON Snapshot Column and Diff Helper

Shared by codesnips Jul 2026
3 tabs
class AddVersionsToArticles < ActiveRecord::Migration[7.1]
  def change
    add_column :articles, :versions, :jsonb, null: false, default: []
    add_index :articles, :versions, using: :gin
  end
end
3 files · ruby Explain with highlit

This snippet shows a lightweight alternative to gems like PaperTrail: instead of tracking every column change through callbacks that touch a separate schema, each versioned record accumulates a compact history of JSON snapshots in a single jsonb column. The pattern suits cases where an approximate audit trail is enough and adding a full versions table feels heavy, and where storing the whole record shape as it existed at a point in time is more useful than a normalized change log.

In migration, a versions column is added as jsonb defaulting to an empty array, with a GIN index so individual snapshots can be queried later. Keeping the history inline means a record and its history load together in one row, avoiding a join at the cost of an ever-growing column — which is why the concern caps retention.

The Versionable concern is the core. It is designed to be mixed into any model that declares which attributes matter via versioned_attributes. On before_update, capture_version runs only when a watched attribute actually changed, using ActiveRecord's dirty tracking (saved_change_to_attribute? is avoided in favor of the pre-save changed? state so the snapshot reflects the values about to be written). Each snapshot records a monotonically increasing version, the attribute slice, and a timestamp, then trims to the most recent MAX_VERSIONS. Because the write happens inside the same UPDATE via self.versions =, the snapshot and the change are atomic — there is no window where a version is missing or duplicated.

The diff_with helper reconstructs what changed between any two stored versions by comparing their attribute hashes, returning a map of field => [old, new]. restore_version shows the payoff: a snapshot can be replayed back onto the record.

In Article model, the concern is wired up with has_versions :title, :body, :status, keeping the model itself declarative. The main trade-offs are unbounded growth without a cap, the loss of per-version foreign keys, and that jsonb diffs are computed in Ruby rather than SQL. For moderate-volume records needing a readable, self-contained history, it is a pragmatic fit.


Related snips

Share this code

Here's the card — post it anywhere.

Versioning Rails Records with a JSON Snapshot Column and Diff Helper — share card
Link copied