◉ANTIGRAVITY LABJP
Articles/App Development
▣ App Development/2026-07-10Advanced

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.

Antigravity377Performance6ORMCI9Testing

✦ Premium Article

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.ts
import { 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.ts
import { 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 });

With SQLAlchemy, listen on before_cursor_execute.

# tests/support/query_counter.py
from contextlib import contextmanager
from sqlalchemy import event
 
@contextmanager
def count_queries(engine):
    counter = {"n": 0}
 
    def _on_execute(conn, cursor, statement, params, context, executemany):
        counter["n"] += 1
 
    event.listen(engine, "before_cursor_execute", _on_execute)
    try:
        yield counter
    finally:
        event.remove(engine, "before_cursor_execute", _on_execute)

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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $15 for lifetime access
View Membership →

Related Articles

▣ App Dev2026-09-03
A dependency without musllinux wheels doesn't fail on Alpine. It just gets slower
On Alpine, a dependency without musllinux wheels can quietly fall back to pure Python. Protobuf went from 0.043ms to 36.2ms, and pip reported no error.
▣ App Dev2026-09-29
Purging Cloudflare Didn't Bring Back the New Wallpapers: the Stale JPEGs Were Held by XPageSpeed on the Origin
An operations record from a wallpaper app: images overwritten at the same URL kept arriving stale even after a Cloudflare purge. Covers the three-way local/origin/edge probe that names the guilty layer, when prefix purge beats per-URL purge, and how to switch off Xserver's XPageSpeed in .htaccess without a 500.
▣ App Dev2026-09-19
I Asked for the Whole Quote PDF Tool at Once, Then Split It Into Four Requests
How I moved my client quote paperwork from copy-and-edit to a small PDF tool built with Antigravity, why the first single-shot request had to be thrown away, and the four smaller requests that replaced it, with working code.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links