Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 076 / 100

Soft deletes in Rails: a practical guide for developers

Recoverable deletes.

Expona Admin7 min read

Introduction

In production systems handling critical data, permanent deletion is often too risky. Soft deletes in Rails: a practical guide for developers—that's what we're covering today because recovering accidentally deleted records can be the difference between keeping your customer's trust and spending hours restoring from backups. We've all seen the request come in: "Can you bring back that order from last week?" Hard deletes make that impossible; soft deletes make it trivial.

Whether you're managing user accounts, financial transactions, or marketplace listings, soft deletes give you a safety net. You mark records as deleted without actually removing them from the database, preserving referential integrity and audit trails while keeping queries performant. Let's walk through the practical implementation, the gotchas you'll encounter, and how to handle purging when you eventually need to reclaim space.

Why soft deletes matter in production

When you permanently delete a record, you lose historical context. A customer service representative can't see why an order disappeared. A financial auditor can't trace transaction chains. A compliance officer has no proof a record ever existed. Soft deletes solve these problems by keeping the data but hiding it from normal queries.

The tradeoff is straightforward: you trade disk space and query complexity for data recovery and auditability. In our experience across e-commerce, SaaS, and trading platforms, that's almost always worth it.

The discard gem: the pragmatic choice

Ruby has several soft delete options, but the discard gem stands out for its simplicity and flexibility. Unlike some alternatives that monkey-patch Active Record heavily, discard takes a minimal approach that integrates naturally with Rails conventions.

Start by adding it to your Gemfile:

ruby
gem 'discard', '~> 1.2'

Then run bundle install. Create a migration to add the discarded_at column to your models:

ruby
class AddDiscardedAtToUsers < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :discarded_at, :datetime
    add_index :users, :discarded_at
  end
end

Notice the index—you'll query by discarded_at constantly. Without the index, your queries will scan the entire table.

In your model, include the Discard::Model concern:

ruby
class User < ApplicationRecord
  include Discard::Model
end

That's it. Now your model has discard, undiscard, and discarded? methods. You can also use kept and discarded scopes for filtering.

The deleted_at column approach: understanding the mechanics

Whether you use discard or implement soft deletes manually, the mechanism is always the same: a timestamp column that marks when a record was deleted. Let's explore how this works and why it matters.

When a record is deleted, you set deleted_at (or discarded_at) to the current time. A nil value means the record is active. This is simpler than a boolean flag because it lets you track exactly when deletion happened—useful for compliance, audits, and debugging.

Here's what a typical soft delete operation looks like:

ruby
user = User.find(42)
user.discard  # Sets discarded_at to Time.current

# Later, restore it:
user.undiscard  # Sets discarded_at back to nil

In your views and serializers, use the kept scope by default:

ruby
class UsersController < ApplicationController
  def index
    @users = User.kept  # Only active users
  end
end

The discard gem automatically excludes discarded records in belongs_to and has_many associations, though you can override this behavior when needed for auditing or admin interfaces.

Default scopes carefully: the pitfall of global filtering

The temptation is to add a default scope that automatically filters out soft-deleted records everywhere. Resist this temptation—or use it very carefully.

A default scope silently applied to every query creates surprising bugs. A query you thought returned all records actually returned a subset. A count for dashboard metrics becomes inaccurate. Join operations that worked before now produce missing records. We've seen production incidents caused by this.

Instead, be explicit. Use User.kept where you need it:

ruby
class UsersController < ApplicationController
  def index
    @users = User.kept
  end

  def all_users_for_admin
    # Explicitly include discarded when needed
    @users = User.unscoped.all
  end
end

If you must use a default scope, document it heavily and make sure your team understands the implications:

ruby
class Post < ApplicationRecord
  include Discard::Model

  # Only applies to user-facing queries by default
  # Use Post.discarded or Post.unscoped for admin access
  default_scope { kept }

  # But this makes testing harder and queries less obvious
end

