Using ActiveRecord Scopes Without Making a Mess
by Eric Hanson, Backend Developer at Clean Systems Consulting
What scopes are actually for
A scope is a named, reusable query fragment that returns a relation. The key word is fragment — scopes are designed to compose. Order.pending.recent.for_user(user) chains three scopes into a single query. That composition is the feature.
The definition is deliberately narrow. A scope is appropriate when it represents a filtering or ordering concern that belongs on the model — something reusable across multiple call sites, composable with other scopes, and expressible as a single where, order, joins, or select clause. It's not appropriate for complex business queries with multiple joins, subqueries, conditional logic, or domain-specific assembly. Those belong in query objects.
The basics, done correctly
class Order < ApplicationRecord
# Simple condition
scope :pending, -> { where(status: :pending) }
scope :shipped, -> { where(status: :shipped) }
# With argument
scope :for_user, ->(user) { where(user: user) }
# Ordering
scope :recent, -> { order(created_at: :desc) }
scope :oldest, -> { order(created_at: :asc) }
# Date range
scope :created_after, ->(date) { where("created_at > ?", date) }
scope :created_before, ->(date) { where("created_at < ?", date) }
# Combining scopes within a scope
scope :overdue, -> { pending.created_before(7.days.ago) }
end
The lambda (->) is non-optional. Without it, the scope is evaluated once at class load time — the Time.current or Date.today in a date scope freezes to the value at boot and never updates. Always use lambdas.
The composing scope (overdue) references other scopes by name. This is the right way to build derived conditions — you get the index behavior and SQL correctness of each component scope, and the composed scope is readable.
The nil return trap
scope has one behavior that catches developers off guard: if the block returns nil or false, Rails returns the entire unscoped relation instead of an empty relation. This is by design — it allows conditional scopes — but it produces surprising results when the condition logic is wrong:
scope :for_role, ->(role) { where(role: role) if role.present? }
If role is blank, this returns all rather than an empty relation. For many use cases this is exactly right — a blank role filter means "no filter, return everything." But if you expect a blank role to return no records, this scope silently returns everything.
The alternative when you want an empty relation on nil:
scope :for_role, ->(role) {
role.present? ? where(role: role) : none
}
none returns an empty relation that still composes correctly with other scopes — it evaluates to WHERE 1=0 in SQL.
Know which behavior your scope needs and implement it explicitly. The implicit "nil scope returns all" behavior is documentation debt — a developer reading the call site has no idea whether a blank argument returns everything or nothing without reading the scope definition.
Default scopes — apply sparingly and carefully
default_scope applies a condition to every query on the model unless explicitly removed with unscoped. The canonical use case is soft deletion:
default_scope { where(deleted_at: nil) }
Every query automatically filters deleted records. Associations respect the scope. Works as expected until it doesn't:
# Trying to find a deleted record
User.find(id) # raises ActiveRecord::RecordNotFound — deleted record excluded
User.unscoped.find(id) # works — bypasses default scope
# Default scope affects associations
user.orders # automatically excludes orders with deleted_at set
user.orders.unscoped # removes the scope — but also removes the user_id condition
The last line is the problem. unscoped removes all scopes including the WHERE user_id = ? added by the association. user.orders.unscoped returns all orders from all users. This is a data leakage bug that shows up whenever someone reaches for unscoped to access soft-deleted records through an association.
The practical recommendation: use a gem like paranoia or discard for soft deletion rather than rolling default_scope yourself. These gems handle the unscoped association problem correctly.
For any other use case, treat default_scope as a last resort. The side effects — on associations, on count, on joins — produce bugs that are difficult to trace because the scope is invisible at the call site.
Scope composition and SQL generation
Chained scopes generate a single SQL query — ActiveRecord builds the relation lazily and executes once. Verifying the generated SQL is part of scope development:
Order.pending.for_user(user).recent.to_sql
# => "SELECT \"orders\".* FROM \"orders\" WHERE \"orders\".\"status\" = 'pending'
# AND \"orders\".\"user_id\" = 42 ORDER BY \"orders\".\"created_at\" DESC"
to_sql without executing is the fastest way to verify the query is what you expect. Check it in a console before writing a test.
One composition issue worth knowing: when two scopes apply conflicting order clauses, the last one wins:
scope :recent, -> { order(created_at: :desc) }
scope :by_total, -> { order(total: :desc) }
Order.recent.by_total.to_sql
# ORDER BY total DESC — created_at ordering is replaced, not appended
If you need multiple order criteria, compose them explicitly:
scope :recent_by_total, -> { order(created_at: :desc, total: :desc) }
Or use reorder within a scope to explicitly replace any prior ordering, which makes the behavior visible rather than implicit.
Scopes vs class methods
Scopes and class methods that return relations are interchangeable at the call site. The behavior difference is the nil-return behavior described above: a scope that returns nil returns all; a class method that returns nil raises NoMethodError on chained calls.
Use scopes for simple, composable filters. Use class methods when the logic is complex enough to warrant conditional branches, multiple returns, or arguments with validation:
# Scope — simple and composable
scope :active, -> { where(active: true) }
# Class method — argument handling warrants method form
def self.search(query)
return none if query.blank?
where("name ILIKE ?", "%#{sanitize_sql_like(query)}%")
end
sanitize_sql_like escapes % and _ characters that have special meaning in SQL LIKE patterns. This is the detail that distinguishes a correct search implementation from a vulnerable one — user-supplied query values containing % would otherwise match everything.
When a scope has outgrown the model
Three signals that a scope should become a query object:
Multiple joins with conditions. A scope that joins three tables and filters on columns from each is building a complex query that's hard to read, test, and modify in a ->{ } block. The query object gives it a name and a class to test against.
Arguments with significant validation or transformation. If the scope argument needs to be parsed, validated, or transformed before the query can be built, that logic doesn't belong in a lambda.
Usage from only one call site. A scope that exists to serve one specific feature and is never reused is a query in the wrong place. Extract it to the code that needs it, or to a query object that makes the specificity explicit.
# This scope has outgrown the model
scope :billable_this_period, ->(start_date, end_date, account_type, minimum_amount) {
joins(:user, :line_items)
.where(users: { account_type: account_type })
.where(status: :completed)
.where("orders.completed_at BETWEEN ? AND ?", start_date, end_date)
.group("orders.id")
.having("SUM(line_items.amount) >= ?", minimum_amount)
}
This belongs in BillableOrdersQuery, where it can be tested in isolation, documented, and modified without touching the model.
The scope test worth writing
Scopes deserve direct unit tests, not just integration test coverage through controller or service specs. The test has two parts: verify the SQL, verify the result set.
RSpec.describe Order, ".pending" do
it "generates the correct SQL" do
expect(Order.pending.to_sql).to include("status = 'pending'")
end
it "returns only pending orders" do
pending_order = create(:order, status: :pending)
shipped_order = create(:order, status: :shipped)
expect(Order.pending).to include(pending_order)
expect(Order.pending).not_to include(shipped_order)
end
end
The SQL test catches regressions where a scope is accidentally changed to return a different query. The result set test verifies the scope works end-to-end with the database. Both are fast and both have clear failure modes.
For composing scopes, test the composition:
it "composes with for_user to restrict results" do
user = create(:user)
own_order = create(:order, user: user, status: :pending)
other_order = create(:order, status: :pending)
expect(Order.pending.for_user(user)).to contain_exactly(own_order)
end
A scope that doesn't compose correctly isn't a scope — it's a query pretending to be one.