ruby 67 lines · 3 tabs

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

Shared by codesnips Jul 2026
3 tabs
class AddSoftDeleteToUsers < ActiveRecord::Migration[7.1]
  def change
    add_column :users, :deleted_at, :datetime

    add_index :users, :deleted_at, where: "deleted_at IS NULL", name: "index_users_on_live"

    # Uniqueness should only apply to live rows so a soft-deleted
    # user does not block re-registering the same email.
    remove_index :users, :email if index_exists?(:users, :email)
    add_index :users, :email,
              unique: true,
              where: "deleted_at IS NULL",
              name: "index_users_on_email_live"
  end
end
3 files · ruby Explain with highlit

This snippet shows a compact soft-delete implementation for Rails that avoids pulling in a full gem, keeping the behavior explicit and easy to reason about. Soft deletion means rows are never physically removed; instead a deleted_at timestamp marks them as gone, so records can be audited, undeleted, or referenced by historical data. The three tabs walk through the schema, a reusable concern, and the controller that drives the delete/restore flow.

The add soft-delete migration tab adds a nullable deleted_at timestamp and a partial index. The index is filtered with where: 'deleted_at IS NULL', which is important for two reasons: queries that scope to live rows hit a small, focused index, and any composite unique index on live data (like a unique email) only applies to non-deleted rows, so a soft-deleted user does not block re-registering the same email. The migration also demonstrates a Postgres partial unique index via add_index with both unique: true and a where clause.

The SoftDeletable concern centralizes the behavior. default_scope filters out deleted rows automatically for every query, which is convenient but a known footgun — it silently affects associations, find, and counts. To recover deleted rows the concern exposes unscoped-based scopes: with_deleted and only_deleted. The instance methods soft_delete and restore flip deleted_at using update_columns so they skip validations and callbacks and never re-trigger the destroy chain. deleted? is a simple predicate on the column.

A key trade-off is that default_scope interacts badly with Model.find when a row is soft-deleted, since it will raise RecordNotFound. That is why the UsersController restore path deliberately queries User.only_deleted in set_deleted_user rather than the default finder. In UsersController, destroy calls soft_delete instead of destroy, returning the record so the UI can offer an undo. restore finds the hidden record and clears its deleted_at. This pattern fits domains needing recoverable deletes and audit trails; the main pitfalls are default_scope leaking into unexpected queries and needing partial indexes to keep uniqueness correct.


Related snips

Share this code

Here's the card — post it anywhere.

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