DEV Community

Cover image for Your Agent's Endpoint Runs 101 Queries. A Query-Count Test Fails the Branch First
Gabriel Anhaia
Gabriel Anhaia

Posted on Edited on AI-assisted

Your Agent's Endpoint Runs 101 Queries. A Query-Count Test Fails the Branch First


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
);
Enter fullscreen mode Exit fullscreen mode
// src/db.ts
import { Pool } from "pg";

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});
Enter fullscreen mode Exit fullscreen mode
// 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;
}
Enter fullscreen mode Exit fullscreen mode
// 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());
});
Enter fullscreen mode Exit fullscreen mode

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 arrow fanning out into a hundred identical small arrows that all hit the same database cylinder
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;
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
});
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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 },
  }));
}
Enter fullscreen mode Exit fullscreen mode

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"))
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 Hermes New task sheet with a task for the orders-api project typed in, and chips for Claude Code, a new hermes/ worktree, Accept edits, model and effort
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"]
Enter fullscreen mode Exit fullscreen mode

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 Hermes Review Desk in By turn view for a Claude Code task in orders-api, with two turns listed, one turn's diff, the changed files and a drafted commit message
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.

Database Playbook: Choosing the Right Store for Every System You Build

Top comments (0)