← All writing
Engineering·Sep 16, 2026·5 min read

The N+1 query that only showed up with real data

Twelve seed rows in staging turn into thirteen queries and nobody notices. Four thousand rows in production turn the same line of code into a page that times out.

H

Hammad Iqbal

Software Engineer

The code review approved it because it read cleanly: fetch the orders, map over them, pull each customer's name. Nothing about that line looks like a performance bug — it looks like normal object access. What it actually is, once the ORM's lazy loading is behind it, is a loop with a network call hiding inside every iteration. In staging, with a dozen seeded orders, that's thirteen queries and a response so fast nobody would think to check. In production, with four thousand orders on the page, it's four thousand and one round trips to the database, run one after another, and a support ticket about a dashboard that 'just hangs.'

In brief
  • Lazy-loaded associations run once per row they're accessed on, not once per query — the cost scales with row count, not with the code.
  • Seed and staging data almost never has enough rows to make an N+1 pattern slow, so it survives review, QA, and a demo without anyone seeing it.
  • The fix is nearly always eager-loading the association in the original query, not adding a cache in front of the symptom.

It's not a bug, it's a loop with a network call in it

`orders.map(o => o.customer.name)` looks like a property access, but an ORM with lazy-loaded relations turns `o.customer` into its own query the first time it's touched. The line that fetched `orders` runs once. Then the loop runs once per order, and each iteration quietly fires a second query to resolve `customer`. Nothing in the syntax signals that a database round trip just got tied to the length of an array.

Twelve rows hide it, four thousand rows don't

The pattern is invisible at small scale because the cost is linear and the constant is tiny. Twelve orders means thirteen queries against a local database over a loopback connection — a few milliseconds, lost in the noise of everything else the request does. Nobody profiles a page that already feels instant.

Production doesn't have twelve orders. It has however many the business generated since launch, and the same code that was thirteen queries in staging is thousands of sequential round trips in production, each one paying real network latency instead of loopback latency. The page doesn't get a little slower — it crosses from instant to timing out, because the cost was never O(1), it was O(n), and n just stopped being small.

Eager load once, not per row

The fix is almost never a cache in front of the slow endpoint — that treats the symptom and adds a second thing that can go stale. It's telling the original query to bring the association with it: a join, an `include`, a `preload`, whatever the ORM calls fetching the related rows in the same round trip or one batched follow-up query instead of one per row. Four thousand orders becomes two queries instead of four thousand and one, and the fix is smaller than the workaround would have been.

Takeaway

If a line of code makes one query per item in a loop, it isn't slow yet — it's just waiting for the loop to get long enough for you to notice, and by then it's a production incident instead of a code review comment.

Have a project this kind of thinking applies to?

Tell me what you're building — I read every message myself.

Available for new projects

4+

Years exp.

8+

Projects shipped

<6h

Avg. reply time

Let's build something

Tell me about your project.

Share what you're building, your timeline, and the best way to reach you.