An Agent's ORM Code Made p95 Five Times Slower — Measuring Query Counts and Blocking Them in CI
N+1 queries slip past code review because the code looks correct. Here is a CI gate that measures query counts and judges them by their slope against input size, with working code and real numbers.
Late one night I noticed the p95 latency on a dashboard had jumped to 940ms. The day before, that same list endpoint had been sitting comfortably at around 180ms.
Nothing was broken. Error rate was zero. The response bodies were correct. It was simply slow.
Tracing back, I landed on a small change I had handed to an agent that afternoon: add "the latest comment for each item" to the list response. Twelve lines of diff. When I reviewed it, my only thought was that it read nicely, and I merged it.
One of those twelve lines resolved a relation inside a loop.
Correct code is not necessarily fast code
What makes N+1 queries so treacherous is that nothing about them is wrong.
Types check. Tests pass. The JSON matches expectations. What a reviewer sees is await getComments(item.id) inside a for loop — a line that is, in isolation, perfectly reasonable. The fact that the number of calls scales with the request payload appears nowhere in the diff.
Since I started delegating implementation work to agents, this asymmetry has grown sharper. Where a human writing the loop might feel a small internal alarm, generated code always reads calm and correct. I have noticed that the shorter the diff, the more readily I let my guard down.
So I stopped trying to solve this by reviewing more carefully. That path depends on human attention, and human attention is not a system property.
You cannot protect what you do not measure.
Making query counts readable from a test
Every major ORM exposes a hook on query execution. That is all we need to count queries from inside a test.
With Prisma, instantiate the client with query events and push into a counter.
// test/support/query-counter.tsimport { PrismaClient } from "@prisma/client";export const prisma = new PrismaClient({ log: [{ emit: "event", level: "query" }],});let count = 0;const seen: string[] = [];prisma.$on("query", (e) => { count += 1; seen.push(e.query);});export function resetQueryCount(): void { count = 0; seen.length = 0;}export function getQueryCount(): number { return count;}export function getQueries(): readonly string[] { return seen;}
With Drizzle, swap in a custom logger.
// test/support/drizzle-counter.tsimport { drizzle } from "drizzle-orm/node-postgres";import type { Logger } from "drizzle-orm/logger";class CountingLogger implements Logger { count = 0; logQuery(query: string): void { this.count += 1; }}export const logger = new CountingLogger();export const db = drizzle(pool, { logger });
That is only instrumentation. The harder question was what to assert on.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Turning query counts into a testable value, and detecting N+1 by slope against input size
✦Hook implementations for Prisma, Drizzle, and SQLAlchemy, plus the full CI wiring
✦Measured outcomes and honest limits: p95 940ms to 190ms, gate cost +11 seconds
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
It became decorative within six months, for two reasons.
First, features legitimately add queries. Somebody raises the ceiling from 20 to 25, then to 30. Each raise looks defensible on its own, and nobody ever reviews the history of raises.
Second, whether code is N+1 has nothing to do with the absolute number. If your fixtures seed three items, an N+1 implementation still finishes in five queries. A threshold of 20 waves it through. It only bares its teeth when production hands the endpoint 200 items.
The signal was never the count. It was how the count grows with input size.
Approach
Detects N+1 with N=3 fixtures?
Survives feature growth?
Absolute ceiling (20 or fewer)
No
No — the ceiling keeps drifting up
Regex inspection of query strings
Partially
No — breaks when query shape changes
Slope against input size
Yes
Yes — the invariant is size-independence
A properly batched implementation issues the same number of queries for one item as for twenty. Its slope is zero. An N+1 implementation adds one query per additional item. Its slope approaches one.
Stated as an invariant: the query count of this endpoint does not depend on the number of input rows. That is a proposition a machine can verify, and it does not depend on how alert a reviewer felt that afternoon.
An assertion built on slope
Run the same scenario at several input sizes and compute the least-squares slope.
// test/support/assert-constant-queries.tsimport { resetQueryCount, getQueryCount } from "./query-counter";type Scenario = (n: number) => Promise<void>;function slope(xs: number[], ys: number[]): number { const n = xs.length; const mx = xs.reduce((a, b) => a + b, 0) / n; const my = ys.reduce((a, b) => a + b, 0) / n; let num = 0; let den = 0; for (let i = 0; i < n; i++) { num += (xs[i] - mx) * (ys[i] - my); den += (xs[i] - mx) ** 2; } return den === 0 ? 0 : num / den;}export async function assertQueryCountIsConstant( scenario: Scenario, opts: { sizes?: number[]; maxSlope?: number } = {},): Promise<void> { const sizes = opts.sizes ?? [1, 5, 20]; const maxSlope = opts.maxSlope ?? 0.2; const counts: number[] = []; for (const n of sizes) { resetQueryCount(); await scenario(n); counts.push(getQueryCount()); } const s = slope(sizes, counts); if (s > maxSlope) { throw new Error( `Query count scales with input size (slope ${s.toFixed(2)}). ` + sizes.map((n, i) => `N=${n}:${counts[i]}`).join(" / ") + ` — suspected N+1`, ); }}
The call site stays small.
// test/api/items.list.test.tsimport { assertQueryCountIsConstant } from "../support/assert-constant-queries";import { seedItems, listItemsWithLatestComment } from "./helpers";it("query count for the list endpoint is independent of item count", async () => { await assertQueryCountIsConstant(async (n) => { await seedItems(n); await listItemsWithLatestComment(); });});
I set maxSlope to 0.2 rather than 0 as a practical concession. Real endpoints have branches — a pagination count query that appears only past a certain size, for instance. A slope of 0.2 tolerates roughly four extra queries at twenty items. Genuine N+1 code produces a slope above 0.9, so that margin has never let one through.
Whether to include 20 in sizes depends on how expensive your seeding is. I start with [1, 5, 20] and drop to [1, 4, 10] on heavy tables. Three points are enough for a stable slope.
Running this assertion against the code that caused my 940ms night produced N=1:4 / N=5:8 / N=20:23, slope 1.00, and failed immediately. After the fix: 4 / 4 / 5, slope 0.05.
Put it in CI, then let the agent read it
A gate only matters once it runs in CI. The part that took me a second attempt was making the failure output explain the fix.
Then I wrote the invariant itself into the repository's AGENTS.md, so the agent works against the same constraint the CI enforces.
## Data access rules- Never resolve a relation inside a loop. Batch with `include` / `with` / `selectinload`- Any change to a list endpoint must add an `assertQueryCountIsConstant` test- Code whose query-count slope exceeds 0.2 cannot merge (CI fails automatically)
Asking an agent to "avoid N+1 queries" is too abstract to change anything. Only when I translated it into a concrete, checkable constraint — a slope above 0.2 fails the build — did the generated code start coming back batched. I suspect the same is true when the person writing the code is a person.
A constraint becomes a constraint only when it is verifiable.
What two weeks of running it taught me
Numbers from a small backend I run as an indie developer: 18 tables, 34 endpoints.
List endpoint p95: 940ms to 190ms after the N+1 fix
CI runtime: +11 seconds, the cost of running the same scenario at three sizes
N+1 issues caught in two weeks: 3 — two in agent-generated code, one in my own
False positives: 1, caused by including N=1 and picking up a one-time cache warm-up query
I resolved that false positive by moving sizes to [2, 5, 20]. When a code path runs initialization queries only on first execution, including N=1 as a measurement point tilts the slope. The cleaner fix is a warm-up run before measurement, but starting at two removes the practical harm.
The limits deserve saying out loud. This gate watches the number of queries and nothing else. A single query doing a full table scan because it missed an index passes with a slope of zero. For that, assert on EXPLAIN cost separately, or catch it in production slow-query logs.
The gate also passes silently when caching absorbs the repeated reads. Disable your cache layer in any test that counts queries. Forget this, and the gate settles into the most dangerous state available to it: always green.
Where to start
Pick the single busiest list endpoint in your repository and write one assertQueryCountIsConstant test for it.
Run it at [1, 5, 20] and print the counts. Three seconds later you will know whether that endpoint is N+1. Three seconds was exactly what I wanted on the night of the 940ms.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.