Detecting N+1 Queries with Traces

The N+1 pattern — one query for a list, then one more for every item in it — is the most common database performance problem in ORM-based Python services, and it is invisible to every per-query tool. Each query is fast; the slow query log is empty; the database's statistics look healthy. Only at the level of a single request does the problem appear, as three hundred near-identical statements where two would do. This page covers detecting it from traces, alerting on it per route, and failing tests when it is reintroduced. It is a task article under database and I/O performance observability, part of the Python profiling and performance observability section.

The staircase in the trace Two traces for an endpoint that lists orders with their line items. In the first, one query fetches the orders and then a staircase of short spans follows, one per order, each fetching that order's line items with an identical statement differing only in its parameter; for fifty orders that is fifty-one queries, each around two milliseconds, adding up to over a hundred milliseconds plus the round trip overhead between them. In the second, the same data is fetched with two queries: one for the orders and one for all their line items using an IN list, so the trace shows two spans and the count stays at two regardless of how many orders there are. The note records that the first trace's shape is the signature — a staircase of identical short spans — and that its length grows with the data. GET /orders — 50 orders with line items lazy loading in a loop … 50 identical statements SELECT * FROM line_items WHERE order_id = %s — × 50, each 2 ms 51 queries · grows with the number of orders eager loading one IN-list query for all 50 orders 2 queries · constant, whatever the number of orders the signature is the staircase: identical short spans, one per item every one of them is fast — which is why no per-query tool reports a problem
A staircase of identical short query spans is the shape. Its length scales with the data, which is what makes it get worse as the product succeeds.

Prerequisites

pip install "sqlalchemy>=2.0.0,<3.0.0" \
            "opentelemetry-instrumentation-sqlalchemy>=0.48b0,<1.0.0" \
            "prometheus-client>=0.20.0,<1.0.0" \
            "pytest>=8.0.0,<9.0.0"

Implementation

Step 1 — Count statements per request. Repetition is invisible at the level of individual queries and obvious at the level of the request, so the count has to be accumulated per request. A mutable counter stored in a context variable, incremented by the driver's execute hook and read when the request ends, gives it — and a histogram per route shows which endpoints are affected.

import collections
import contextvars
from sqlalchemy import event
from prometheus_client import Histogram

_stmts: contextvars.ContextVar[collections.Counter | None] = \
    contextvars.ContextVar("stmts", default=None)

QPR = Histogram("db_queries_per_request", "Statements per request", ["route"],
                buckets=(1, 2, 5, 10, 20, 50, 100, 200, 500, 1000))


def begin_request():
    _stmts.set(collections.Counter())


@event.listens_for(engine, "before_cursor_execute")
def _count(conn, cursor, statement, parameters, context, executemany):
    counter = _stmts.get()
    if counter is not None:
        counter[statement] += 1        # template, because parameters are separate

Step 2 — Report the most repeated template. Knowing a request issued three hundred queries is useful; knowing that two hundred and ninety-eight of them were the same statement names the loop. Putting both on the server span means every trace carries its own diagnosis.

from opentelemetry import trace

def end_request(route: str):
    counter = _stmts.get()
    if not counter:
        return
    total = sum(counter.values())
    QPR.labels(route).observe(total)
    template, repeats = counter.most_common(1)[0]
    span = trace.get_current_span()
    span.set_attribute("db.query_count", total)
    span.set_attribute("db.most_repeated_count", repeats)
    span.set_attribute("db.most_repeated_statement", template[:200])
    if repeats >= 10:
        span.set_attribute("db.suspected_n_plus_one", True)

Expected Output: a server span that identifies the pattern without anyone opening its children.

{
  "name": "GET /orders",
  "attributes": {
    "db.query_count": 51,
    "db.most_repeated_count": 50,
    "db.most_repeated_statement": "SELECT line_items.id, line_items.sku, … WHERE line_items.order_id = %(param_1)s",
    "db.suspected_n_plus_one": true
  }
}

Step 3 — Alert per route. Different endpoints legitimately issue different numbers of queries, so a single global threshold is either too loose for simple endpoints or too tight for complex ones. A threshold per route, or an alert on a sudden increase relative to the same route's own history, catches the regression that matters: the deploy after which an endpoint went from four queries to four hundred.

