Scopes and advanced queries

Ruby on Rails · Practical · 14 min

Named scopes turn scattered query fragments into a readable, composable vocabulary for your domain.

A scope names a query fragment on the model:

scope :published, -> { where(published: true) }
scope :recent, ->(days = 7) { where(created_at: days.days.ago..) }

Scopes always return a relation, so they chain: Article.published.recent(30).order(:title). They also work through associations: user.articles.published. A class method that returns a relation works the same way; scopes are just shorter, and a scope whose body returns nil returns all instead, keeping chains safe.

Avoid default_scope: it silently applies everywhere, including to associations and new records, and is hard to escape.

More query tools:

  • merge(OtherModel.scope) reuses another model's scope inside a join: Article.joins(:author).merge(User.active).
  • group(:category_id).count returns a hash like {1 => 12, 2 => 5}; sum, average, maximum work the same way.
  • left_joins(:comments).where(comments: { id: nil }) finds records without children; where.missing(:comments) is shorthand.
  • or, not, rewhere, unscope, extending.
  • in_batches(of: 1000).update_all(...) changes large sets in chunks; insert_all and upsert_all write many rows in one statement (skipping validations and callbacks).
  • Article.where(...).explain shows the database's query plan.

For SQL that is easier written raw, find_by_sql and select("...") remain available, still with bound parameters.

Example

class Article < ApplicationRecord
  belongs_to :author, class_name: "User"
  has_many :comments

  scope :published, -> { where(published: true) }
  scope :recent, ->(days = 7) { where(created_at: days.days.ago..) }
  scope :by, ->(user) { where(author: user) if user }   # nil -> all
  scope :popular, -> { joins(:comments).group(:id).having("COUNT(comments.id) >= ?", 10) }
end

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

Article.published.recent(30).by(nil).order(:title)   # by(nil) keeps the chain
Article.joins(:author).merge(User.active)             # reuse User's scope
Article.published.group(:category_id).count           # {1 => 12, 2 => 5}
Article.where.missing(:comments)                      # articles with no comments
Article.where(published: false).in_batches(of: 1000)
       .update_all(archived: true)                    # chunked set-based update
Article.upsert_all([{ slug: "a", title: "A" }], unique_by: :slug)
puts Article.published.recent.explain

Remember

  • Scopes name reusable query fragments and always return relations
  • merge reuses another model's scopes across joins
  • group/count, where.missing, in_batches and upsert_all cover common bulk needs

Watch out for

  • Using default_scope, which leaks into every query and association.

Check yourself

  1. What does a scope return if its lambda body evaluates to nil?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: guides.rubyonrails.org/active_record_querying.html#scopes