ruby 75 lines · 3 tabs

Soft-Delete in Rails with a Default Scope, Restore Action, and Unique Index Handling

Shared by codesnips Aug 2026
3 tabs
class AddSoftDeleteToDocuments < ActiveRecord::Migration[7.0]
  def change
    add_column :documents, :deleted_at, :datetime
    add_index :documents, :deleted_at

    # Replace the global unique index with one scoped to live rows,
    # so a slug can be reused after a record is soft-deleted.
    remove_index :documents, :slug
    add_index :documents, :slug,
              unique: true,
              where: "deleted_at IS NULL",
              name: "index_documents_on_slug_when_live"
  end
end
3 files · ruby Explain with highlit

Soft-deleting means marking a row as gone with a deleted_at timestamp instead of running a destructive DELETE. This snippet shows the whole loop across three collaborating files: a migration that adds the column and adjusts uniqueness, a reusable SoftDeletable concern that hides deleted rows and adds restore behavior, and the controller that wires up destroy and a custom restore action.

In add_soft_delete_to_documents migration, a nullable deleted_at column is added with an index, since it is queried on every request. The migration also drops the plain unique index on slug and replaces it with a partial index (where: "deleted_at IS NULL"). This matters: without it a user could not reuse the slug of a soft-deleted record, and truly deleting-then-recreating would collide. Scoping uniqueness to live rows keeps the constraint useful while allowing soft-deleted duplicates to linger.

The SoftDeletable concern centralizes the pattern. Its default_scope filters to deleted_at: nil so ordinary queries, associations, and finders automatically skip deleted rows — the big convenience, and also the big pitfall, since a default_scope silently affects everything and can surprise callers. Named scopes with_deleted and only_deleted provide escape hatches. unscoped is used inside with_deleted to bypass the default scope cleanly. The instance methods soft_delete, restore, and deleted? toggle the timestamp; soft_delete uses update_column to skip validations and callbacks so removal cannot be blocked by a stale validation, while restore uses update so any re-activation logic still runs.

Overriding destroy makes the model soft-delete by default, so existing destroy call sites and dependent: :destroy associations keep working without changes. A separate hard_destroy remains for genuine purges (GDPR, admin cleanup).

In DocumentsController, destroy simply calls document.destroy and the record disappears from normal views. The restore action loads through with_deleted — necessary because the default scope would otherwise make the deleted record unfindable — then calls restore. This is the key edge case: any controller working with deleted rows must opt out of the default scope explicitly. The trade-off is more careful querying in exchange for recoverable data and audit-friendly history.


Related snips

Share this code

Here's the card — post it anywhere.

Soft-Delete in Rails with a Default Scope, Restore Action, and Unique Index Handling — share card
Link copied