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).countreturns a hash like{1 => 12, 2 => 5};sum,average,maximumwork 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_allandupsert_allwrite many rows in one statement (skipping validations and callbacks).Article.where(...).explainshows 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.explainRemember
- 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.