Category guides

Soft deletes in Rails: Discard, Paranoia and acts_as_paranoid compared

Category guides · · Published 2026-11-04

Black pencils with white erasers standing in a grey concrete pot on a desk
Pencils with erasers: a mark you can take back, as long as nobody has torn out the page.

A user deletes a project by mistake, support wants it back, and the row is gone. Soft deletion avoids that by marking a record as deleted instead of removing it. Rails has no built-in way to do this, and the three gems that cover it disagree on one central question: should a soft-deleted record disappear from every query automatically, or only when you ask?

Release figures are from RubyGems.org, November 2026. The current Rails line is 8.1.

What soft deletion actually means

In practice it is a nullable column, usually a timestamp called deleted_at or discarded_at. A row with NULL is live; a row with a time is "deleted". Everything else is a matter of convention: which queries filter on that column, what happens to associated records, and how you eventually remove the data for good. The gems below differ precisely on those conventions, not on the column.

Discard: explicit scopes, nothing overridden

Discard (2.0.0, May 2026, MIT) is the option Paranoia's own README points new projects to. Its author, John Hawthorn, had helped maintain Paranoia before writing it, and the README sets out why he chose a different design: Discard does not override any Active Record method. It adds a discarded_at column, a few methods and a few scopes, and leaves destroy meaning destroy.

bin/rails generate migration add_discarded_at_to_posts discarded_at:datetime:index

class Post < ApplicationRecord
  include Discard::Model
end

post.discard          # sets discarded_at
post.undiscard        # clears it
Post.kept             # WHERE discarded_at IS NULL
Post.discarded        # WHERE discarded_at IS NOT NULL
Post.all              # still everything

The last line is the point. Because there is no default scope, every query that should hide discarded rows has to say so with kept. That is more typing, but it is visible in the code, and it means admin screens, reports and background jobs see what is really in the table. If you want a default scope anyway, the README shows how to add one yourself with default_scope -> { kept }, and self.discard_column = :deleted_at lets you reuse a column left behind by Paranoia.

Version 2.0 requires Active Record 7.0 or later (its gemspec accepts anything below 9.0) and, according to the changelog, now wraps discard and undiscard in a transaction so that an exception in a callback rolls back the write. Validations are not run when a record is discarded, and the README is frank about what the gem leaves out: no special handling of counter caches, which keep counting discarded rows, and no recursive discards through dependent: :destroy. Cascading is done in your own after_discard callback, or avoided by filtering children with a join:

class Comment < ApplicationRecord
  include Discard::Model
  belongs_to :post
  scope :kept, -> { undiscarded.joins(:post).merge(Post.kept) }
end

Choose it when you are starting fresh and want soft deletion to be an explicit, readable decision in each query.

Paranoia: destroy that only hides

Paranoia (3.1.0, November 2025, MIT) was written by Ryan Bigg as a lighter re-implementation of acts_as_paranoid, and for years it was the default answer. Adding acts_as_paranoid to a model changes the meaning of destroy: it sets deleted_at, and a default scope hides the row from every query.

class Client < ApplicationRecord
  acts_as_paranoid
end

client.destroy          # sets deleted_at
Client.with_deleted     # everything
Client.only_deleted     # just the hidden rows
client.restore(recursive: true)
client.really_destroy!  # actually DELETE

Its own README now opens with a notice that the gem "has some surprising behaviour (like overriding ActiveRecord's delete and destroy) and is not recommended for new projects", and that it accepts bug fixes and new Rails versions but no new features. It is still maintained in that sense: 3.1.0 added Rails 8.0 and 8.1 support, raised the minimum Ruby to 3.1 and dropped Rails 6.x. The gemspec accepts Active Record from 7 up to, but not including, 8.2.

The surprising behaviour is mostly about associations. A soft destroy cascades through dependent: :destroy: if the child model is also paranoid it is soft-deleted, but if it is not, it is really deleted. A single model without acts_as_paranoid in the chain is enough to lose data that the parent can no longer restore.

Choose it when an existing application already depends on it. For a new one, its maintainers point you elsewhere.

acts_as_paranoid: the original, still moving

acts_as_paranoid goes back to a Rails 1 era plugin by Rick Olson, from the same acts_as_ naming family as acts_as_list, which left Rails core in Rails 2.0. Today it is a gem maintained by the ActsAsParanoid organisation on GitHub. The current release, 0.11.0 (October 2025, MIT), targets Rails 6.1 to 8.1 and Ruby 3.1 or later.

The model API looks like Paranoia's (acts_as_paranoid, with_deleted, only_deleted), but the details differ. Records come back with recover, which by default also recovers dependent records deleted within two minutes of the parent; destroy_fully! removes a row for real. The column can be a time, a boolean or a string. One behaviour deserves a warning: by default, destroying a record that is already soft-deleted removes it from the database, which the README says will stop being the default in a future version. Set double_tap_destroys_fully: false if that is not what you want. The README also lists a known issue: using acts_as_paranoid and Active Storage on the same model leads to a SystemStackError.

Choose it when you want the default-scope model with more options than Paranoia, and the Active Storage limitation does not affect you.

The problems every soft delete shares

When not to soft delete

If the real need is to see what a record looked like before it was removed, or who removed it, a history gem may be the better tool. PaperTrail stores the state of a record before a destroy, so a deleted row can be rebuilt from its last version, and Logidze keeps changes in the row itself; our comparison of audit trail and versioning gems covers both. Another option is to move deleted rows into a separate archive table, which keeps the main table and its indexes clean. And for records that only change state, such as a closed account or an archived project, a plain status column often says more than a deletion timestamp.

Whatever you pick, run it through the gem evaluation checklist: these gems touch every query on the models that use them, and replacing one later means auditing all of those queries.

Summary

GemDefault scopeLatest releaseBest fit
DiscardNo, explicit kept2.0.0, May 2026New applications
ParanoiaYes, overrides destroy3.1.0, November 2025Existing apps already using it
acts_as_paranoidYes, overrides destroy0.11.0, October 2025Default-scope model with recovery options