Skip to main content

N+1 Isn’t a Rails Problem: It’s a Query-Shaping Problem

Why copying SQL or rewriting in ActiveRecord can still explode into N+1—and how to think in sets

Copied SQL into Rails and still got N+1? Rewrote in ActiveRecord and still got N+1? The fix is a set-based mental model: where N+1 really comes from, how to verify it, and the AR patterns that eliminate it safely.

A
Raza Hussain
· Updated: · 5 min read · 119
N+1 Isn’t a Rails Problem: It’s a Query-Shaping Problem

Start from a real production bug

You paste a SQL query into Rails.

It returns the right rows.

Then production falls over because a page that should run one query runs hundreds.

So you rewrite it “the Rails way” in ActiveRecord.

It still runs hundreds.

At that point it’s tempting to conclude:

  • “Rails is slow”
  • “ActiveRecord hides the truth”
  • “SQL is the only way”

But the bug isn’t Rails.

The bug is your mental model: you shaped the query correctly once, then reintroduced N+1 through how you accessed the data.


The wrong mental model

“If my initial query is correct, the page can’t be N+1.”

N+1 isn’t always caused by the initial query.

It’s caused by:

  • iterating over records
  • touching associations
  • calling methods that trigger queries
  • rendering partials that query per row

You can write a perfect SQL query and still trigger N+1 later through follow-up reads.


Why copying SQL into Rails still produces N+1

Let’s say your SQL correctly fetches orders:

SELECT o.*
FROM orders o
WHERE o.status = 'open'
ORDER BY o.created_at DESC
LIMIT 100;

In Rails:

orders = Order.find_by_sql(sql)
orders.each { |o| o.user.email }

Even though the initial query was set-based, this line:

o.user

is an association lookup.

If you didn’t preload users, Rails does a new query per order.

That’s N+1.


Why rewriting as ActiveRecord still produces N+1

You rewrite “idiomatically”:

orders = Order.where(status: "open").order(created_at: :desc).limit(100)
orders.each { |o| o.user.email }

This looks fine, but it’s the same access pattern. Rails is doing exactly what you asked:

  • load orders
  • then lazily load each user when accessed

ActiveRecord didn’t create the bug. It exposed the consequence of lazy-loading.


The correct mental model: think in sets, verify with logs

You want one query per set, not per row.

Your job is to shape both:

  1. the base relation
  2. the data dependencies you’ll touch during rendering

Step 1: confirm it’s N+1

In development, look at Rails logs.

If you see:

  • the same SELECT repeated with different ids
  • one query per row in a list

…you have N+1.

Example smell:

User Load (0.6ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2
User Load (0.5ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2
User Load (0.6ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2

Fix #1: includes when you will render associations

orders = Order
  .where(status: "open")
  .includes(:user)
  .order(created_at: :desc)
  .limit(100)

orders.each { |o| o.user.email }

What happens:

  • one query for orders
  • one query for users with an IN (...)

Typical generated SQL:

SELECT "orders".*
FROM "orders"
WHERE "orders"."status" = 'open'
ORDER BY "orders"."created_at" DESC
LIMIT 100;

SELECT "users".*
FROM "users"
WHERE "users"."id" IN ( ... );

Fix #2: preload when you must avoid JOIN behavior

includes can flip into JOIN mode if you add conditions on the included table.

Sometimes you want to avoid that and force two queries:

orders = Order
  .where(status: "open")
  .preload(:user)
  .order(created_at: :desc)
  .limit(100)

Use this when:

  • JOINs would duplicate rows
  • you don’t want DISTINCT hacks
  • you want predictable query shapes

Fix #3: joins is not eager loading

A very common mistake:

orders = Order.joins(:user).where(status: "open").limit(100)
orders.each { |o| o.user.email } # still N+1 in many cases

joins joins for filtering and SQL shape. It does not populate association caches by default.

So you can still get N+1 even with a JOIN.

Safer combined approach (filter + eager load)

orders = Order
  .joins(:user)
  .where(status: "open", users: { active: true })
  .includes(:user)
  .limit(100)

But be careful: includes may convert to a JOIN and change row cardinality.


Fix #4: push computation down to SQL when you only need scalars

If you only need one value per row, don’t load full associated objects.

Example: you just need user emails:

rows = Order
  .where(status: "open")
  .joins(:user)
  .order(created_at: :desc)
  .limit(100)
  .pluck("orders.id", "users.email")

This stays set-based and avoids unnecessary object allocation.


Production pitfalls & edge cases

1) includes + where(users: ...) can change SQL shape

Rails may choose a LEFT OUTER JOIN to satisfy conditions. This can:

  • duplicate rows
  • require DISTINCT
  • slow queries significantly

Always check generated SQL on complex relations.

2) Rendering partials reintroduces N+1

A list might be eager loaded, then a partial calls another association per row. Treat views as query code.

3) N+1 can come from methods, not associations

A model method that does OtherModel.where(...) inside a loop is still N+1.

4) Eager loading too much can be slower

Fetching giant graphs (includes(user: [:profile, :settings, ...])) can increase memory and degrade performance. Eager load only what you render.


Rule of thumb

N+1 is the cost of row-by-row thinking. Fix it with set-based thinking.

Shape your data access around the page:

  • decide what associations/scalars you will touch
  • preload or pluck accordingly
  • verify with logs (and in production, with slow query sampling)

That’s why “SQL vs ActiveRecord” didn’t fix it.

Your thinking did.

Was this article helpful?

Your feedback helps us improve our content

Be the first to vote!

How We Verify Conversions

Every conversion shown on this site follows a strict verification process to ensure correctness:

  • Compare results on same dataset — We run both SQL and ActiveRecord against identical test data and verify results match
  • Check generated SQL with to_sql — We inspect the actual SQL Rails generates to catch semantic differences (INNER vs LEFT JOIN, WHERE vs ON, etc.)
  • Add regression tests for tricky cases — Edge cases like NOT EXISTS, anti-joins, and predicate placement are tested with multiple scenarios
  • Tested on Rails 8.1.1 — All conversions verified on current Rails version to ensure compatibility

Last updated: January 16, 2026

Try These Queries in Our Converter

See the SQL examples from this article converted to ActiveRecord—and compare the SQL Rails actually generates.

119
R

Raza Hussain

Full-stack developer specializing in Ruby on Rails, React, and modern JavaScript. 15+ years upgrading and maintaining production Rails apps. Led Rails 4/5 → 7 upgrades with 40% performance gains, migrated apps from Heroku to Render cutting costs by 35%, and built systems for StatusGator, CryptoZombies, and others. Available for Rails upgrades, performance work, and cloud migrations.

💼 15 years experience 📝 12 posts

Responses (0)

No responses yet

Be the first to share your thoughts