Company
About Tomosu
Platform
Platform & Agents Indexes How it works Solutions Pricing
Get Started
MCP Server VS Code — Plugin Installation Scan Your Repo — Guide Integrations · GitHub App Integrations · CodeRabbit MCP FAQ
Free Tools
Governance Impact
Resources
Blogs News Download / Free Trial Book a call →
Production Debugging · Database queries

N+1 Queries That Pass Tests but Fail Under Production Traffic

Tomosu AI·13 min read·

The endpoint has tests. They pass in CI, they pass locally, and the pull request is approved. In production the same endpoint turns into hundreds of database queries per request, and at peak traffic it takes the connection pool and the database with it. Nothing about the code changed between the two. The data and the traffic did.

Quick answer

N+1 queries pass tests because test fixtures have one or two parent rows, the database is local, and each test sends one request, so the extra queries are few and fast. Tests check the response, not how many queries produced it. Production multiplies the query count by page size and again by traffic.

An N+1 query is a pattern where code runs one query to load a list of N items, then one more query per item to load related data, for 1 + N queries where one or two would do. The latency arithmetic, why round trips that are free on localhost cost real time in production, is covered in Why an API Is Slow in Production but Fast Locally. This post is about the other two questions: why the test suite never notices, and why the pattern gets dramatically worse under traffic rather than just slower per request.

Why do N+1 queries pass tests?

They pass because every property of a test environment shrinks the cost of the pattern toward zero, and because tests assert on results, not on the work done to produce them. An N+1 returns exactly the right data. It just asks for it one row at a time.

Test environmentEffect on an N+1In production
Fixtures with 1–3 parent rows1 + N is 2 to 4 queriesA page of 50, or a job over 50,000: 51 to 50,001 queries
Database on localhost or in memoryEach round trip costs microsecondsEach round trip crosses a network, often another zone
One request at a timeNo contention for connections or CPUHundreds of requests share the pool and the database
Tiny tables, fully cachedEvery per-item query is a cache hitPer-item lookups miss cache and hit disk
Assertions on the response bodyCorrect data, so the test passesCorrect data, delivered too slowly or not at all
SAME CODE, THREE SCALES IN THE TESTONE PROD REQUESTPROD TRAFFIC N+1 Batched 1 order in fixture 2 queries page of 50 orders 51 queries 200 requests/s 10,200 queries/s 1 order in fixture 2 queries page of 50 orders 2 queries 200 requests/s 400 queries/s × N× rps × N× rps The test cannot tell the two rows apart: both run 2 queries. The difference only appears when N and traffic grow.
With one parent row, an N+1 and a batched query issue the same number of queries. That is why a single-row fixture can never catch it. Illustrative numbers.

That last row of the figure is the key point for testing. With N = 1, an N+1 is indistinguishable from correct code. Most fixtures, factories, and seed files create one parent with a couple of children because that is enough to test the behaviour. It is exactly not enough to test the query pattern.

Why does an N+1 fail under traffic, not just run slower?

A single slow request is a latency problem. Under traffic, an N+1 turns into three capacity problems that feed each other. That is why it can look fine in a staging smoke test, pass a manual check in production at 9 a.m., and fall over at the daily peak.

1. Query volume multiplies twice

The database sees requests per second × (1 + N) statements. Each statement carries fixed overhead that has nothing to do with how much data it returns: a network round trip, parsing or plan lookup, executor startup, and a result message. Fifty tiny queries cost the database far more than one query returning fifty rows. At 200 requests per second, the difference between 400 and 10,200 statements per second is often the difference between a quiet database and one pinned on CPU.

2. Connections are held for the whole loop

The request usually holds one pooled connection, or one transaction, from the first query to the last. If the loop takes 60 ms instead of 3 ms, each request holds its connection 20 times longer, and the pool needs about 20 times as many connections for the same traffic. When it does not have them, callers queue and time out. The error looks like pool exhaustion, and telling it apart from a connection leak is the first step of that investigation.

3. The tail gets much worse than the average

Every query has some small chance of being slow: a lock wait, a cache miss that goes to disk, a checkpoint, a noisy neighbour, a garbage-collection pause in the driver. A request that issues one query is exposed to that chance once. A request that issues 51 queries in sequence is exposed 51 times.

CHANCE A REQUEST HITS AT LEAST ONE SLOW QUERY 0%10%20%30%40% 1%3%10.5%40% 1 query31151 queries per request ASSUMPTION Each query has a 1% chance of being slow, independently. P(REQUEST HITS ONE) 1 − 0.99^n At n = 51, about 4 in 10 requests include a slow query.
Sequential queries compound tail latency. A rare slow query becomes a common slow request. Illustrative probabilities.

