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:
- A scope body always returns a relation.
nonefor the empty case, never a bareif. - 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-newfailures in one go. mergeonly across models, behind ajoins. Same-model composition is chaining.unscopednever 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.
Comments
No comments yet. Be the first.