# a route's p95 query count jumped relative to its own last week
histogram_quantile(0.95, sum by (route, le) (rate(db_queries_per_request_bucket[30m])))
  >
3 * histogram_quantile(0.95, sum by (route, le) (rate(db_queries_per_request_bucket[30m] offset 7d)))

Step 4 — Fix with eager loading or batching. The fix is to fetch the related rows in one query for all items rather than one per item. In SQLAlchemy that is a loader option on the original query; in hand-written code it is a single query with an IN list followed by grouping in Python.

from sqlalchemy import select
from sqlalchemy.orm import selectinload

stmt = (
    select(Order)
    .where(Order.customer_id == customer_id)
    .options(selectinload(Order.line_items))     # one extra query, not one per order
)
orders = session.scalars(stmt).all()

Step 5 — Fail tests that exceed a query budget. The pattern is reintroduced constantly, usually by a template or serialiser that accesses a relationship nobody thought of as a query. A test that renders the endpoint with a representative number of related records and asserts on the query count catches it in review. The key detail is using enough records that the difference between constant and linear is unmistakable — ten is a good minimum.

# test_query_budget.py
import pytest
from sqlalchemy import event

@pytest.fixture
def query_counter(engine):
    count = {"n": 0}
    def _inc(*args, **kwargs):
        count["n"] += 1
    event.listen(engine, "before_cursor_execute", _inc)
    yield count
    event.remove(engine, "before_cursor_execute", _inc)

def test_order_list_query_count_is_constant(client, make_orders, query_counter):
    make_orders(count=25, items_each=4)
    client.get("/orders")
    # 1. Constant regardless of order count; a linear pattern would be ~26.
    assert query_counter["n"] <= 3, f"issued {query_counter['n']} queries"

Expected Output: a failing test when the pattern is introduced.

FAILED test_query_budget.py::test_order_list_query_count_is_constant
AssertionError: issued 27 queries
Why it gets worse as the business grows Query count per request is plotted against the number of related records returned. The N+1 endpoint's line rises linearly: eleven queries for ten records, one hundred and one for a hundred, a thousand and one for a thousand. The fixed endpoint's line is flat at two queries regardless. A marker at ten records shows where development and test data typically sit, where the difference between eleven and two queries is easy to overlook and the latency is similar. A marker at a thousand records shows where a large customer sits in production, where the N+1 endpoint issues five hundred times as many queries and takes seconds. The note records that this is why the test in step five uses enough records to make linear growth unmistakable. queries per request against related records related records returned queries N+1 — linear in the data eager loaded — constant test data 11 vs 2 large customer: 1001 vs 2
In test data the difference is nine queries and nobody notices. For a large customer it is a thousand, and the endpoint times out.

Where the pattern hides

N+1 queries are rarely written deliberately. They emerge from code that looks innocent in isolation, and four locations account for most of them.

Templates and serialisers. A template that loops over orders and displays each order's customer name triggers a lazy load per order. The template author may not know the relationship is lazy, and the view author may not know the template accesses it. Neither piece of code looks wrong on its own.

Properties and computed fields. A model property that computes something from a relationship — a total from line items, a status from related records — issues a query every time it is accessed. Accessing it in a list view is a loop of queries hidden behind attribute syntax.

Permission and ownership checks. A per-item check that loads the item's owner or team to decide visibility is a query per item, often added late, often in a decorator or middleware that the list endpoint's author never sees.

Background jobs processing batches. A job iterating over a batch of records and accessing a relationship on each is the same pattern with no user-facing latency to reveal it — only a job that takes an hour when it should take a minute, and a database under constant low-grade load.

The first three are caught by the per-request count and the test budget. The fourth needs the same counting applied per job run, which is the counting shown in telemetry from serverless and batch Python applied to database statements.

Making lazy loading loud during development

The measurements above detect the pattern after it exists. The cheapest prevention stops it being written in the first place, and SQLAlchemy offers a direct mechanism.

