RUBY ON RAILS · 16 MIN READ ·

Rails Audit Logging with PaperTrail: Change History, Compliance Trails, and Restoring Deleted Records

Rails audit logging with PaperTrail: track every model change, restore deleted records, meet SOC 2/GDPR, and query change history at production scale.

Rails Audit Logging with PaperTrail: Change History, Compliance Trails, and Restoring Deleted Records

The auditor asked a question I had never heard phrased quite that way. “Can you show me who changed this customer’s billing address on the fourteenth of March, at what time, from what value to what value, and from which admin session?” It was a SOC 2 Type II readiness review, the company was a healthtech scale-up, and the answer at that moment was “no.” There was a updated_at column. There was an admin. There was a Postgres backup somewhere in cold storage. There was no Rails audit logging. Twenty minutes later I was in a war room with the CTO explaining that we were going to install PaperTrail before the auditor’s next visit, and that everything we had shipped for the past three years was going to be a black box in the audit report.

After nineteen years of Rails I have watched this scene play out at least a dozen times. Somebody sells to a regulated buyer, or gets acquired, or has a data-subject access request under GDPR, and suddenly “we log the important stuff to Papertrail… wait, no, to Papertrail the log service, not the audit gem, sorry” becomes a very expensive conversation. Rails audit logging is the kind of infrastructure that costs almost nothing to install on day one, and costs six figures of engineering time to backfill on year three. This post is the playbook I hand teams when they realise they need it yesterday.

What Rails Audit Logging Actually Needs to Do

Before we install a gem, it is worth being precise. Auditors, security officers and lawyers are asking four different questions when they ask about a change history:

  • Who made a change. Not just user_id, but the actor’s role, IP, session and whether it was an API token, an admin impersonation, or a background job.
  • What changed. Both the before and after values, not just a boolean “this record was updated.”
  • When. To sub-second precision, in a timezone you trust, ideally with the wall-clock and the database clock.
  • Why. A reason, ticket ID, or context tag that connects the change to a business decision. This is the field everyone forgets and every regulator asks for.

A PaperTrail::Version row with whodunnit, object, object_changes and a meta column populated from your controller layer answers all four. Rails itself will not do this for you. updated_at and Rails logs are not an audit trail — they are debugging aids that happen to include timestamps.

Installing PaperTrail the Right Way

The gem itself is boring, which is exactly what you want in security infrastructure. Add it, generate the migration, run it:

# Gemfile
gem "paper_trail", "~> 15.2"
bin/rails g paper_trail:install --with-changes
bin/rails db:migrate

The --with-changes flag matters. Without it, you get versions.object — a YAML/JSON blob of the record before the change. With it, you also get versions.object_changes — a diff of exactly what changed. When a compliance officer asks “what changed on the fourteenth of March,” you want to answer in one query, not by diffing two 200-column YAML dumps.

Then, on every model that touches something a regulator or customer might ask about:

class User < ApplicationRecord
  has_paper_trail(
    on: [:create, :update, :destroy],
    ignore: [:updated_at, :last_seen_at, :sign_in_count],
    meta: {
      tenant_id:    :tenant_id,
      email_hash:   ->(u) { Digest::SHA256.hexdigest(u.email.to_s.downcase) }
    }
  )
end

Two things worth flagging. First, always exclude fields that change on every request — last_seen_at, sign_in_count, current_sign_in_ip — otherwise every page load creates a version row and your versions table becomes the biggest table in the database within a quarter. Second, put the tenant scoping key into meta. You will thank yourself the first time you need to purge a single tenant under a GDPR right-to-erasure request.

Capturing Who and Why in the Controller

Out of the box, PaperTrail’s whodunnit is set to Current.user-ish only if you tell it. The pattern I use everywhere:

class ApplicationController < ActionController::Base
  before_action :set_paper_trail_whodunnit
  before_action :set_paper_trail_context

  private

  def user_for_paper_trail
    return "system"        if current_user.nil? && Current.system_actor?
    return "api:#{api_key.id}" if api_key.present?
    current_user&.id&.to_s || "anonymous"
  end

  def info_for_paper_trail
    {
      ip:         request.remote_ip,
      user_agent: request.user_agent&.first(255),
      request_id: request.request_id,
      reason:     request.headers["X-Change-Reason"]
    }
  end

  def set_paper_trail_context
    PaperTrail.request.controller_info = info_for_paper_trail
  end
end

