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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

The Bay Area Has 10,000 Backend Job Postings and a 12-Week Hire Cycle — Async Contractors Skip the Line

Ten thousand backend roles open across the Bay Area. Your listing is one of them. So is Google's. Guess which one gets seen first.

Read more

Why Every Engineering Team Needs a Tech Lead

At first, skipping a tech lead feels like saving money. Then decisions pile up, and nobody knows who should make them.

Read more

When Even Senior Developers Can’t Replace a Tech Lead

“We don’t need a tech lead—we have senior developers.” It sounds reasonable… until decisions start going nowhere.

Read more

When Git Is Prohibited: Why Use Modern Tools When You Can Hand Over Code Like It’s 1999?

Remember the days before Git, CI/CD, and proper version control? Some managers seem determined to bring us back—one Word document at a time.

Read more