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
module SoftDeletable
extend ActiveSupport::Concern
included do
default_scope { where(deleted_at: nil) }
scope :with_deleted, -> { unscope(where: :deleted_at) }
scope :only_deleted, -> { unscope(where: :deleted_at).where.not(deleted_at: nil) }
end
def soft_delete
return true if deleted?
update_columns(deleted_at: Time.current)
end
def restore
return true unless deleted?
update_columns(deleted_at: nil)
end
def deleted?
deleted_at.present?
end
end
class UsersController < ApplicationController
before_action :set_user, only: :destroy
before_action :set_deleted_user, only: :restore
def destroy
@user.soft_delete
redirect_to users_path,
notice: "User archived.",
flash: { undo_id: @user.id }
end
def restore
@user.restore
redirect_to user_path(@user), notice: "User restored."
end
private
def set_user
@user = User.find(params[:id])
end
# default_scope hides soft-deleted rows, so restore must look
# them up explicitly through only_deleted.
def set_deleted_user
@user = User.only_deleted.find(params[:id])
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.