Soft deletes in Rails: Discard, Paranoia and acts_as_paranoid compared

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
- Unique indexes. A unique index on
emailstill sees the deleted row, so a user cannot sign up again with the same address. On PostgreSQL and SQLite, make the index partial:add_index :users, :email, unique: true, where: "discarded_at IS NULL". Paranoia's README recommends the same for ordinary indexes, so that they stay as fast as before. - Uniqueness validations. Rails'
validates :email, uniqueness: trueruns its own query and does not know which rows are deleted. Addconditions: -> { kept }(or the equivalent scope) to match the partial index. - Sign-in. A discarded user can still log in unless the authentication layer checks. With Devise, the Discard README suggests overriding
active_for_authentication?to return false for discarded users; see our guide to Rails authentication for the wider choice. - Bulk operations.
discard_allruns callbacks once per record, likedestroy_all. For large sets, Discard's README suggestsupdate_all(discarded_at: Time.current), at the cost of skipping callbacks. - Real deletion. Hidden personal data is still personal data. If users can ask for their data to be erased, or your retention policy has a time limit, you need a job that deletes old soft-deleted rows for good. Soft deletion is a safety net, not an archive.
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
| Gem | Default scope | Latest release | Best fit |
|---|---|---|---|
| Discard | No, explicit kept | 2.0.0, May 2026 | New applications |
| Paranoia | Yes, overrides destroy | 3.1.0, November 2025 | Existing apps already using it |
| acts_as_paranoid | Yes, overrides destroy | 0.11.0, October 2025 | Default-scope model with recovery options |
Category guidesAudit trails and versioning
PaperTrail, Audited or Logidze: a versions table, an audits table, or a trigger-maintained log in a JSONB column.
Plugin historiesacts_as_list
How acts_as_list keeps Active Record rows in order, its history since it left Rails core in Rails 2.0, its status in 2026, and alternatives such as positioning and ranked-model.
GuidesEvaluating a Rails gem
A practical checklist for judging a Ruby gem: maintenance signals, compatibility with Rails 7 and 8, dependencies, licence, and security advisories with bundler-audit.