Category guides

Audit trails and record versioning in Rails: PaperTrail, Audited and Logidze

Category guides · · Published 2026-11-02

An open handwritten account book from the 1770s, with dated entries and columns of figures, in a museum display case
A shopkeeper's account book from the 1770s: every entry dated, nothing erased.

Sooner or later someone asks who changed a price, when an account was closed, or what a record looked like last Tuesday. Rails keeps no history of its own, so the answer comes from a gem. Three cover most applications, and the real choice between them is where the history is written: by Ruby callbacks into a separate table, or by the database itself.

Release figures are from RubyGems.org, November 2026. The latest Rails release at the time of writing is 8.1.4, from September 2026.

Audit trail or versioning?

The two needs overlap but are not the same. An audit trail answers "who did what, and when", and is read by support staff, compliance or a security review. Versioning answers "what did this record look like before", and is used to show a diff, undo a mistake or restore a deleted row. All three gems below store enough to do both; they differ in how much effort each side takes.

PaperTrail: a versions table and reify

PaperTrail (17.0.0, October 2025) is the most widely installed of the three. Its README states compatibility explicitly: version 17 requires Ruby 3.2 or later and Active Record 7.1 to 8.1. Installation adds one polymorphic versions table shared by every tracked model:

bundle exec rails generate paper_trail:install --with-changes
bundle exec rails db:migrate

class Product < ApplicationRecord
  has_paper_trail ignore: [:updated_at], skip: [:internal_notes]
end

class ApplicationController < ActionController::Base
  before_action :set_paper_trail_whodunnit
end

Each version stores the event (create, update, destroy), the whodunnit (by default the ID returned by current_user) and the state of the record before the change. That last design choice is what makes PaperTrail strong at versioning: version.reify rebuilds the old object, product.paper_trail.previous_version walks back through history, and a destroyed record can be restored from its last version. The --with-changes option adds an object_changes column with the diff of each update.

version = product.versions.last
version.event       # "update"
version.whodunnit   # "42"
old = version.reify # the product as it was before the update
old.save!           # revert

Points to check: objects are serialized as YAML by default, and the README recommends json or jsonb columns if you want to query changes with where_object_changes. Tracking associations was extracted into a separate gem, paper_trail-association_tracking, and is not part of the core. A version_limit setting caps how many versions each record keeps.

Choose it when you need to show or restore earlier versions of records, and want the most documented option.

Audited: an audits table with comments and associations

Audited (5.8.0, November 2024) began life as the acts_as_audited plugin and is published from the collectiveidea organisation on GitHub. Its gemspec accepts Active Record from 5.2 up to, but not including, 8.2, so it installs on Rails 8.1; the README's compatibility line, written for 5.6, still lists Rails 7.2 as the newest tested version, so run your own test suite before relying on it.

rails generate audited:install --audited-changes-column-type jsonb
rails db:migrate

class Account < ApplicationRecord
  audited except: [:password_digest], associated_with: :company
end

class Company < ApplicationRecord
  has_associated_audits
end

Each audit stores the action and a hash of changes, so it reads naturally as a log: account.audits.last.audited_changes returns {"plan" => ["basic", "pro"]}. Within a request, the current user is recorded automatically through current_user; outside one, a block such as Audited.audit_class.as_user(admin) { ... } sets it. Audited's distinctive features are on the audit side: an audit_comment attribute, with an option to require a comment on every change, and associated_with, which lets you list every audit of every account belonging to one company. Previous states are available through revisions and revision_at(date).

Releases have slowed: the latest is two years old. That is not a problem for a mature gem with a narrow job, but it is the kind of signal the gem evaluation checklist asks you to weigh, especially before a Rails upgrade beyond 8.1.

Choose it when the history is mainly read by people, who need to see who changed what, with a reason, grouped by parent record.

Logidze: history kept by PostgreSQL triggers

Logidze (1.4.1, June 2025), built by Evil Martians, takes a different route. It works only with PostgreSQL (version 10 or later, per its README) and installs database triggers that write each change into a log_data JSONB column on the row itself, so no extra table is needed. The 1.x line requires Rails 6.0 or later.

bundle exec rails generate logidze:install
bundle exec rails generate logidze:model Post --only=title,body,status
bundle exec rails db:migrate

post.at(time: 2.days.ago)           # the post as it was then
post.diff_from(time: 1.hour.ago)    # what changed since
post.undo!                          # step back one version

Logidze.with_responsible(current_user&.id) { post.save! }

Because the trigger runs in the database, changes made by update_all, raw SQL or another application sharing the database are recorded too; the README even documents Logidze.without_logging for the bulk updates you do not want logged. Logidze uses SQL functions and triggers, so you either switch to structure.sql for the schema dump or add the fx gem, which keeps schema.rb working. If a growing log_data column worries you, a --limit option caps the number of versions per row, and a detached mode stores history in a separate logidze_data table instead.

Choose it when you run PostgreSQL, write data outside Active Record callbacks, or care about the write overhead of an extra INSERT per change.

The changes callbacks never see

PaperTrail and Audited hook into Active Record callbacks. The Rails guide to callbacks lists the methods that skip callbacks entirely, among them update_all, update_column, update_columns, delete, delete_all, insert_all and upsert_all. Any of those, a data migration in SQL, or a console session with tracking switched off, leaves no trace in a callback-based audit log. If the log has to be complete, for example for a regulatory requirement, either forbid those methods on audited models in code review or choose a trigger-based approach.

Practical advice for any of the three

Summary

GemStorageLatest releaseBest fit
PaperTrailShared versions table, callbacks17.0.0, October 2025Versioning, reverting and restoring records
AuditedShared audits table, callbacks5.8.0, November 2024Human-readable audit logs with comments
LogidzeJSONB column via PostgreSQL triggers1.4.1, June 2025Complete history, including SQL and bulk updates