Put the three together and the failure mode is a cliff, not a slope. Higher traffic means more queries per second, which makes each query a little slower, which makes each request hold its connection longer, which leaves fewer connections for the next request. Retries from clients add more requests at exactly the wrong moment (the retry storm pattern). The N+1 was there all along; the traffic peak is when it becomes the incident.

Caches hide N+1 queries until they are cold

If per-item lookups go through an application cache or an ORM second-level cache, a warm cache absorbs most of the N queries and the pattern looks harmless. After a deploy, a cache flush, or an eviction burst, all of them go to the database at once. The N+1 shows up as a database spike that “started after the release”, even when the release did not touch the query. The cache stampede post covers the cold-cache side of that failure.

Where do N+1 queries hide in the code?

The classic example is an explicit for loop with a query inside it. Reviewers catch those. The ones that reach production are usually split across files: the list is loaded in one place, and the per-item query is triggered somewhere that does not look like data access at all.

THE LIST QUERY IS HERE; THE PER-ITEM QUERY IS SOMEWHERE ELSE VIEW / REPOSITORY Order.objects   .all()[:50] 1 query, looks fine 50 × Serializer fieldsource="customer.name" Template{{ order.customer.name }} GraphQL resolverOrder.customer(parent) Model propertyorder.total: sum(self.items) Per-item HTTP callpricing.get(item.sku) Callback or hookafter_save: audit per row Each layer: 1 query or call per item
The diff that adds a serializer field or a template line contains no query at all. The query happens when the field is read for every row.

Here is the version that most often ships, in Django REST Framework. The view looks efficient, and the serializer looks like pure presentation:

orders/api.py2N + 2 queries per page
class OrderSerializer(serializers.ModelSerializer):
    # lazy-loads the customer once per order
    customer_name = serializers.CharField(source="customer.name")
    item_count = serializers.SerializerMethodField()

    def get_item_count(self, order):
        return order.items.count()   # a COUNT query per order

    class Meta:
        model = Order
        fields = ["id", "created_at", "customer_name", "item_count"]

class OrderList(generics.ListAPIView):
    queryset = Order.objects.order_by("-created_at")
    serializer_class = OrderSerializer
    pagination_class = PageSize50
orders/api.pyconstant: 2 queries per page
class OrderSerializer(serializers.ModelSerializer):
    customer_name = serializers.CharField(source="customer.name")
    item_count = serializers.IntegerField(read_only=True)   # annotated

    class Meta:
        model = Order
        fields = ["id", "created_at", "customer_name", "item_count"]

class OrderList(generics.ListAPIView):
    queryset = (Order.objects
                .select_related("customer")             # JOIN
                .annotate(item_count=Count("items"))     # SQL COUNT
                .order_by("-created_at"))
    serializer_class = OrderSerializer
    pagination_class = PageSize50

The two queries left are the pagination COUNT and the page itself. Note that the fix touched the view, not the serializer where the problem was introduced. That split is why the pattern survives review: the file that causes the N+1 and the file that can prevent it are different files.

How do you make tests catch N+1 queries?

Two techniques work across frameworks. The first makes the test fail on the shape of the data access. The second makes lazy loading impossible in tests so the code has to declare what it loads.

Technique 1: assert the query count does not grow with the data

An absolute count (“this endpoint runs 4 queries”) is brittle, since authentication, sessions, and middleware add queries that change for unrelated reasons. A scaling test is sturdier: run the same request with a small fixture and a larger one, and assert that the count is the same.

QUERIES PER REQUEST VS ROWS IN THE FIXTURE 05101520 N+1: 1 + N batched: 2 151020 parent rows in the fixture THE SCALING TEST count(rows=20)   == count(rows=1) N+1: 21 vs 2, test fails. Batched: 2 vs 2, passes. Middleware and auth queries appear on both sides and cancel.
At one row the two lines meet. Any test that uses a single-row fixture sits exactly on that point.
tests/test_orders_api.py · pytest + Djangofails on N+1
from django.db import connection
from django.test.utils import CaptureQueriesContext

def queries_for(client, url):
    with CaptureQueriesContext(connection) as ctx:
        assert client.get(url).status_code == 200
    return len(ctx.captured_queries)

def test_order_list_query_count_is_constant(api_client, make_orders):
    make_orders(count=1, items_per_order=3)
    one = queries_for(api_client, "/api/orders/")

    make_orders(count=19, items_per_order=3)       # 20 in total, still one page
    many = queries_for(api_client, "/api/orders/")

    assert many == one, f"query count grew from {one} to {many}"

