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:
gem 'discard', '~> 1.2'Then run bundle install. Create a migration to add the discarded_at column to your models:
class AddDiscardedAtToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :discarded_at, :datetime
add_index :users, :discarded_at
end
endNotice 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:
class User < ApplicationRecord
include Discard::Model
endThat'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:
user = User.find(42)
user.discard # Sets discarded_at to Time.current
# Later, restore it:
user.undiscard # Sets discarded_at back to nilIn your views and serializers, use the kept scope by default:
class UsersController < ApplicationController
def index
@users = User.kept # Only active users
end
endThe 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:
class UsersController < ApplicationController
def index
@users = User.kept
end
def all_users_for_admin
# Explicitly include discarded when needed
@users = User.unscoped.all
end
endIf you must use a default scope, document it heavily and make sure your team understands the implications:
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
endMost 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:
class User < ApplicationRecord
include Discard::Model
has_many :orders, dependent: :discard
end
class Order < ApplicationRecord
include Discard::Model
belongs_to :user
endWith 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:
class User < ApplicationRecord
include Discard::Model
has_many :orders # Orders survive user deletion
endBe 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:
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
endSchedule it in your config/schedule.rb if using Whenever, or in your infrastructure as a cron job:
every 1.week do
runner 'PurgeOldDeletedRecordsJob.perform_later'
endUse 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:
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
endThis 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_atordiscarded_atcolumn to keep queries performant. - Avoid default scopes; use explicit
.keptor.discardedscopes instead for clarity. - Handle associations carefully with
dependent: :discardwhen 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.






Comments
Loading comments…