Most production teams we work with avoid default scopes entirely, preferring explicit .kept calls. It's slightly more verbose but much easier to reason about and audit.

Handling associations with care

When records are associated, soft deletes introduce complexity. If you soft-delete a user, what happens to their orders? In most systems, you keep the orders but mark them as belonging to a deleted user.

Here's a realistic scenario—an e-commerce system:

ruby
class User < ApplicationRecord
  include Discard::Model
  has_many :orders, dependent: :discard
end

class Order < ApplicationRecord
  include Discard::Model
  belongs_to :user
end

With dependent: :discard, deleting a user soft-deletes all their orders too. This is often what you want—if a user requests account deletion under GDPR, their entire history gets hidden together.

However, sometimes you want orders to persist even after user deletion (for accounting purposes). In that case, don't use dependent deletion at all:

ruby
class User < ApplicationRecord
  include Discard::Model
  has_many :orders  # Orders survive user deletion
end

Be consistent across your codebase. A rule that works differently for different models becomes a source of bugs.

Purge old records with jobs: eventually clearing the graveyard

Soft deletes can't live forever. Eventually, you'll have compliance requirements (GDPR's right to be forgotten), storage concerns, or just cleanliness. Set up a background job to purge old soft-deleted records.

Create a scheduled job that runs weekly or monthly:

ruby
class PurgeOldDeletedRecordsJob < ApplicationJob
  queue_as :default

  def perform
    # Delete records discarded more than 90 days ago
    cutoff_date = 90.days.ago
    
    User.discarded.where('discarded_at < ?', cutoff_date).find_each do |user|
      user.destroy  # Permanently remove
    end

    Order.discarded.where('discarded_at < ?', cutoff_date).find_each do |order|
      order.destroy
    end
  end
end

Schedule it in your config/schedule.rb if using Whenever, or in your infrastructure as a cron job:

ruby
every 1.week do
  runner 'PurgeOldDeletedRecordsJob.perform_later'
end

Use find_each to avoid loading all records into memory at once. For large tables, this makes a difference. Also consider whether you need to log these permanent deletions for compliance—if so, create an audit log before destroying:

ruby
class PurgeOldDeletedRecordsJob < ApplicationJob
  queue_as :default

  def perform
    cutoff_date = 90.days.ago
    
    User.discarded.where('discarded_at < ?', cutoff_date).find_each do |user|
      AuditLog.create!(
        action: 'permanent_delete',
        model: 'User',
        record_id: user.id,
        data: user.attributes
      )
      user.destroy
    end
  end
end

This gives you a paper trail even after permanent deletion.

Common mistakes

Not indexing the deleted_at column: Your dashboard query that counts active users suddenly takes 10 seconds. Index it immediately after creating the migration.

Forgetting to filter in reports: Your monthly revenue report includes orders from deleted users and accounts. Always use .kept in reporting queries unless you specifically need discarded records.

Mixing soft and hard deletes: Using soft deletes for users but hard deletes for sessions creates inconsistency. Pick a strategy and stick to it across your domain model.

Using default scopes without documentation: A junior developer adds a column to a table and suddenly admin dashboards show the wrong data because they didn't know about the hidden default scope. Make it explicit instead.

Not handling dependent associations: A soft-deleted user still has a current_order association that points to a live order, but the user is invisible in queries. Test your edge cases.

Key takeaways

  • Soft deletes preserve data safety and auditability—use them in production systems handling critical information.
  • The discard gem provides a clean, minimal implementation; add it to projects that need soft delete behavior.
  • Always index the deleted_at or discarded_at column to keep queries performant.
  • Avoid default scopes; use explicit .kept or .discarded scopes instead for clarity.
  • Handle associations carefully with dependent: :discard when appropriate.
  • Schedule background jobs to purge old soft-deleted records based on your compliance requirements.
  • Log permanent deletions for audit trails, especially under GDPR and similar regulations.
  • Test soft delete behavior thoroughly—edge cases around associations and reporting are common sources of bugs.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles