Rails default_scope: Why You Should Never Use It

`default_scope` quietly breaks `Model.all`, corrupts scope chaining, bleeds into associations, and can leak data in multi-tenant apps. Here's why it's dangerous and what to use instead.

Ally Piechowski · · Updated · 6 min read
Rails default_scope: Why You Should Never Use It

Update — March 2026: This post was significantly expanded to cover additional pitfalls: association bleeding, STI subclass inheritance, multi-tenant security risks with unscoped, and the Rails 6.1 all_queries: true option. It also now includes a recommendation for the discard gem as the correct soft-delete solution.

A pretty popular opinion in the Ruby on Rails community is that default_scopes should be avoided at all costs. Even among Rails experts, it’s often hard to articulate exactly why. Here’s a concrete breakdown.

Using default_scopes

Starting with a very basic Post model that contains a deleted_at column to support soft delete, like so:

class CreatePosts < ActiveRecord::Migration[8.1]
  def change
    create_table :posts do |t|
      t.string :title
      t.text :body
      t.datetime :deleted_at
      t.timestamps
    end
  end
end

The application is supposed to only display posts that have not been deleted, so a default_scope seems like the perfect solution!

class Post < ApplicationRecord
  default_scope -> { where(deleted_at: nil) }
end

This default_scope successfully hides all posts that are deleted, which is great!

Additionally, the posts should be ordered by the date they were created, which can also be added to the default_scope.

class Post < ApplicationRecord
  default_scope -> { where(deleted_at: nil).order(:created_at) }
end

Problem 1: Post.all Doesn’t Mean All

This one sounds minor until it isn’t. Post.all reads as “give me all posts.” With a default_scope, it actually means “give me all non-deleted posts, ordered by created_at.” The method name is a lie.

This bites hardest in tests (factory records that mysteriously don’t return), the console (wrong counts), and reporting (silently wrong aggregates).

Post.count        # returns 4
Post.unscoped.count  # returns 11

Seven posts just don’t exist as far as the application is concerned, and there’s nothing at the query site to tell you that.

Problem 2: Scopes That Should Work Don’t

Now that the application hides deleted posts by default, a feature request comes in for administrators to show deleted posts.

Adding a named scope seems like the obvious fix:

class Post < ApplicationRecord
  default_scope -> { where(deleted_at: nil) }
  scope :deleted, -> { where.not(deleted_at: nil) }
end

But calling Post.deleted returns no records, even when deleted posts exist in the database. The generated SQL shows why:

SELECT * FROM posts WHERE deleted_at IS NULL AND deleted_at IS NOT NULL

Both conditions are applied simultaneously. That can never match anything. The default_scope wins silently and Post.deleted is broken from day one.

The natural instinct is to reach for unscoped:

class Post < ApplicationRecord
  default_scope -> { where(deleted_at: nil) }
  scope :deleted, -> { unscoped.where.not(deleted_at: nil) }
end

This makes Post.deleted work, but breaks chaining. If you try to filter deleted posts by title:

Post.where(title: 'My Title').deleted

The unscoped inside deleted strips everything before it in the chain, including the where(title:) clause. You get all deleted posts regardless of title. The more conditions you add to the chain, the more surprising the results.

There is no clean way out of this. The scope is fighting itself.

Problem 3: Order Clauses Stack

The default_scope includes an order(:created_at). Later, a different part of the app wants posts sorted by updated_at:

Post.order(:updated_at)

This does not replace the existing order. It appends to it:

SELECT * FROM posts WHERE deleted_at IS NULL ORDER BY created_at ASC, updated_at ASC

The created_at order from the default_scope runs first, which makes the updated_at sort meaningless for any posts created on the same day. The results look almost right, which makes this particularly hard to debug.

The fix is reorder instead of order:

Post.reorder(:updated_at)

But this is a trap waiting for the next developer. Nothing in the call site suggests that order won’t work normally here.

Problem 4: Associations Inherit It Too

The default_scope applies not just to direct queries, but to any association that loads the model. So if Post has a default_scope, this:

user.posts

generates:

SELECT * FROM posts WHERE user_id = 1 AND deleted_at IS NULL

That might seem fine until a query returns fewer records than expected and nobody thinks to look at the model’s default_scope because the query was on an association, not a direct Post call. The scope is invisible at the call site. This is the most common source of subtle, hard-to-reproduce bugs I see in audits.

Problem 5: STI Subclasses Inherit It

default_scope is inherited by subclasses through single-table inheritance. If User has one, Admin < User gets it too, whether that’s intended or not:

class User < ApplicationRecord
  default_scope -> { where(active: true) }
end

class Admin < User
end

Admin.all
# SELECT * FROM users WHERE type = 'Admin' AND active = true
# Inactive admins are silently excluded

This is easy to miss during a code review because nothing about Admin suggests it filters by active.

Problem 6: unscoped Can Leak Data in Multi-Tenant Apps

unscoped strips every scope on the model, not just the one you’re trying to work around. In an app that uses default_scope for tenant isolation, this is a serious security risk:

class Post < ApplicationRecord
  default_scope -> { where(tenant_id: Current.tenant_id) }
end

# Later, someone tries to work around a soft-delete scope...
Post.unscoped.where(deleted_at: nil)
# Returns posts from every tenant in the database

Tenant isolation via default_scope is itself a bad pattern, but it exists in real codebases. If you ever call unscoped on a model you didn’t write, check what else the default_scope might be doing before you ship.

Rails 6.1+: It Can Get Worse

Rails 6.1 added an all_queries: true option to default_scope:

class Post < ApplicationRecord
  default_scope(all_queries: true) { where(deleted_at: nil) }
end

This extends the scope to write operations (updates and deletes), not just reads. Post.where(title: "Old").update_all(title: "New") will silently exclude deleted posts. Post.delete_all won’t delete them either. The filter is now invisible at every query site, reads and writes alike. Don’t use it.

Solution

In Rails, default_scopes are always painful to work with and result in unexpected bugs, so it’s best not to use Rails’ default_scopes.

Instead, communicative scopes can be used:

class Post < ApplicationRecord
  scope :active, -> { where(deleted_at: nil) }
  scope :deleted, -> { where.not(deleted_at: nil) }
end

For ordering, I use a named scope like for_index that makes intent explicit at the call site:

class Post < ApplicationRecord
  scope :active, -> { where(deleted_at: nil) }
  scope :deleted, -> { where.not(deleted_at: nil) }
  scope :for_index, -> { active.order(:created_at) }
end

Even though it seems like more work to include these scopes almost every time you access the records, it ends up being less work as the app ages. In Rails, default_scopes should be avoided at all costs. You’ll regret using default_scopes, maybe not today, but sometime in the future.

If You Need Soft Delete, Use the discard Gem

Most of the time I see default_scope in the wild, it’s there for soft delete. The right tool is discard. It gives you explicit, predictable soft delete without any of the scope inheritance problems:

class Post < ApplicationRecord
  include Discard::Model
end

Post.kept       # active records (replaces Post.all with a named scope)
Post.discarded  # soft-deleted records

post.discard    # soft-delete
post.undiscard  # restore

Post.all still returns everything. Post.kept is explicit at the call site. Associations don’t silently filter. unscoped doesn’t mean anything dangerous. The behavior is exactly what you’d expect.


Related Articles