LaunchKit
← All posts
· 6 min read · by The LaunchKit team · 3 views

Soft delete in Rails, and the four things default_scope breaks

You add a deleted_at column, a default_scope, and an hour later everything looks right. The rows disappear from the index page, the data is still in the table, and you move on. The four things below are what you find out later, and only one of them throws an error when it happens.

Everything here was measured on Rails 8.1.4 against a rails new --minimal app with two models, a Post and its Comment.

class Post < ApplicationRecord
  default_scope { where(deleted_at: nil) }
  has_many :comments
  validates :title, uniqueness: true

  def discard! = update!(deleted_at: Time.current)
end

The happy path does work, and it's worth seeing before the rest. After post.discard!, Post.count is 0, Post.unscoped.count is 1, and Post.find(post.id) raises ActiveRecord::RecordNotFound. The scope compiles to exactly what you'd write by hand, "posts"."deleted_at" IS NULL.

Your uniqueness validation ignores the scope

This is the one that surprises people, and it isn't a bug.

post.discard!
Post.new(title: "Hello").valid?   # => false

The post is gone from every query you'll ever write, and the validation still says the title is taken. The reason is one line in Rails:

# activerecord-8.1.4/lib/active_record/validations/uniqueness.rb:113
def build_relation(klass, attribute, value)
  relation = klass.unscoped

unscoped, deliberately. A uniqueness validation that honoured default_scope would let you create duplicates that the scope happens to hide, which is a worse default than this one. But it means your soft delete does the opposite of what people expect: the name stays reserved by a row nobody can see.

If you want the title to become available again, the validation has to say so:

validates :title, uniqueness: { conditions: -> { where(deleted_at: nil) } }

The index underneath has the same argument, louder

A validation is a suggestion. The index is the thing that actually decides, and the shape you pick here is the whole design.

With the validation out of the way so the database answers for itself:

Index Reinsert the same title after a discard Two live rows with that title
UNIQUE (title) ActiveRecord::RecordNotUnique RecordNotUnique
UNIQUE (title) WHERE deleted_at IS NULL allowed RecordNotUnique

The partial index is the one you want, and it's four extra words:

add_index :posts, :title, unique: true, where: "deleted_at IS NULL"

Now the constraint says what you actually mean, which is that live titles are unique and dead ones aren't anybody's business. Postgres and SQLite both support this. MySQL doesn't, and on MySQL the usual workaround is a generated column or storing 0 instead of NULL for live rows, which is worth knowing before you pick the pattern rather than after.

A soft deleted row followed through three checks. Against a full unique index on title, reinserting the same title after the discard raises RecordNotUnique, so the name stays locked by an invisible row. Against a partial unique index restricted to rows where deleted_at is null, the same reinsert is allowed while two simultaneously live rows still raise. A third panel shows the uniqueness validator sitting above both of them and querying through klass.unscoped, so it blocks regardless of which index is underneath until its conditions option is set.

belongs_to goes quiet

Here's the one with no error message at all.

Comment.create!(post: post, body: "hi")
post.discard!

Comment.count          # => 1, the comment is still here and still live
Comment.first.post     # => nil

The association reads through the same default_scope, finds nothing, and hands back nil. It doesn't raise, it doesn't warn, and belongs_to's own presence validation only runs on save, so an existing comment is perfectly happy.

What breaks is every view that already worked. comment.post.title was fine yesterday and is a NoMethodError today, on a page nobody touched, triggered by an action taken somewhere else entirely. If you've ever seen undefined method 'title' for nil appear in your exception tracker with no deploy behind it, this is a good candidate.

And notice the first line of that snippet: the children weren't discarded. dependent: :destroy fires on destroy, and a soft delete is an update. You either discard the children yourself or you accept orphans, and the second one is a decision rather than an accident only if you make it on purpose.

The scope assigns, as well as filters

A smaller one, but it explains a class of bug that looks impossible.

Post.new.deleted_at                                  # => nil
Post.where(deleted_at: 2.days.ago).new.deleted_at    # => preset from the scope

Any where with an equality condition seeds those attributes on records built through it. Most of the time this is a convenience. Combined with a soft delete it means a record can be born deleted, and the place it tends to happen is inside an association or a scope chain somebody wrote months ago.

So should you use default_scope at all

If what you need is for one screen to hide archived rows, you don't. A named scope, scope :live, -> { where(deleted_at: nil) }, costs you none of the above. You type Post.live in the four places that want it, and the other thirty parts of your app keep behaving normally. The validator still sees everything, but it was going to anyway, and now that's consistent rather than surprising. The cost is that forgetting it shows archived rows, which is a visible bug you fix in one line, in the one place it appeared.

default_scope is the right call when hiding the row is a rule rather than a preference, when leaking one into a view would be a real incident, and when you're prepared to pay the four prices above. Reach for it second, not first.

What this page doesn't cover

No gem was installed. Discard and Paranoia both automate parts of this, and they both sit on the same mechanics, so the four behaviours above are what you're buying a wrapper around rather than an alternative to.

Nothing here touches restoring a row, which sounds trivial and isn't the moment a unique index rejects the undelete. Nor cascading a discard down an association tree, nor what any of this does to counter caches, which keep counting rows the scope has stopped returning and go wrong in a way no query will show you.

#rails #active-record #database

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.