DEV Community

Cover image for Writing Reusable and Performant Scopes in Rails with .merge
Michel Sánchez Montells
Michel Sánchez Montells

Posted on

Writing Reusable and Performant Scopes in Rails with .merge

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 
Enter fullscreen mode Exit fullscreen mode

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)) } 
Enter fullscreen mode Exit fullscreen mode

Typical usage:

`# app/models/post.rb Post.published.by_active_american_authors` 
Enter fullscreen mode Exit fullscreen mode

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'));` 
Enter fullscreen mode Exit fullscreen mode

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] } ) } 
Enter fullscreen mode Exit fullscreen mode

Typical usage:

Post.published.by_active_american_authors 
Enter fullscreen mode Exit fullscreen mode

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;` 
Enter fullscreen mode Exit fullscreen mode

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) }` 
Enter fullscreen mode Exit fullscreen mode

Typical usage:

Post.published.by_active_american_authors 
Enter fullscreen mode Exit fullscreen mode

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; 
Enter fullscreen mode Exit fullscreen mode

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)` 
Enter fullscreen mode Exit fullscreen mode

Rails interprets this as:

“Okay, you're doing a join with authors, and you want to apply conditions from this other scope in Author. Let me mix that into one single ActiveRecord::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) # ❌`  
Enter fullscreen mode Exit fullscreen mode

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)` 
Enter fullscreen mode Exit fullscreen mode

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)` 
Enter fullscreen mode Exit fullscreen mode

2. When using aliases or multiple joins

Post.joins(:author, :editor).merge(Author.active) # ⚠️ Rails may get confused`  
Enter fullscreen mode Exit fullscreen mode

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, or offset 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)