Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 014 / 100

Scopes in Active Record: a practical Ruby on Rails guide

Reusable query fragments.

Expona Admin7 min read

Scopes in Active Record: a practical Ruby on Rails guide

Most Rails applications end up repeating the same database queries across controllers, service objects, and background jobs. You query for active users in one place, inactive users in another, and before long you're maintaining the same where(active: true) logic in five different files. Scopes solve this problem by letting you encapsulate query logic directly in your model, making it reusable and testable. In this guide, we'll walk through how to write effective scopes that keep your codebase clean and your team aligned on what "active" really means.

Understanding the scope syntax

A scope in Rails is a class method that returns an ActiveRecord::Relation, allowing you to chain it with other queries. The most common syntax uses a lambda:

ruby
class User < ApplicationRecord
  scope :active, -> { where(active: true) }
end

This creates a method you can call on the User class or any relation:

ruby
User.active
User.where(role: 'admin').active
User.active.order(created_at: :desc)

The lambda syntax is preferred over the older block syntax because it's lazy-evaluated: the query only runs when you actually need the data, not when the scope is defined. This matters when you're building complex query chains and want Rails to optimize the final SQL.

Scopes accept parameters too. This is useful when you want to keep related logic grouped but need to vary the condition:

ruby
scope :created_after, ->(date) { where('created_at > ?', date) }
scope :with_role, ->(role) { where(role: role) }

Then you call them like regular methods:

ruby
User.created_after(1.week.ago)
User.created_after(1.week.ago).with_role('moderator')

Chaining scopes together

One of the biggest wins with scopes is composability. Each scope returns a relation, which means you can keep adding more scopes or query methods. This lets you build complex queries from simple, readable pieces:

ruby
class User < ApplicationRecord
  scope :active, -> { where(active: true) }
  scope :verified, -> { where(email_verified: true) }
  scope :recent, -> { where('created_at > ?', 30.days.ago) }
  scope :with_orders, -> { joins(:orders).distinct }
end

Now you can compose these into more complex queries:

ruby
# Get recently joined, active users who have made purchases
User.recent.active.with_orders

# Get recently joined, active users who have made purchases and are verified
User.recent.active.with_orders.verified

Each scope is independent and focuses on one thing. That makes them easy to test, easy to reuse in different combinations, and easy for your teammates to understand what each piece does.

A practical example: imagine you're building a notification digest system. You might have:

ruby
class User < ApplicationRecord
  scope :digest_eligible, -> { active.verified.where(notifications_enabled: true) }
  scope :high_engagement, -> { where('last_login_at > ?', 7.days.ago) }
end

# In your background job:
User.digest_eligible.high_engagement.each do |user|
  DigestMailer.send_digest(user)
end

This reads like business logic, not SQL. Anyone reviewing your code knows exactly what "digest eligible" means because it's defined in one place.

Naming scopes by business meaning

The biggest mistake we see is naming scopes by their database mechanics instead of their business purpose. Avoid names like active_and_verified or where_status_pending. Instead, use names that reflect what the scope means in your domain.

Consider an e-commerce system with orders:

ruby
# Bad: names describe the database condition, not the business concept
scope :where_status_complete, -> { where(status: 'complete') }
scope :where_paid, -> { where(payment_status: 'paid') }
scope :where_shipped, -> { where(shipping_status: 'shipped') }

# Good: names describe what these orders represent in your business
scope :completed, -> { where(status: 'complete') }
scope :paid, -> { where(payment_status: 'paid') }
scope :fulfilled, -> { where(shipping_status: 'shipped') }

# Usage makes sense:
Order.completed.paid.fulfilled

This matters because your code becomes self-documenting. When a new team member reads Order.fulfilled, they immediately understand what that means. When they see Order.where_shipped, they have to guess whether it includes pending or just actually-shipped orders.

Here's another pattern we use frequently: scopes that represent specific user journeys or business states:

