Why is Ruby on Rails' default_scope bad?

A pretty popular opinion in the Ruby on Rails community is that default_scopes should be avoided at all costs. Even though this is a widely expressed opinion, often at the time it’s hard for even Rails experts to express exactly how default_scopes are bad.

The reason is two-fold:

  1. When you request Model.all, it is right to assume you will be retrieving all records
  2. default_scope does not always cooperate with other clauses

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[6.0]
  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

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

This is super easy with a scope, like so:

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

The issue occurs when this scope is used by calling Post.deleted, no results are returned, even when there are indeed deleted records.

This is caused by conflicting where clauses, where the default_scope requests for all records where posts.deleted_at IS NULL and the scope requests all records where posts.deleted_at IS NOT NULL, which will always be exactly 0 records.

Adding unscoped to the deleted scope kind of works:

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

However, this now makes it impossible to build a where clause like so:

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

This is caused by deleted effectively unscopeing all of the where clauses prior, which removes the where clause, displaying all deleted records.

Problem 2

If we want to order the posts by updated_at, we would typically use the following code:

Post.order(:updated_at)

With the default_scope applied, this results in being ordered by created_at first, then updated_at, deeming this order useless…

Instead, it must be remembered to reorder, which is not intuitive when debugging:

Post.reorder(:updated_at)

Problem 3

Finally, Post.all no longer returns all records, which is misleading.

Solution

In Rails, default_scopes are always painful to work with and result in unexpected bugs, so it’s best to not 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

As far as the order clause, I typically just type that one out. It’s short, sweet, and communicates the order within the line of code you’re using. Often I combine these scopes to make a scope like for_index in which specifies what should be shown, and in what order, like so:

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 some time in the future.


Related Articles