jhawthorn/discard: Soft Deletes for ActiveRecord Without the Cascade Problem
🃏🗑 Soft deletes for ActiveRecord done right
At a glance
- What is it?
- Discard is a small ActiveRecord mixin that flags rows as discarded with a discarded_at timestamp instead of deleting them. Its design rejects paranoia-style cascading deletes, which is the main reason to pick it and the main thing to understand before adopting it.
- Who is it for?
- Adopt jhawthorn/discard if you want a single discarded_at column, kept and discarded scopes, and full control over whether dependent records disappear; it suits teams already comfortable writing joins. Do not adopt it if you expect a discarded parent to take its children with it, or if you rely on counter caches reflecting only live rows, because the README lists both as non-features.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 99 days ago.
- What is it written in?
- Mainly Ruby, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 28, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The problem discard solves, and the paranoia baggage it refuses to carry
Most Rails apps eventually need a delete that is not a delete. A user closes an account but support needs the row back. A post is removed but its comments should stay readable in someone's history. The usual answer is acts_as_paranoid or paranoia, which sets a deleted_at timestamp and hides the record from ordinary queries. Discard does the same core job with a column named discarded_at, but it makes one deliberate break from that lineage: it does not cascade.
The README is blunt about why. Under paranoia, soft deleting a record destroys any dependent: :destroy associations, so every dependent model also has to be paranoid, and restoring becomes a timestamp-matching exercise. Discard's position is that this is usually wrong. Mark one row as discarded and use SQL joins when you want to hide related rows. The README's own example is a blog: a user's comment history may legitimately include comments on posts that were later discarded, so posts and comments are discarded independently. If you want comments to follow their post, you override the kept scope on Comment rather than relying on a callback cascade.
That is a real design opinion, not a missing feature, and it shapes who the gem is for. It suits engineers who are comfortable expressing visibility rules as queries. It does not suit anyone looking for a drop-in replacement that makes a whole object graph disappear.
How the discarded_at flag, scopes and callbacks actually behave
Including Discard::Model adds a small set of methods and scopes. Post.kept returns rows where discarded_at IS NULL; Post.discarded returns the opposite; Post.all is untouched unless you add a default scope yourself. On an instance, discard sets the timestamp and returns true, discard! raises Discard::RecordNotDiscarded on failure, and discarded?, undiscarded? and kept? report the current state. undiscard and undiscard! reverse it.
The mechanics are worth reading closely because they explain the sharp edges. Discarding updates the column via update_attribute, so validations do not run during discard or undiscard, though save and update callbacks do. The column flips between the before_ and after_ callbacks, which means before_discard sees discarded? as false and after_discard sees it as true. Callbacks written with if: :discarded? therefore fire on exactly one side of the transition, which the README presents as a deliberate convenience.
The default scope question is handled with unusual restraint. The README says adding one is "usually undesirable" and leaves it to you: default_scope -> { kept } makes Post.all return only kept rows, with Post.with_discarded available as the escape hatch. That is the right call for a library, because a hidden default scope is the kind of thing that produces a bug report six months later, but it does mean every query in your app has to be written with visibility in mind from day one.
Installing discard and discarding your first record
Installation is a Gemfile line and a bundle install. The README pins the major version:
gem 'discard', '~> 2.0'Then run bundle. Next, the record needs a discarded_at column. The README offers a generator:
rails generate migration add_discarded_at_to_posts discarded_at:datetime:indexor a hand-written migration. Note the index in that generator invocation; the README's manual version adds one too, and it is the column every kept query filters on:
class AddDiscardToPosts < ActiveRecord::Migration[5.0]
def change
add_column :posts, :discarded_at, :datetime
add_index :posts, :discarded_at
end
endWith the column in place, include the mixin in the model:
class Post < ActiveRecord::Base
include Discard::Model
endA first real use is a controller action. Replace destroy with discard and the row stays in the table with a timestamp:
def destroy
@post.discard
redirect_to users_url, notice: "Post removed"
endAfter that, Post.kept returns an empty array for the discarded post while Post.discarded returns it, which is the behaviour you should confirm before wiring the rest of the app.
Where discard is the wrong tool: counter caches, cascades and default scopes
The README's non-features section is the most useful part of the document, because it names the three places teams get burned. First, counter cache columns are not adjusted. A counter cache counts all rows, kept and discarded alike, so a posts_count that previously dropped when a post was destroyed will now stay put. If your UI reads those counters, they will be wrong in a way that is easy to miss.
Second, there are no recursive discards. Discarding a post does nothing to its comments unless you write an after_discard callback calling comments.discard_all, or restrict the association through a joined kept scope. The README also states that recursive restores are "fundamentally broken", which is a strong claim but a consistent one: if you never cascade the discard, you never need to cascade the restore.
Third, and easiest to underestimate, is the default scope. Because discard does not add one, every existing query that assumed deleted rows were gone will now see them. That is not a bug in the gem, but it is the migration cost. The performance note is related: discard_all and undiscard_all behave like destroy_all, running callbacks and validations with one query per record, and the README suggests scope.update_all(discarded_at: Time.current) when that is too slow. That trade is explicit: you give up callbacks to get a single statement.
discard versus paranoia, and the Devise integration
The nearest alternative is paranoia (and its acts_as_paranoid predecessors), and the difference is not cosmetic. Paranoia cascades: soft deleting a parent destroys dependent: :destroy associations, which forces those models into the same paranoid pattern and makes restoration depend on matching timestamps. Discard does the opposite. It marks exactly one record and expects you to express visibility through queries, with the README's Comment example showing an overridden kept scope that joins posts and filters both discarded_at columns. The README's claim is that SQL databases handle this well and performance should not be an issue, which is plausible for indexed columns but is an assertion rather than a measurement.
If you are migrating from paranoia, discard accommodates the column name: self.discard_column = :deleted_at keeps the existing schema in play, so you can change the library without a data migration. That is a genuinely useful escape hatch.
The other integration the README covers is Devise. Discarding a User does not by itself stop that user logging in or continuing an existing session, because Devise does not know about discarded_at. The documented fix is to override active_for_authentication? to return super && !discarded?. Anyone applying discard to an authentication model should treat that override as part of the installation, not an optional extra.
Maintenance, licence and what an upgrade costs
The repository is not archived and the last push was on 2026-06-22. The version to pin comes from the README's Gemfile example, which specifies '~> 2.0'. The repository carries a CHANGELOG.md, which is where version-to-version behaviour changes would be recorded; check it against your pinned constraint before bumping the major version, since the README's own example is written for 2.x.
The licence is MIT, per the LICENSE.txt file at the repository root. In practical terms that permits commercial and closed-source use with attribution requirements defined by the licence text itself; the repository's CODE_OF_CONDUCT.md and .yardopts indicate a conventionally maintained Ruby gem layout, with lib/, spec/ and a Rakefile. Nothing here is legal advice, and the licence file is the authority.
The upgrade surface is small by construction. The gem adds scopes and instance methods to models that include Discard::Model and expects one timestamp column. The costs that do grow are in your own code: every default_scope you add, every after_discard callback that calls discard_all, and every custom kept scope you write to emulate cascades. Those are application decisions, and they are where a future refactor will land.
Editorial conclusion
Adopt jhawthorn/discard if you want a single discarded_at column, kept and discarded scopes, and full control over whether dependent records disappear; it suits teams already comfortable writing joins. Do not adopt it if you expect a discarded parent to take its children with it, or if you rely on counter caches reflecting only live rows, because the README lists both as non-features. Before rolling it out, verify three things in your own schema: that discarded_at is indexed, that no default_scope is silently hiding records, and that Devise-backed models override active_for_authentication? if discarded users must lose access.
Frequently asked questions
Does jhawthorn/discard delete associated records when I discard a parent?
No. Recursive discards are listed as a non-feature; the README suggests either restricting associations through a joined kept scope or emulating the cascade with an after_discard callback that calls discard_all.
Can discard use an existing deleted_at column instead of discarded_at?
Yes. The README documents self.discard_column = :deleted_at for models migrating from paranoia, which lets you keep the existing column name.
Does discard add a default scope that hides discarded records?
It does not. The README calls default scopes usually undesirable and shows how to add one yourself with default_scope -> { kept }, with Post.with_discarded as the way to see everything.
Are validations run when a record is discarded?
No. The README states that validations are not run during discard or undiscard because the column is updated via update_attribute, while save and update callbacks do run.
Do discarded users stay logged in with Devise?
By default yes, since Devise does not check discarded_at. The README shows overriding active_for_authentication? to return super && !discarded? so discarded users cannot authenticate.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/jhawthorn-discard)