ruby
class Subscription < ApplicationRecord
  scope :active, -> { where(status: 'active') }
  scope :at_risk, -> { active.where('next_billing_date < ?', 7.days.from_now) }
  scope :expiring_soon, -> { where(status: 'active').where('expires_at < ?', 30.days.from_now) }
  scope :eligible_for_renewal, -> { where(status: 'cancelled').where('cancelled_at > ?', 60.days.ago) }
end

Each name tells a story about where that subscription is in its lifecycle. Your retention team can write Subscription.at_risk without needing to understand the exact logic—that's encapsulated in the model.

Keeping scopes simple and focused

A scope should do one thing well. If you find yourself writing complex conditional logic or multiple database calls within a single scope, that's a sign you should split it or move that logic elsewhere.

Here's what tends to happen as applications grow:

ruby
# Bad: too much logic, hard to test, hard to compose
scope :newsletter_candidates, -> {
  active.verified.where(notifications_enabled: true)
    .where.not(id: user_ids_who_unsubscribed)
    .where('last_email_sent_at IS NULL OR last_email_sent_at < ?', 1.week.ago)
    .joins(:subscription)
    .where(subscriptions: { status: 'active' })
}

# Good: break it into composable pieces
scope :active, -> { where(active: true) }
scope :verified, -> { where(email_verified: true) }
scope :newsletter_enabled, -> { where(notifications_enabled: true) }
scope :subscribed, -> { joins(:subscription).where(subscriptions: { status: 'active' }) }
scope :not_emailed_recently, -> { where('last_email_sent_at IS NULL OR last_email_sent_at < ?', 1.week.ago) }

# Exclude unsubscribed users in your query, not as a scope
newsletter_candidates = User.active.verified.newsletter_enabled.subscribed.not_emailed_recently
newsletter_candidates = newsletter_candidates.where.not(id: user_ids_who_unsubscribed)

The second approach is more flexible. You can use User.active.verified for one feature, User.active.subscribed for another, and so on. If the unsubscribed list changes, you update it in one place. If you need to test newsletter eligibility without the "not recently emailed" part, you can.

A good rule: if your scope starts with a conditional statement, it probably shouldn't be a scope. Move that logic into your controller or service instead:

ruby
# In your model: simple, focused scopes
scope :vip, -> { where(tier: 'vip') }
scope :active, -> { where(active: true) }

# In your service or controller: business logic that uses scopes
def send_vip_offer
  users = if feature_enabled?('vip_offers')
    User.vip.active
  else
    User.active
  end
  # ... send offers
end

Common mistakes

Using scopes with side effects. Scopes should be pure: they should always return the same relation given the same input. Avoid writing scopes that update data or trigger callbacks:

ruby
# Bad: this modifies data, don't do this
scope :mark_as_processed, -> { tap { |relation| relation.update_all(processed: true) } }

# Good: if you need to bulk update, do it explicitly
User.active.update_all(last_checked_at: Time.current)

Forgetting to return relations. Always make sure your scope returns an ActiveRecord::Relation, not a plain array or hash:

ruby
# Bad: returns an array, can't chain further
scope :sorted, -> { all.sort_by(&:created_at) }

# Good: returns a relation, fully chainable
scope :sorted, -> { order(created_at: :asc) }

Over-parameterizing scopes. If a scope takes many parameters, it's probably doing too much. Consider a class method instead:

ruby
# This is getting unwieldy
scope :between_dates, ->(start_date, end_date, field = :created_at, inclusive = true) {
  # ... complex logic
}

# Better as a class method
def self.between_dates(start_date, end_date, field: :created_at, inclusive: true)
  # ... logic here
end

Key takeaways

  • Scopes are lambda-based class methods that return relations, enabling query reuse and composition
  • Chain scopes together to build complex queries from simple, readable pieces
  • Name scopes by business meaning, not database mechanics—User.at_risk is clearer than User.where_churn_likely
  • Keep scopes focused and simple; if they're complex, split them or move logic to services
  • Scopes should be side-effect free and always return relations for full chainability
  • Test scopes individually and in combination to ensure they work as expected
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles