The N+1 Query: Death by a Thousand SELECTs

The page worked fine with ten records and fell over with ten thousand. A field guide to spotting the N+1 query hiding in your loops, serializers, and helpers.

1 querypost 1post 2post 3post 4post 5×N
Bob
Senior Backend Engineer
Dec 3, 2025
6 min read

A few years back I got paged because our orders dashboard was taking 40 seconds to load. Nothing had been deployed that day. No infrastructure had changed. The only thing that had changed was that a big customer had finished onboarding, and their account had 12,000 orders instead of the 30 we'd tested with.

The culprit was four lines of code that had passed review six months earlier, worked flawlessly the entire time, and was quietly running 12,001 database queries per page load.

That's the thing about N+1 queries. They're not bugs in the traditional sense. The code is correct. It returns the right answer every time. It just does it in the most expensive way possible, and it hides that cost until your data grows enough to expose it.

What it actually looks like

The classic form is a loop over a collection where each iteration touches an association:

# 1 query to load the orders...
orders = Order.where(status: :shipped)

orders.each do |order|
  # ...then N queries, one per order
  puts order.customer.name
end

One query fetches the orders. Then for every single order, Rails runs another SELECT to fetch that order's customer. Thirty orders, thirty-one queries. Twelve thousand orders, and now you know why my dashboard took 40 seconds.

The fix is old news — tell ActiveRecord up front what you'll need:

orders = Order.where(status: :shipped).includes(:customer)

Two queries total, no matter how many orders. Everyone knows this. So why do N+1s keep shipping? Because in real codebases, they almost never look like that textbook example.

Where they actually hide

In serializers and JSON builders

This is the most common hiding spot I see in review. The controller looks innocent:

def index
  render json: current_account.projects
end

But somewhere in the serializer, there's a has_many :tasks or a computed field like owner_name that reaches through an association. The loop isn't in your code — it's in the serialization layer, iterating the collection for you. You'll never see it by reading the controller. You'll only see it in the query log.

In helpers and presenters

A view helper like badge_for(user) that checks user.subscription.plan looks like a pure formatting function. Call it once, it's fine. Call it inside a table with 200 rows and you just added 200 queries, and the diff that introduced it touched a helper file nobody thought to load-test.

In "just one more field" changes

The most dangerous N+1s are added to code that was already eager-loaded correctly. Someone adds order.customer.company.billing_address to a view where only :customer was included. The original author did everything right; the association chain just got one link longer than the includes covered.

In AI-generated code

I'll be honest: I'm seeing this pattern more, not less, since coding assistants became standard. The models write idiomatic, readable Ruby — and idiomatic, readable Ruby loops over associations. An assistant asked to "show each comment with its author's avatar" will happily produce a clean comments.each block with comment.author.avatar_url inside it. It compiles, it passes the tests (which run against five records), and it reads beautifully in the diff. The query pattern is invisible unless you go looking. If a chunk of code arrived via autocomplete or a chat window, treat association access inside loops as a mandatory checkpoint.

How to actually catch them

Reading the code helps, but honestly, the reliable methods are all about observing the queries, not the code.

Watch the log in development. Rails logs every query. If you load a page and see the same SELECT shape repeat with different IDs — that unmistakable machine-gun pattern — you have an N+1. This costs nothing and catches most of them.

Use strict_loading. Modern Rails will raise (or log) when a lazy load happens on a record you've flagged:

orders = Order.strict_loading.where(status: :shipped)
orders.first.customer # raises StrictLoadingViolationError

You can enable it per-query, per-association, or globally in development. It turns a silent performance problem into a loud, immediate error, which is exactly the trade you want.

Run Bullet (or similar) in dev and CI. Bullet watches your queries and yells when it detects both N+1s and unused eager loading — because includes everything everywhere is its own smell.

In code review, look for the shape. Any loop, map, or serializer collection where the body touches a belongs_to or has_many is a question worth asking. Not an accusation — a question: "is this collection eager-loaded where it's queried?" Half the time the answer is yes. The other half, you just saved a future incident.

Fixing without overcorrecting

includes is the default answer, but a couple of nuances matter:

  • preload vs eager_load: includes lets Rails choose. If you need to filter on the association, you need eager_load (or an explicit joins), because preload runs a separate query and can't be referenced in WHERE.
  • Don't eager-load the world. Loading five associations "just in case" bloats memory and slows the query down. Load what the code path actually uses.
  • Sometimes the answer is a counter cache or a select. If you only need order.line_items.count for a badge, a counter_cache column beats loading every line item. If you need one field from the association, joins plus select may beat instantiating whole objects.
  • Sometimes the answer is SQL. For dashboards and reports, one honest aggregate query with a GROUP BY will outrun any amount of clever eager loading.

And a note from experience: when you fix an N+1, add a test or a Bullet assertion if your setup allows it. Otherwise the next well-meaning "just one more field" change reintroduces it, and nobody notices until the next big customer onboards.

The checklist I actually use

When I review a diff that touches queries, loops, or serializers, I run through this:

  1. Any association access inside a loop, map, or partial rendered per-record? Ask where the collection is loaded and whether it's eager-loaded.
  2. Serializer changes? Check what associations the serializer now touches, and whether the controllers feeding it were updated.
  3. New helper or presenter method? Check whether it queries, and where it gets called from.
  4. Generated code? Assume the assistant optimized for readability, not query count. Verify with the log.
  5. Fixture sizes. If the tests run with 3 records, they will never catch this. Load the page locally with a few hundred.

The N+1 is the most forgivable performance bug there is — we've all shipped one, most of us this quarter. The skill isn't never writing them. It's knowing their hiding spots well enough that they don't survive review.

railsperformancedatabaseactiverecordcode-review
Written by
Bob
Senior Backend Engineer · Firetrail review team
More Backend