Setting the default loader strategy to raise on lazy access means that any code touching an unloaded relationship fails immediately with an error naming the relationship, rather than silently issuing a query. In development and in tests, that turns every potential N+1 into a visible exception at the moment it is written. The developer then chooses deliberately: eager-load the relationship in the query that needs it, or explicitly allow the lazy load where a single access is genuinely intended.

from sqlalchemy.orm import raiseload

# development and test: any unplanned lazy load is an error, not a query
stmt = select(Order).options(raiseload("*"))

This is too strict for production, where an unexpected lazy load is better served slowly than not at all, and it is exactly right for the environments where code is written and reviewed. Combined with the query budget tests from step 5, it closes the loop: new code cannot introduce a lazy load without noticing, and existing endpoints cannot regress without failing a test.

The same principle — make the expensive thing loud where it is cheap to fix — applies well beyond ORMs. Any access pattern whose cost scales with data volume is worth surfacing in development rather than discovering through a customer with unusually large data.

Query count for one page as data grows A bar chart of database queries issued to render one order-list page as the number of orders shown grows, with lazy loading of each order's customer. With 10 orders the page issues 11 queries. With 50 orders, 51 queries. With 200 orders, 201 queries. With eager loading using a join or a select-in load, the page issues 2 queries at every size. The note says the query count tracking the row count is the signature of the pattern, and a span-count check in tests catches it before the data grows. queries to render one page (lazy loading unless noted) 10 orders 11 queries 50 orders 51 queries 200 orders 201 queries 200 orders, eager load 2 queries a query count that tracks the row count is the signature assert on the number of database spans in a test to catch it early
Lazy loading makes query count grow with the data. Eager loading keeps it constant, whatever the page size.

Configuration options

Mechanism Detects When
Queries per request histogram endpoints issuing many queries continuously
Most repeated statement attribute which loop per trace
Suspected-pattern flag on span traces worth opening per trace
Per-route regression alert a deploy that introduced it after each deploy
Query budget test reintroduction at review time
raiseload in development any lazy load at all during development

Verification

Confirm the fix by checking that the query count no longer scales with the data.

for n in (10, 100, 1000):
    make_orders(count=n, items_each=4)
    with count_queries() as c:
        client.get("/orders")
    print(f"{n:5d} orders -> {c.n} queries")

Expected Output: a constant count after the fix, where before it was N+1.

   10 orders -> 2 queries
  100 orders -> 2 queries
 1000 orders -> 2 queries

Common mistakes

Looking for it in the slow query log. Error signature: an empty slow query log on a slow endpoint. Root cause: each individual query is fast. Remediation: count queries per request.

Testing with too few records. Error signature: a query budget test that passes with the pattern present. Root cause: with two or three records the linear and constant cases barely differ. Remediation: use at least ten related records.

A global query count threshold. Error signature: an alert that fires constantly for complex endpoints and never for simple ones. Root cause: legitimate query counts vary by route. Remediation: alert per route, relative to its own history.

Fixing one access and missing another. Error signature: a query count that drops but still scales with the data. Root cause: a second relationship accessed in the same loop. Remediation: re-run the scaling check after each fix until the count is constant.

Eager loading everything. Error signature: list endpoints that got slower after a blanket change. Root cause: loading relationships nobody displays. Remediation: eager-load only what the endpoint actually uses.

Frequently Asked Questions

What exactly is an N+1 query?

A pattern where code fetches a list with one query and then issues one additional query for each item — usually by accessing a lazily loaded relationship inside a loop. For N items it issues N+1 queries where one or two would do.

Why is it hard to spot?

Because every individual query is fast. Per-query dashboards, slow query logs and database statistics all show healthy numbers. The problem is only visible at the level of the request, where hundreds of fast queries add up.

Does the count always scale with data?

That is its defining property and the reason it is dangerous. An endpoint that issues eleven queries in development with ten test records issues ten thousand and one in production for a customer with ten thousand. It gets worse exactly as the business grows.

How do I prevent it from coming back?

Assert on query counts in tests for the endpoints that matter, using a representative number of related records. A test that fails when the count scales with the data catches the pattern at review time.