- Book: Database Playbook
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A page of 100 orders, 101 round trips to Postgres, and every test on the agent's branch is green.
The agent's GET /orders handler returns each order with its customer's name. It fetches the orders, then loops over them and fetches each customer with its own query: one for the list, one per row. The diff is short and readable, and the test data has two rows.
That is the N+1 query problem. The Laravel docs put numbers on it: 25 books and their authors, loaded in a loop, "would run 26 queries". Your functional tests don't catch it. They check the JSON, and the JSON is correct. None of them counts the queries behind it.
So you add a test that does. It wraps one request, counts the queries, and fails the branch before the agent's work lands.
Why the green tests miss it
Here is the kind of code an agent writes when the prompt says "return orders with their customer". It uses node-postgres and Express on this schema:
CREATE TABLE customers ( id serial PRIMARY KEY, name text NOT NULL ); CREATE TABLE orders ( id serial PRIMARY KEY, customer_id int NOT NULL REFERENCES customers(id), total_cents int NOT NULL ); // src/db.ts import { Pool } from "pg"; export const pool = new Pool({ connectionString: process.env.DATABASE_URL, }); // src/orders.ts import { pool } from "./db"; export async function listOrders() { const { rows: orders } = await pool.query( "SELECT id, customer_id, total_cents FROM orders ORDER BY id" ); for (const order of orders) { const { rows } = await pool.query( "SELECT id, name FROM customers WHERE id = $1", [order.customer_id] ); order.customer = rows[0]; } return orders; } // src/app.ts import express from "express"; import { listOrders } from "./orders"; export const app = express(); app.get("/orders", async (_req, res) => { res.json(await listOrders()); }); Every line of it is correct. A test that seeds two orders gets two orders back, each with the right customer, after three queries. Nobody counts three. At 100 rows the same code makes 101 queries, and at a few thousand rows the endpoint is slow in a way no assertion on the response body will show.

