P50 28s Analyze →
↩ All field notes
Querk · Field notes

Spotting N+1s in Your ORM Before They Page You at 3 AM

Published August 24, 2026 · Querk — Postgres review pipeline

Spotting N+1s in Your ORM Before They Page You at 3 AM

An endpoint that ran fine in staging at 50 milliseconds turns into a 2-second liability the moment real traffic and real data volumes show up. Nine times out of ten, the culprit is the same: an N+1 query pattern quietly issuing one query per row instead of one query for the whole set. It doesn't show up in unit tests with three fixtures. It shows up in production with three thousand rows, and it shows up as a page.

Why N+1s Are Invisible Until They Aren't

The N+1 pattern happens when code loads a collection of parent records, then loops over that collection triggering a separate query for each related child record. With ten rows, that's eleven queries — barely noticeable. With ten thousand rows, that's ten thousand and one queries, each carrying its own round-trip latency, connection overhead, and lock contention. The math is linear, but the pain is exponential relative to your latency budget, because every additional query competes for the same connection pool and the same database CPU that other requests need.

The reason this survives code review so often is that ORMs are designed to make lazy loading feel invisible. `post.comments` or `author.books` looks like a simple attribute access, not a query. That abstraction is exactly what hides the problem until load testing — or worse, a traffic spike — exposes it.

Framework-Specific Tells

In Rails, watch for `includes` being skipped in favor of implicit associations inside a `.each` block; the classic fix is `Model.includes(:association)` or `preload`/`eager_load` depending on whether you need a join. Rails' `bullet` gem is purpose-built to flag exactly this pattern in development logs.

In Django, the tell is a `.select_related()` or `.prefetch_related()` missing from a queryset that's later accessed in a template loop. Django's `django-debug-toolbar` shows a query count per request — if that number scales with row count, you have your answer.

In Hibernate, the giveaway is `FetchType.LAZY` associations accessed inside a loop without a `JOIN FETCH` or an entity graph. Hibernate's own statistics logging (`hibernate.generate_statistics`) will show query counts spiking in lockstep with result set size.

In Prisma, it's usually a `findMany()` followed by a `.map()` that calls another `findUnique()` per item, instead of using `include` or a batched `findMany` with an `in` filter. Prisma's query logging will show the repeated pattern immediately if you know to look.

Catching It Before Production Does

Query counting in CI is more reliable than eyeballing code. Assert a maximum query count per test for critical endpoints, and any regression that adds a query per row will fail loudly before it ships. Load testing with realistic row counts — hundreds or thousands, not five — is the other half; N+1s are often invisible at low volume and obvious at scale.

On the database side, analyzing query patterns that repeat with only a changing parameter is one of the fastest ways to confirm an N+1 after the fact, since the same query shape firing hundreds of times in a short window is a strong signal regardless of which ORM produced it. Tools like Querk that surface repeated query shapes and their frequency can shorten the time between "the endpoint is slow" and "here's the exact loop causing it."

Fixing the pattern is rarely hard once found — eager loading, batching, or a single join usually collapses N+1 queries into one or two. The hard part is noticing it before a traffic spike does the noticing at 3 AM.

Paste your slowest query — 3 free reviews, no signup. Index DDL, rewrites, write-path impact, and a verification command back in ~30 seconds. Analyze a query →