How to combine scopes in Rails in a DRY and efficient way?
In my day-to-day work with Rails, I use scopes in almost every model. They’re the perfect tool to encapsulate filtering logic, keep code clean, and build complex queries in a readable way.
One of the most common cases:
"I want the published posts from active authors living in America, except if they write in Portuguese or English."
Nothing fancy, right?
But here’s where many of us (myself included) hit a common question:
Should I chain scopes directly? Or use
.merge
? Does it matter?
Spoiler: It does matter.
The setup: authors and posts
In this example, we have two classic models: Author
and Post
.
# app/models/author.rb class Author < ApplicationRecord has_many :posts scope :from_america, -> { where(continent: 'America') } scope :excluding_languages, -> (langs) { where.not(language: langs) } scope :active, -> { where(active: true) } # Chained and reusable scope scope :american_active_no_pt_en, -> { from_america.active.excluding_languages(%w[pt en]) } end # app/models/post.rb class Post < ApplicationRecord belongs_to :author scope :published, -> { where(published: true) } end
The requirement
Our client (or product team, or pragmatic mind) wants this:
"Give me all published posts by active authors who live in America and do not write in Portuguese or English."
And as good developers, we want this to be:
- DRY
- Performant
- Reusable
So, we try different approaches.
First solution: DRY, but with a subquery (less performance)
scope :by_active_american_authors, -> { where(author_id: Author.american_active_no_pt_en.select(:id)) }
Typical usage:
`# app/models/post.rb Post.published.by_active_american_authors`
Generated query:
SELECT "posts".* FROM "posts" WHERE "posts"."published" = TRUE AND "posts"."author_id" IN ( SELECT "authors"."id" FROM "authors" WHERE "authors"."continent" = 'America' AND "authors"."active" = TRUE AND "authors"."language" NOT IN ('pt', 'en'));`
This approach is super DRY.
We reuse Author.american_active_no_pt_en
without duplicating logic.
But: it uses a subquery in the WHERE IN
, which can become expensive if there are many authors or if that scope includes additional joins.
Second solution: efficient, but repetitive
scope :by_active_american_authors, -> { joins(:author) .where( authors: { continent: 'America', active: true }) .where.not( authors: { language: %w[pt en] } ) }
Typical usage:
Post.published.by_active_american_authors
Generated query:
SELECT "posts".* FROM "posts" INNER JOIN "authors" ON "authors"."id" = "posts"."author_id" WHERE "authors"."continent" = 'America' AND "authors"."active" = TRUE AND "authors"."language" NOT IN ('pt', 'en') AND "posts"."published" = TRUE;`
Here the query is very efficient: the JOIN
lets the DB use indexes.
But we’re duplicating the logic already defined in Author.american_active_no_pt_en
.
Every time that logic changes, we have to remember to update it in multiple places.
And we all know how those stories end.
Third solution: DRY and efficient with .merge
scope :by_active_american_authors, -> { joins(:author).merge(Author.american_active_no_pt_en) }`
Typical usage:
Post.published.by_active_american_authors
Generated query (same as above):
SELECT "posts".* FROM "posts" INNER JOIN "authors" ON "authors"."id" = "posts"."author_id" WHERE "authors"."continent" = 'America' AND "authors"."active" = TRUE AND "authors"."language" NOT IN ('pt', 'en') AND "posts"."published" = TRUE;
With .merge
, we get the best of both worlds:
- ✅ We reuse the scope from the
Author
model, keeping the code clean. - ✅ We generate an optimal query, with no subqueries.
So… what does .merge
actually do?
We’ve seen .merge
gives us both efficiency and reuse.
But if you’re like me, you want to know how it works and when it can fail.
Does Rails run the scope first and then mix the results?
No. .merge
doesn’t run anything separately.
Rails fuses(merge) the SQL conditions of the second scope into the first one.
It’s not that Author.american_active_no_pt_en
gets executed and its results passed over.
Here’s what’s really happening:
`Post.joins(:author).merge(Author.active)`
Rails interprets this as:
“Okay, you're doing a
join
withauthors
, and you want to apply conditions from this other scope inAuthor
. Let me mix that into one singleActiveRecord::Relation
.”
The result is a single SQL query.
What exactly gets copied?
Rails takes from the second scope things like:
-
WHERE
-
JOIN
-
ORDER
-
LIMIT
,OFFSET
,GROUP BY
(with some caveats)
And it adds them into the ActiveRecord::Relation
you’re building with Post
.
What if I try .merge
without a join
?
Example:
Post.merge(Author.active) # ❌`
This will raise an error.
Rails doesn’t know how to apply authors
conditions in a query that only involves posts
.
The right way is:
Post.joins(:author).merge(Author.active)`
Can .merge
break things?
Yes. Here are real situations where .merge
can cause trouble:
1. When the merged scope uses select
# This overrides the base model's select Author.select(:id, :name).merge(Author.active)`
2. When using aliases or multiple joins
Post.joins(:author, :editor).merge(Author.active) # ⚠️ Rails may get confused`
3. When the scope uses group
, limit
, or offset
It won’t always break, but can produce unexpected SQL or DB errors.
Best practices for .merge
- Use
.merge
only when the relation is already loaded (joins
,includes
, etc.). - Avoid
select
,group
,limit
, oroffset
in scopes you plan to merge. - Don’t merge scopes that don’t apply to the current base model.
- Name your scopes well so they’re expressive (
.visible_to_user
,.eligible_for_export
, etc.).
So… how should you think about .merge
?
This is how I see it:
“Take this logic that’s already defined in another model and blend it into my current query… without breaking anything.”
And when that’s what you need, .merge
is just 🔥.
Top comments (0)