Audit BenchAi
← All posts

Why AI Coding Assistants Keep Reintroducing N+1 Queries

·7 min read

Ask an AI assistant to "show each order with its customer's name" and a large share of the time you'll get a loop: fetch all the orders, then for each order fetch its customer. It works. It passes a manual test with ten seed rows. It also issues one query to load the orders and N more queries to load each order's customer, and on a production table with ten thousand orders that's ten thousand and one round trips to the database for a single page load. This is the N+1 query problem, and it is one of the most reliably recurring architectural bugs in AI-generated code.

Why the loop reads as correct

At the level of a single function, the code is doing exactly what was asked, in an order that makes intuitive sense: get the list, then get details for each item in the list. There's no syntax error, no obvious logic bug, and the output is correct. The defect is entirely about cost, not correctness, and cost is invisible unless you're specifically counting queries or testing against a dataset large enough to make the pattern hurt. An assistant generating code from a prompt has no visibility into your production row counts — it optimizes for "produces the right output," and a loop produces the right output.

ORMs make the pattern easier to write, not harder to have

Modern ORMs (Active Record, Eloquent, SQLAlchemy, Prisma, TypeORM) make this worse in a specific way: their default lazy-loading behavior means accessing a related object — order.customer.name — triggers a query automatically, invisibly, at the point of access. The code doesn't look like it's making a database call at all; it looks like a property access. An AI assistant reaching for the most natural-looking way to express "get the customer for this order" will reach for exactly this syntax, because it's the idiomatic way the ORM is designed to be used — and it's also the one that silently produces N extra round trips.

Why this pattern specifically recurs across AI-generated code

Eager-loading the right relationship (include, joinedload, with(), depending on the ORM) requires knowing in advance which related data the calling code will need — a piece of intent that lives in the developer's head, not in the immediate prompt. Absent an explicit instruction to eager-load, the assistant has no signal that this particular query will run in a loop rather than once, so it defaults to the simplest correct expression of the request. Multiply this across every list-with-related-data feature in an application built prompt by prompt, and N+1 becomes the default shape of the data layer rather than an occasional lapse.

How to catch it before it reaches production

The most reliable check doesn't require reading the ORM code carefully — it requires counting. Logging or asserting the number of queries executed during a test against a realistic (not single-row) dataset turns an invisible cost into an assertion that fails loudly: "this request issued 41 queries, expected 2." Several ORMs and frameworks ship a query-count assertion helper or a development-mode query logger for exactly this reason — the tool exists; it just has to be pointed at the endpoints that return lists.

Checklist for reviewing a diff that loads related data

  1. Any loop that accesses a related object or makes a per-item database call is a candidate for eager loading — check for it explicitly rather than trusting that the loop "looks fine."
  2. Test list endpoints against a seeded dataset of realistic size (dozens or hundreds of rows, not one or two), where an N+1 pattern is slow enough to notice.
  3. Where the ORM supports it, assert on query count in tests for endpoints that return a list with related data, so a regression fails the test suite instead of showing up as a latency spike later.
  4. Treat "returns a list with nested related data" as a standing prompt to ask explicitly for eager loading, rather than trusting the assistant to infer it.

Read how to review database migrations safely →

See security review patterns for large codebases →