The same idea works elsewhere. With Hibernate, enable hibernate.generate_statistics in the test profile and compare getPrepareStatementCount() from the SessionFactory statistics before and after the call. With SQLAlchemy, count statements with a before_cursor_execute event listener on the engine. Django also offers assertNumQueries and pytest-django’s django_assert_num_queries when an exact count is stable enough to pin, and recent Rails versions include query-count assertions in Active Record’s test helpers.

Technique 2: make lazy loading raise in tests

Most N+1 queries are lazy loads: a relation that was not loaded up front gets loaded on first access. If lazy loading raises an error in tests, every such access fails immediately, with a stack trace pointing at the line that caused it, even with a one-row fixture.

StackLoad related data up frontMake lazy loads fail in tests
Djangoselect_related (JOIN, to-one), prefetch_related (second query, to-many), annotate for aggregatesNo built-in switch; use scaling tests, or a third-party package such as nplusone
Railsincludes, preload, eager_loadstrict_loading per query or model; config.active_record.strict_loading_by_default = true in the test environment; the Bullet gem to report N+1s
SQLAlchemy 2.xselectinload, joinedload, subqueryloadraiseload("*") as a query option, or lazy="raise" on relationships
JPA / HibernateJOIN FETCH, @EntityGraph, @BatchSize / hibernate.default_batch_fetch_sizeNo raise mode; assert statement counts via Hibernate statistics
Laravel Eloquentwith(), load()Model::preventLazyLoading() outside production
GraphQL (any server)DataLoader-style batching per requestScaling test on a list query with nested fields
Rails · SQLAlchemylazy loads raise
# Rails: config/environments/test.rb
config.active_record.strict_loading_by_default = true
# order.customer.name now raises ActiveRecord::StrictLoadingViolationError
# unless the query declared it:
Order.includes(:customer, :line_items).order(created_at: :desc).limit(50)

# SQLAlchemy 2.x: declare what is loaded, raise on everything else
stmt = (
    select(Order)
    .options(joinedload(Order.customer), selectinload(Order.items), raiseload("*"))
    .order_by(Order.created_at.desc())
    .limit(50)
)

Strict loading in the test environment is the cheaper of the two techniques to adopt. It will surface a backlog of existing lazy loads the day you turn it on, so many teams enable it per model or per new query first and widen it over time.

With one parent row, an N+1 is indistinguishable from correct code. A test has to grow the data, or forbid the lazy load, to see it.

How do you find N+1 queries already in production?

In production, an N+1 has a recognizable signature in every telemetry source: many near-identical statements per request. You are looking for a ratio, not a slow query. Each individual query is usually fast, which is why slow-query logs miss it.

SourceN+1 signatureWhat to do with it
Distributed tracesOne request span with dozens of short, identical database spans in sequenceThe parent span names the endpoint; the repeated span names the statement
pg_stat_statementsA cheap statement near the top by calls, with calls many times the endpoint’s request countDivide calls by requests over the same window; a ratio near the page size is an N+1
Slow query logUsually nothing; each query is fastDo not treat a quiet slow log as evidence of no problem
Database metricsStatements per second and CPU rise faster than request rateLook for the endpoint whose traffic share matches the rise
Connection pool metricsConnection hold time rises with page size or tenant sizeCorrelate with the endpoints in the traces
PostgreSQL · pg_stat_statements (PG 13+ column names)
SELECT calls,
       round(mean_exec_time::numeric, 2) AS mean_ms,
       round(total_exec_time::numeric)    AS total_ms,
       left(query, 90)                    AS statement
FROM   pg_stat_statements
ORDER BY calls DESC
LIMIT  15;
-- An N+1 looks like: very high calls, sub-millisecond mean_ms,
-- and a statement such as SELECT ... FROM customers WHERE id = $1

The pg_stat_statements documentation describes the columns. If you already trace with OpenTelemetry, the database spans carry the statement text, which makes the repeated statement easy to group; the OpenTelemetry database semantic conventions list the attributes. When you find one, correlating the trace with the code change usually points at a serializer, template, or resolver change rather than the query code.

How do you fix an N+1 without over-fetching?

The fix is always to replace N per-item queries with a constant number of queries. The choice is between joining and batching, and the wrong choice can create a different problem.