One request, one query per row: the shape of an N+1.
Step 1: count the queries around one request
You don't need a library for this. pool.query is the method the app calls for every query (pool docs), so the test can wrap it, record each SQL string, and still run the real query:
// test/count-queries.ts import type { Pool } from "pg"; export function countQueries(pool: Pool) { const real = pool.query; const seen: string[] = []; pool.query = function (this: Pool, ...args: unknown[]) { const q = args[0] as string | { text: string }; seen.push(typeof q === "string" ? q : q.text); return (real as Function).apply(this, args); } as Pool["query"]; return { seen, restore: () => { pool.query = real; }, }; } One limit: this only sees pool.query. If your code checks out a client with pool.connect() (the pool docs say not to use pool.query for transactions, because a transaction lives on a single client), wrap that client's query method the same way.
Step 2: seed 100 rows and watch it fail
The test uses Vitest and supertest, which binds the app to an ephemeral port for you. Point DATABASE_URL at a throwaway test database with the schema above applied, because the setup truncates both tables:
// test/orders.queries.test.ts import request from "supertest"; import { afterAll, beforeAll, expect, test } from "vitest"; import { app } from "../src/app"; import { pool } from "../src/db"; import { countQueries } from "./count-queries"; beforeAll(async () => { await pool.query( "TRUNCATE orders, customers RESTART IDENTITY" ); await pool.query(` INSERT INTO customers (name) SELECT 'customer ' || n FROM generate_series(1, 100) n`); await pool.query(` INSERT INTO orders (customer_id, total_cents) SELECT n, n * 100 FROM generate_series(1, 100) n`); }); afterAll(() => pool.end()); test("GET /orders: query count does not grow", async () => { const q = countQueries(pool); try { const res = await request(app).get("/orders"); expect(res.status).toBe(200); expect(res.body).toHaveLength(100); // A JOIN is 1 query, a batched load is 2. const msg = `${q.seen.length} queries:\n` + q.seen.slice(0, 3).join("\n"); expect(q.seen.length, msg).toBeLessThanOrEqual(2); } finally { q.restore(); } }); The seeding runs before the counter is installed, so it doesn't count. Against the loop above, the count is 1 for the orders plus 100 for the customers, so the last assertion fails at 101. Vitest takes a message as the second argument to expect, so the failure also prints the first few SQL strings, and the repeated WHERE id = $1 tells whoever reads it where the loop is.
The limit of 2 is the point. It doesn't care whether there are 100 rows or 10,000. A list endpoint whose query count grows with its rows fails, and one that stays flat passes.
Step 3: hand the failing test back to the agent
Now the agent has something concrete to fix: a test that fails, a number, and the SQL. Give it all three, and say what it must not touch:
test/orders.queries.test.ts fails: GET /orders runs 101 queries for 100 orders. Load the customers in the same query with a JOIN, keep the response shape the same, and make the test pass without editing the test. The last clause matters. The fastest way to turn a red test green is to change the test, so check that it wasn't changed (git diff main...HEAD -- test/ should print nothing). The fix you want looks like this:
export async function listOrders() { const { rows } = await pool.query(` SELECT o.id, o.customer_id, o.total_cents, c.name AS customer_name FROM orders o JOIN customers c ON c.id = o.customer_id ORDER BY o.id`); return rows.map((r) => ({ id: r.id, customer_id: r.customer_id, total_cents: r.total_cents, customer: { id: r.customer_id, name: r.customer_name }, })); } One query, same JSON, and the test passes at 1.
The same test in Django and Laravel
ORMs make the loop easier to write by accident, because order.customer looks like a field access and runs a query. Both big frameworks ship the counter.
Django has assertNumQueries, which works as a context manager on TestCase:
from django.test import TestCase from django.urls import reverse from shop.models import Customer, Order class OrderListQueries(TestCase): @classmethod def setUpTestData(cls): for n in range(100): c = Customer.objects.create(name=f"c{n}") Order.objects.create(customer=c, total_cents=100) def test_order_list_is_one_query(self): with self.assertNumQueries(1): self.client.get(reverse("order-list")) The fix is select_related("customer"), which joins the related row into the first query. Django 6.1 also added fetch modes: FETCH_PEERS loads the missing field for every instance from the same queryset at once, and FETCH_RAISE raises FieldFetchBlocked instead of querying.
Laravel 13 gives you DB::listen, which calls a closure for every query. Register it after seeding so the factory inserts don't count. This assumes your OrderFactory defines customer_id as Customer::factory(), so each order gets its own customer:
public function test_order_list_query_count(): void { Order::factory()->count(100)->create(); $queries = 0; DB::listen(function () use (&$queries) { $queries++; }); $this->getJson('/api/orders')->assertOk(); $this->assertLessThanOrEqual(2, $queries); } There is also expectsDatabaseQueryCount, but it counts every query in the whole test, seeding included, so it fits tests that don't create data first. The fix is Order::with('customer')->get(), which the docs show running two queries instead of one per row. And Model::preventLazyLoading(! $this->app->isProduction()) throws a LazyLoadingViolationException outside production whenever code lazy-loads a relationship (docs).
In any stack, middleware such as sessions or auth can add queries of their own. Measure the endpoint once after the fix and set the limit from that number.
Make it the done-check on the agent's branch
The test only helps if it runs before the branch merges. With plain git that is your job: check out the agent's branch (or its worktree), run npx vitest run test/orders.queries.test.ts, and merge only on green.
I build Hermes IDE, so weigh this section with that in mind. In Hermes, ⌘N (Ctrl+Shift+N on Windows and Linux) starts each task on its own hermes/ branch in its own worktree, with the task as the agent's first prompt. That is where a prompt like the one from step 3 goes.

The ⌘N New task sheet: the task text, chips for Claude Code, the orders-api project, a new hermes/ worktree, Accept edits, model and effort, and a preview of the claude command Hermes will run.
The test becomes the task's check. In Hermes, your own checks can decide when a task is done, and you can set a default for your repo as done_when in .hermes/worktree.toml:
# .hermes/worktree.toml done_when = ["npx vitest run test/orders.queries.test.ts"] Use Land only once that check passes. It commits, opens a pull request or merges locally in one step, into the branch the task started from, and can be undone.
To see where the loop came from, open the Review Desk with ⌘G (Ctrl+Shift+G). Hermes saves every agent turn, and the Review Desk shows the changes by turn, so you can find the turn that added the for loop. A line comment on that loop goes back to the agent that wrote it, and you can diff or restore any single turn, with undo.

The Review Desk (⌘G) in By turn view: two turns listed, one turn's diff, the changed-files list, and a commit message drafted from the turns.
Hermes is free to download for macOS, Windows and Linux, and source-available under BSL 1.1. It is not affiliated with Nous Research, Anthropic, OpenAI or any agent vendor. Every agent runs in its own terminal with your own sign-in. The test, the branch and the fix are plain git and Vitest either way.
If this was useful
A query-count test tells you how many trips an endpoint makes. Deciding what those trips should look like is a database question: which store, which schema, which index, which join. The Database Playbook is my short guide to those choices, for the moments when the agent's code is correct and the data model under it is the real problem.