The X-Change-Reason header is the least-technical trick in this post and it pays for itself every audit. Any internal admin tool, Retool-style dashboard, or ops runbook sets this header with a ticket or reason string. Then, six months later, when an auditor asks “why did engineering update these 400 rows,” you have SELECT COUNT(*) FROM versions WHERE object_changes->>'balance' IS NOT NULL AND meta->>'reason' LIKE 'INCIDENT-4412%' and a clean answer.

If you also use API tokens, you need to distinguish between “the user did this” and “an integration acting on their behalf did this.” Both matter. whodunnit should reflect the actor, meta should carry the acting identity.

Restoring Deleted Records From a Version

The classic demo of PaperTrail is un-deletion. It is worth understanding because half the compliance stories in the healthtech and fintech worlds start with “a support agent clicked the wrong Delete.” With on: [:destroy] enabled:

version = PaperTrail::Version.where(item_type: "User", item_id: 42).last
user    = version.reify
user.save!

reify reconstructs the record from object at the moment of destroy. It also has options for restoring has_many associations, which is where things get subtle:

version.reify(has_many: true, has_one: true, mark_for_destruction: true).save!

That will pull nested associations back into memory. It does not replay every change since the destroy — if a related model was itself modified after the parent was deleted, the reified graph reflects the state at destroy-time, not now. I have seen teams treat reify as a general-purpose undo. It is not. It is a point-in-time restore for one record. Anything more sophisticated needs event sourcing or database-level PITR.

Querying Change History Without Killing Postgres

The moment your versions table crosses a few million rows, naive queries fall over. versions is unusual: it is append-only, has variable-width JSON payloads, and is queried by four different access patterns (by item, by user, by time, by field). You need indexes and, honestly, you need to plan for eventual partitioning.

The indexes I add before I ship PaperTrail to production:

class OptimizeVersionsIndexes < ActiveRecord::Migration[7.2]
  def change
    add_index :versions, [:item_type, :item_id, :created_at],
              name: "idx_versions_item_time"
    add_index :versions, [:whodunnit, :created_at],
              name: "idx_versions_actor_time"
    add_index :versions, :created_at,
              name: "idx_versions_time"

    # Postgres-only: JSONB GIN on object_changes for field-level queries
    execute <<~SQL
      CREATE INDEX CONCURRENTLY idx_versions_changes_gin
      ON versions USING gin (object_changes jsonb_path_ops);
    SQL
  end

  def down
    remove_index :versions, name: "idx_versions_item_time"
    remove_index :versions, name: "idx_versions_actor_time"
    remove_index :versions, name: "idx_versions_time"
    execute "DROP INDEX IF EXISTS idx_versions_changes_gin;"
  end
end

Wrap the JSONB migration in disable_ddl_transaction! and use CREATE INDEX CONCURRENTLY. I go deeper on this pattern in Rails strong migrations — it is the difference between a smooth deploy and an incident.

Configure PaperTrail to store JSON in a JSONB column, not text:

# config/initializers/paper_trail.rb
PaperTrail.config.object_changes_adapter = PaperTrail::JSON
PaperTrail.serializer = PaperTrail::Serializers::JSON

Then a “who changed the billing address” query is fast:

PaperTrail::Version
  .where(item_type: "Customer", item_id: customer.id)
  .where("object_changes ? 'billing_address'")
  .order(created_at: :desc)
  .limit(50)

Rails Audit Logging Without PaperTrail: Audited and DIY

PaperTrail is the default, but it is not the only option. The audited gem takes a slightly different shape — it stores each audit as an Audited::Audit row with audited_changes as a hash, and it has cleaner defaults for polymorphic associations. If you inherit a codebase already using audited, do not migrate to PaperTrail just to migrate. They are functionally equivalent for the questions most auditors ask.

Rolling your own is a tempting third option. Do not. I have watched three teams build “just a simple audit_logs table with an after_commit hook.” All three ended up with:

  • A silent gap where transactions that raised didn’t audit-log — because they used after_save, not after_commit.
  • No object field, only object_changes, so they could not reconstruct historical state.
  • No whodunnit on background jobs, because nobody remembered to thread the actor.
  • A serialize column that stored Ruby-marshalled objects, which broke when the class hierarchy changed.

PaperTrail’s authors solved these problems over a decade of production usage. Use the gem.

Compliance Realities: SOC 2, GDPR, and Retention

Two questions come up from every regulated buyer. First, how long do you retain audit history. Second, how do you delete a user’s data when they exercise their GDPR right to erasure without destroying the audit trail that proves you handled the request correctly.

