- Home
- Blog
- Ruby & Rails Core
- N+1 Isn’t a Rails Problem: It’s a Query-Shaping Problem
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.
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
userwhen 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:
- the base relation
- 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
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.
Deep Dive into ActiveRecord
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.
More on SQL to ActiveRecord
Why ActiveRecord Exists: The SQL Mental Model That Breaks in Rails
If you know SQL, ActiveRecord can feel pointless—until it saves you from consistency bugs. Here’s the mental model: where SQL alone breaks app invariants, and how to use AR without losing performance.
LEFT JOIN + WHERE in ActiveRecord: The Trap That Turns It Into INNER JOIN
LEFT JOIN queries often break the moment you add a WHERE on the joined table—silently turning into INNER JOIN behavior. Learn the correct Rails patterns (ON vs WHERE, scoped associations, where.missing/where.associated, EXISTS) and how to verify generated SQL.
ActiveRecord equivalent of FULL OUTER JOIN
Need a FULL OUTER JOIN in Rails? Learn why ActiveRecord doesn’t support it natively and the safest Postgres + UNION workarounds for production.
Responses (0)
No responses yet
Be the first to share your thoughts