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:
class User < ApplicationRecord
scope :active, -> { where(active: true) }
endThis creates a method you can call on the User class or any relation:
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:
scope :created_after, ->(date) { where('created_at > ?', date) }
scope :with_role, ->(role) { where(role: role) }Then you call them like regular methods:
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:
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 }
endNow you can compose these into more complex queries:
# 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.verifiedEach 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:
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)
endThis 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:
# 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.fulfilledThis 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:
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) }
endEach 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:
# 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:
# 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
endCommon 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:
# 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:
# 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:
# 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
endKey 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_riskis clearer thanUser.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






Comments
Loading comments…