LaunchKit
← All posts
· 14 min read · by The LaunchKit team · 0 views

What a Rails scope actually returns

Article.published.draft returns zero rows and Article.published.merge(Article.draft) returns the drafts. Both lines read like they compose two scopes. One of them ANDs and one of them throws the first scope away, and the difference is not in the scopes, it is in which method you used to join them. Rails scopes are a thirty-six line macro, and the three silent wrong answers they produce are all about what the body gives back rather than what the caller passes in. Everything below was run against activerecord 8.1.3.1 on PostgreSQL 17.7, and the SQL is copied out of to_sql.

What the macro defines

scope runs from line 154 to line 189 of active_record/scoping/named.rb, and the half that matters starts at line 173:

if body.respond_to?(:to_proc)
  singleton_class.define_method(name) do |*args|
    scope = all._exec_scope(*args, &body)
    scope = scope.extending(extension) if extension
    scope
  end
else
  singleton_class.define_method(name) do |*args|
    scope = body.call(*args) || all
    # ...
  end
end
singleton_class.send(:ruby2_keywords, name)

generate_relation_method(name)

A class method on the model, and a matching instance method on that model's relation subclass so that Article.where(...).published does not have to go through method_missing. That is the whole feature. Introspection agrees:

defined as a singleton method: true
relation method for :published defined right away? true
relation method for :featured  defined right away? false
relation method for :featured  after one call?      true

featured there is a plain def self.featured = where(featured: true). The relation method for it does not exist until the first call, and then it does, because Relation#method_missing in relation/delegation.rb:127 calls model.generate_relation_method(method) before delegating. So the chainability difference between a scope and a class method, on activerecord 8.1.3.1, is one method definition, once per process. Not a behaviour difference at all.

Note the two branches in the snippet. The || all fallback everyone quotes lives in the branch for bodies that are not procs, which in practice means a callable object you wrote yourself. Your lambda goes down the other branch, and the other branch does something different.

The relation the body never returned

_exec_scope is at relation.rb:562:

def _exec_scope(...) # :nodoc:
  @delegate_to_model = true
  registry = model.scope_registry
  _scoping(nil, registry) { instance_exec(...) || self }
ensure
  @delegate_to_model = false
end

|| self, not || all. When a lambda body evaluates to nil, the scope returns the receiver, which is whatever relation the scope was called on. That distinction is invisible on Article.something, where the receiver is all anyway, and load bearing on a chain.

The shape that produces nil is the one everybody writes, a conditional filter:

scope :for_account, ->(id) { where(account_id: id) if id.present? }

Two rows in the table, one per account:

with an account: ["acme invoice"]
with nil:        ["acme invoice", "globex invoice"]
sql with nil:    SELECT "articles".* FROM "articles"

A nil tenant id does not return nothing. It returns everything, in silence, with a clean plan and a green log line. Article.where(account_id: nil) is the query that would have returned zero rows, and it is not the query that ran.

The saving grace is that the receiver survives:

Article.where(status: "draft").for_account(nil)
SELECT "articles".* FROM "articles" WHERE "articles"."status" = 'draft'

So a nil scope in the middle of a chain widens the result to whatever was already there rather than to the whole table. That is a smaller blast radius than the folklore claims, and it is still a filter you asked for that did not happen.

Why the class method fails louder

The identical body as a class method behaves the way a Ruby reader expects:

def self.for_account(id)
  where(account_id: id) if id.present?
end
Article.for_account(nil)                      => nil
Article.for_account(nil).count                => NoMethodError: undefined method 'count' for nil
Article.where(...).for_account(nil).order(:id) => NoMethodError: undefined method 'order' for nil

An exception on the next line of the chain, in development, with a backtrace pointing at the conditional. Against a silent superset in production, that is the better failure, and it is the one argument for a class method that survives contact with Rails 8. Everything else people cite is gone: both forms return Article::ActiveRecord_Relation, both chain, both work on an association.

The usual counter argument is that scope protects the chain, and it does, which is exactly the complaint. If you want the protection, write the guard where you can see it and let the scope always return a relation:

scope :for_account, ->(id) { id.present? ? where(account_id: id) : none }

none prints as WHERE (1=0) in to_sql and runs no query at all: Article.none.to_a and Article.none.count together produced zero sql.active_record events. For a tenant filter, empty is a defensible default and everything is not. Pick deliberately; the if with no else picks for you and picks wrong.

Two collisions scope does catch, and a class method does not:

ArgumentError: You tried to define a scope named "create" on the model "Article", but Active Record already defined a class method with the same name.
ArgumentError: The scope body needs to be callable.

def self.create overwrites create with no warning whatsoever. A model carrying def self.create(*) = "not a record" answered Doomed.create(title: "x") with the String and left zero rows in the table.

Chaining two conditions on one column

Straightforward, and worth stating before the surprise:

Article.published.draft
SELECT "articles".* FROM "articles" WHERE "articles"."status" = 'published' AND "articles"."status" = 'draft'
count: 0

Each where appends. Two equality predicates on one column AND to nothing, and Active Record builds the contradiction without comment because it has no idea the two came from different scopes.

This is the single most common confusion around Rails enums, whose generated scopes are ordinary where scopes: Article.published.draft on an enum column produced exactly the SQL above. The line people mean when they write it is Article.where(status: [:published, :draft]), or Article.published.or(Article.draft), which emits WHERE ("articles"."status" = 'published' OR "articles"."status" = 'draft'). or is fussier than it looks: it demands structural compatibility, and Article.published.or(Article.featured.limit(1)) raises ArgumentError: Relation passed to #or must be structurally compatible. Incompatible values: [:limit].

What merge does to the left side

The reason people merge scopes at all is that chaining them cannot express "use this one instead". merge is documented in relation/spawn_methods.rb:31 as "For conditions that exist in both relations, those from other will take precedence." Precedence is doing a lot of work in that sentence. The implementation is where_clause.rb:26:

def merge(other)
  predicates = except_predicates(other.extract_attributes)

  WhereClause.new(predicates | other.predicates)
end

other.extract_attributes is the list of columns the right-hand relation constrains. except_predicates then rejects every left-hand predicate on any of those columns. Not overridden, not re-ordered: deleted, before the union.

Article.published.merge(Article.draft)
SELECT "articles".* FROM "articles" WHERE "articles"."status" = 'draft'
count: 1

Article.draft.merge(Article.published)
SELECT "articles".* FROM "articles" WHERE "articles"."status" = 'published'

One condition in the output where two went in. where.not is subject to the same rule in both directions: Article.where.not(status: "draft").merge(Article.where(status: "draft")) emits status = 'draft', and the reverse emits status != 'draft'. Whichever side is on the right wins the column outright.

The date window that quietly became a year

Equality predicates on a status column are the tame case, because a contradiction is obvious once you look. Range predicates are the case that ships:

scope :recent,    -> { where(published_at: 7.days.ago..) }
scope :this_year, -> { where(published_at: Time.current.beginning_of_year..) }

Both are honest. Both constrain published_at. Chained, they intersect and the tighter one governs. Merged:

Article.recent.merge(Article.this_year)
SELECT "articles".* FROM "articles" WHERE "articles"."published_at" >= '2025-12-31 23:00:00'

Article.recent.this_year
SELECT "articles".* FROM "articles" WHERE "articles"."published_at" >= '2026-09-17 14:35:31.361715' AND "articles"."published_at" >= '2025-12-31 23:00:00'

The merge produced one bound and it is the loose one. Run on 24 September 2026, a 7 day window became a 267 day window. A "recent activity" panel that a helper built by merging in a year filter renders all 267 days of rows, in the right order, with the right columns, and looks entirely plausible on a young table. It goes wrong slowly, as the table fills up, long after the commit that caused it. No log line marks the moment the first bound disappeared.

The rule to carry: merge replaces per column, and a range is a column constraint like any other.

Where the replacement does not reach

extract_attributes walks the predicate list looking for Arel attributes. A predicate that is a String is not one, so it is invisible to the deletion:

Article.popular.merge(Article.unpopular)
SELECT "articles".* FROM "articles" WHERE (views > 100) AND (views < 10)
count: 0

Same two scopes, same column, same merge, opposite outcome, and the only difference is that the bodies were written as where("views > 100") instead of where(views: 101..). A refactor from a String condition to a Hash condition is normally a pure cleanup. Under a merge it changes the result set.

order is not replaced either, it appends:

Article.newest.merge(Article.oldest)
SELECT "articles".* FROM "articles" ORDER BY "articles"."published_at" DESC, "articles"."published_at" ASC

Byte for byte what plain chaining produces. So one method has three behaviours depending on what the right-hand relation happens to contain, which is the real reason merge is hard to hold in your head.

The case that is safe is the one merge exists for. Attributes carry their table, so two models with a status column do not collide:

Article.published.joins(:author).merge(Author.active)
SELECT "articles".* FROM "articles" INNER JOIN "authors" ON "authors"."id" = "articles"."author_id"
WHERE "articles"."status" = 'published' AND "authors"."status" = 'active'

Reusing Author.active instead of restating authors.status = 'active' in the article query is worth having, and it is the one use of the method that comes with no asterisk. Forget the joins and Active Record builds the condition anyway: PG::UndefinedTable: ERROR: missing FROM-clause entry for table "authors".

default_scope writes your new records

default_scope applies to reads, and it also applies to new. With default_scope { where(status: "published") }:

status on a fresh record: "published"

The column default in the schema is 'draft'. The scope beat it, because scope_attributes turns the where clause into attribute assignments on instantiation. scoping/default.rb:81 says so in as many words, "The #default_scope is also applied while creating/building a record", and it is the same mechanism that makes author.published_articles.new.status come back "published" for a scoped has_many, and on an association it is genuinely useful. Sitting on the whole model it means the only way to create a draft is Article.unscoped.new or an explicit status:, forever, in every factory, seed and console session.

The scope that cannot fire

Reads are worse than writes here, because the default scope and your scope are both just where clauses and they both apply:

Article.draft
SELECT "articles".* FROM "articles" WHERE "articles"."status" = 'published' AND "articles"."status" = 'draft'
count: 0

The scope is unreachable. Not overridden, not shadowed: ANDed into a contradiction, the same way two chained scopes on one column were earlier in this post, and this time one of the two is invisible in the calling code. Article.where(status: "draft") does the same thing. Getting drafts requires knowing that a default scope exists and reaching for Article.unscoped.where(...), Article.rewhere(status: "draft") or Article.unscope(:where).where(status: "draft"), all three of which produce the bare query.

unscope has a trap of its own. Article.where(status: "draft").unscope(where: :status) removes predicates by column, and both predicates are on status, so it removes yours too: SELECT "articles".* FROM "articles", every row.

Lookups by id are subject to the scope as well:

Article.find(draft_id)
ActiveRecord::RecordNotFound: Couldn't find Article with 'id'=1 [WHERE "articles"."status" = $1]

Article.exists?(draft_id)       => false
Article.find_by(id: draft_id)   => nil
Article.unscoped.find(draft_id) => the row, which was there the whole time

Writes are not, by default. An UPDATE on a loaded record emits WHERE "articles"."id" = $3 and nothing else, which is why a soft-delete default_scope protects your index pages and not your update_all. default_scope -> { ... }, all_queries: true extends it:

UPDATE "articles" SET "title" = $1, "updated_at" = $2 WHERE "articles"."id" = $3 AND "articles"."locale" = $4
DELETE FROM "articles" WHERE "articles"."id" = $1 AND "articles"."locale" = $2