What each item needsBest fixWatch out for
One related row (to-one), e.g. the order’s customerJOIN in the same query: select_related, joinedload, eager_load, JOIN FETCHSelecting wide columns you do not render
A collection (to-many), e.g. the order’s itemsA second batched query with IN (…): prefetch_related, selectinload, preload, batch fetchingJOINing several collections multiplies rows (a cartesian product)
Only a count or sumAggregate in SQL: annotate(Count(...)), GROUP BY, a counter columnLoading every child row just to call len()
Data from another serviceA batch endpoint or a per-request batcher (DataLoader)Unbounded batch sizes; chunk large lists
Paginated parents with collectionsPaginate the parents first, then batch-load their collectionsA collection fetch JOIN with LIMIT; some ORMs then paginate in memory
Hibernate specifics

Fetching two collections with JOIN FETCH in one query can throw MultipleBagFetchException for List collections, and combining a collection fetch with pagination makes Hibernate log a warning and apply the limit in memory after loading every row. Batch fetching (@BatchSize or hibernate.default_batch_fetch_size) avoids both by loading collections with IN queries. The HikariCP pool guide covers the connection side of slow JPA requests.

For GraphQL, field resolvers run once per parent object by design, so N+1 is the default behaviour rather than a mistake. The standard fix is a DataLoader-style batcher created per request: resolvers ask for a key, the loader collects every key requested in the same tick, and issues one query for all of them. The DataLoader project describes the pattern; most GraphQL servers have an equivalent.

After the fix, verify two things: the scaling test passes, and the batched version does not fetch far more data than the page renders. A fix that loads every item of every order to display a count is an N+1 traded for an over-fetch.

What should a reviewer look for in the diff?

Because the list query and the per-item access usually live in different files, reviewing for N+1 means reading the diff with one question: is this line executed once per item of a collection, and does it touch a relation or a remote call?

This is one item in a broader production review; How to Review a Pull Request for Production Reliability Risks has the full checklist, and the sibling guide on missing database indexes in a PR covers the other common data-size regression. For the broader question of why green CI does not predict production behaviour, see A PR Passed CI but Broke Production.

How Tomosu helps

The reason N+1 queries survive review is that the loop and the query are in different places. Tomosu analyzes the repository as a whole, and each pull request against it, for production reliability risks, so it can follow a collection from where it is loaded to where each item is used:

Findings feed the Production Reliability Index and appear on the pull request, where the fix is a one-line change to a fetch plan rather than an incident.

Scan your repository with Tomosu →

Key takeaways

Frequently asked questions

Why do N+1 queries pass tests?

Test fixtures usually create one or two parent rows with a few children, the database is local or in memory, and each test sends one request. An N+1 then issues a handful of fast queries and finishes in milliseconds. Tests check results, not how many queries produced them, so nothing fails until production data and traffic multiply the query count.

How can I detect N+1 queries in tests?

Assert that the query count does not grow with the data. Run the same request with 1 parent row and with 20, count queries each time with Django’s CaptureQueriesContext, Hibernate statistics, or a SQL event listener, and fail if the counts differ. Also make lazy loading raise in tests with Rails strict_loading or SQLAlchemy raiseload.

Why does an N+1 query fail under load but not for a single request?

Under traffic the extra queries multiply: 200 requests per second at 51 queries each is over 10,000 queries per second. Each request also holds a pool connection for the whole loop, and with 51 sequential queries the chance of hitting at least one slow query rises sharply, so tail latency and pool pressure grow together.

How do I find N+1 queries in production?

Look at traces for one request span containing dozens of near-identical database spans. In PostgreSQL, pg_stat_statements shows cheap statements whose call count is many times the endpoint’s request count. The slow query log usually shows nothing, because each individual query is fast.

Is eager loading always the right fix for N+1 queries?

No. Eager loading with a JOIN multiplies rows when it loads several collections, and fetching a collection with a JOIN breaks database-side pagination in some ORMs. A second batched query, such as prefetch_related, selectinload, preload, or batch fetching, is often safer for collections, and an aggregate in SQL is better when you only need a count.

What causes N+1 queries in GraphQL APIs?

Field resolvers run once per parent object, so a resolver that loads a related record issues one query per item in the list. The standard fix is a DataLoader-style batcher, created per request, that collects the keys requested in the same tick of execution and loads them with a single query.

Can a cache hide an N+1 query?

Yes. A warm application or ORM cache can serve most per-item lookups, so the N+1 looks harmless. After a deploy, a cache flush, or an eviction burst, every lookup goes to the database at once, and the N+1 appears as a sudden database spike after a release.


An N+1 is correct code at the wrong scale. Tests that grow the data, and reviews that follow each collection to where it is used, find it before traffic does. Assess your repository →