Retention policy first. SOC 2 does not prescribe a number, but auditors will ask. The pragmatic answer is seven years for financial data, three years for other regulated data, and forever for security-relevant changes (role assignments, permission grants, admin access). Enforce it with a scheduled job:

class VersionRetentionJob < ApplicationJob
  queue_as :maintenance

  def perform
    PaperTrail::Version
      .where("created_at < ?", 7.years.ago)
      .where.not("meta->>'category' = 'security'")
      .in_batches(of: 5_000) { |batch| batch.delete_all }
  end
end

I run this weekly, and I move the deleted rows into a cold-storage table first for one more year before actually deleting. Auditors like the paper trail of the paper trail.

For GDPR, the pattern that works is pseudonymisation rather than deletion. When a user requests erasure, you replace personally-identifying fields in their versions with a stable hash. The audit trail — someone with this pseudonymous ID changed this record on this date — survives, but the PII does not. There is a whole post to write about scrubbing PII correctly, and I touched on it from a different angle in Sentry PII scrubbing.

Performance Warnings I Keep Repeating

Three things trip up teams every single time.

Do not audit every column of every model. Wide models, especially ones with counter caches and denormalised fields, will generate a version on every write. Use ignore: liberally and be honest about what actually matters. A Post.views_count update six times per minute is not something an auditor cares about.

Never audit inside a large batch job unaudited. If you are running a data migration or a bulk update_all, PaperTrail is bypassed — update_all skips callbacks. This is sometimes what you want. It is sometimes a bug. Decide explicitly. If you need audit rows for a migration, find_each { |r| r.update!(...) } at the cost of speed, or write a custom PaperTrail::Version row per record explaining the migration. I usually pick the latter for anything touching customer-visible fields.

Watch the versions table size. At one client we had a versions table larger than the entire rest of the database within eight months, because someone had audited a model with a last_pinged_at field that updated every 30 seconds. Partition, purge, and monitor row-count growth as a first-class metric.

Wiring PaperTrail Into Background Jobs

The single most common bug I see: background jobs create versions with whodunnit = nil. The fix is threading the actor identity through the job:

class ProcessRefundJob < ApplicationJob
  def perform(refund_id, actor_id: nil, reason: nil)
    PaperTrail.request(whodunnit: actor_id || "system") do
      PaperTrail.request.controller_info = { reason: reason, source: "job" }
      Refund.find(refund_id).process!
    end
  end
end

PaperTrail.request is a scoped setter. Every change made inside the block gets that whodunnit and controller info. When you enqueue the job from a controller, pass Current.user.id and the ticket reason through:

ProcessRefundJob.perform_later(
  refund.id,
  actor_id: current_user.id,
  reason:   params[:reason]
)

Do this everywhere and the audit trail becomes coherent across sync and async paths. Skip it and half your changes look like they came from ghosts.

Frequently Asked Questions

How is PaperTrail different from updated_at and Rails logs?

updated_at tells you when a row last changed but not what changed or who changed it. Rails logs contain request data but are rotated, unindexed, and rarely retained long enough for compliance. Rails audit logging with PaperTrail creates a structured, queryable, indexed table of who/what/when/why for every model change you care about, retained on your retention policy rather than your log vendor’s.

Does PaperTrail work with Rails 8 and modern databases?

Yes. PaperTrail 15.x supports Rails 7.1, 7.2 and 8.0, and works with Postgres, MySQL and SQLite. On Postgres, use JSONB for object and object_changes and add GIN indexes for field-level queries — that is where the performance leverage is.

How do I audit changes made by background jobs?

Wrap the job body in PaperTrail.request(whodunnit: actor_id) { ... } and pass the acting user’s ID and a reason when you enqueue the job. Without this, jobs create versions with whodunnit = nil, which every auditor immediately flags as a gap.

Can I use PaperTrail for GDPR right-to-erasure requests?

Yes, but do it with pseudonymisation rather than deletion. Replace PII in the affected versions rows with a stable per-user hash, preserving the audit trail (someone with this pseudonymous ID did X) while removing the identifying data. Deleting the version rows outright means you lose the evidence that you handled the erasure request correctly.

Need help wiring Rails audit logging into a legacy codebase before an audit? TTB Software specialises in preparing Rails applications for SOC 2, ISO 27001 and GDPR reviews. We have been doing this for nineteen years, and we have seen the questions auditors actually ask.

#rails-audit-logging #rails-papertrail #papertrail-gem #rails-audit-trail #rails-versioning #soc2-rails #gdpr-rails

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software