An ORDER BY that inverts itself

The worst one, because it corrupts output rather than filtering it. Three rows, created on 1, 2 and 3 January, a default_scope { order(created_at: :asc) }, and a scope :newest_first, -> { order(created_at: :desc) }:

newest_first titles: ["post 0", "post 1", "post 2"]
sql:                 SELECT "articles".* FROM "articles" ORDER BY "articles"."created_at" ASC, "articles"."created_at" DESC
what you wanted:     ["post 2", "post 1", "post 0"]

Oldest first, from a scope named newest_first. order appends, so the default scope's key is the primary sort key and yours is the tiebreaker, and a tiebreaker on the same column can never break a tie. PostgreSQL is not being clever; the raw statement behaves identically:

SELECT title FROM articles ORDER BY created_at ASC, created_at DESC
["post 0", "post 1", "post 2"]

reorder(created_at: :desc) is the fix and emits the single key. It has to be reorder, and a reviewer reading Article.newest_first in a controller has no way to see that it matters.

unscoped inside a scope body

A scope whose body calls unscoped is the documented escape from a default scope, and it removes more than the default scope:

scope :deleted, -> { unscoped.where(status: "deleted") }
Article.where(title: "keep me").deleted
SELECT "articles".* FROM "articles" WHERE "articles"."status" = 'deleted'

The title filter is gone. unscoped returns relation, a fresh relation off the model, so the body discards the receiver entirely rather than subtracting the default scope from it. Same body without unscoped keeps everything, default scope included. Any conditional filter applied before a scope like this one, including a tenant filter, evaporates and the caller cannot tell from the call site.

The rules this site follows

Scopes are worth having. Lead.subscribed on this site is where.not(confirmed_at: nil).where(unsubscribed_at: nil, blocked_at: nil), a three-clause rule about who may be emailed, written once, and scope :sequence_pending, -> { subscribed.where(sequence_step: ...SEQUENCE_LENGTH) } builds on it, which works because the body is instance_execed on a relation that already answers to every scope on the model. That composition is the good case and it is most cases.

Four rules, and each one is there because of a section above:

  1. A scope body always returns a relation. none for the empty case, never a bare if.
  2. No default_scope. Name the scope and call it. The cost is one visible method call per query, and it removes the inert-scope, inverted-order and mutated-new failures in one go.
  3. merge only across models, behind a joins. Same-model composition is chaining.
  4. unscoped never inside a scope body.

What would change rule 3: a merge that raised on a same-model column collision instead of silently preferring the right-hand side, or even logged one. The behaviour is deliberate and has to stay for compatibility, but nothing forces it to stay quiet. Until it speaks, the only way to see a dropped predicate is to print to_sql, because the Ruby reads correctly in every case above.

Rule 2 has a real cost and it is worth naming. Soft delete is the case where default_scope is correct: the alternative is where(deleted_at: nil) on every query, and one missed call is a bug you find in a screenshot from a user. If you take default_scope for soft delete, take it for nothing else, keep it to a single nullable timestamp column, and never put an order in it.

What this post does not cover

Neither this site nor the LaunchKit boilerplate declares a default_scope. Neither calls Active Record's merge. The product's models use plain scopes chained off associations, subscriptions.live and Current.user.notifications.unread, and the default_scope reproductions above are throwaway classes in a scratch database, not code either repository runs. That is the honest provenance, and it is also the recommendation.

Not covered: scope with an extension block, which attaches a module to the returned relation and appears in neither repository; current_scope and the thread-local registry behind scoping, which is how Model.scoping { } leaks into unrelated queries in the same block and deserves its own page; single table inheritance, where the subclass type condition is itself a default scope and interacts with the ones you declare; and Arel-level predicates, since everything above is about the clause list Active Record assembles before any adapter sees it.

#rails #active-record

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.