Modern QA2026N+1 Query Detection — tiles
Log inJoin
42 / 89 · 04 API & Contract Testing with AI · GraphQL Query Depth and N+1 Detection← prev⊞ allnext →☰ Read as one page

7.3N+1 Query Detection

The Problem

The N+1 problem occurs when resolving a list of N items triggers N additional database queries instead of one batched query.

Query: users(first: 10) { friends { name } }

N+1 pattern (BAD):
  Query 1: SELECT * FROM users LIMIT 10     -- 1 query
  Query 2: SELECT * FROM friends WHERE user_id = 1  -- N queries
  Query 3: SELECT * FROM friends WHERE user_id = 2
  ...
  Query 11: SELECT * FROM friends WHERE user_id = 10
  Total: 11 queries

Batched pattern (GOOD):
  Query 1: SELECT * FROM users LIMIT 10
  Query 2: SELECT * FROM friends WHERE user_id IN (1,2,3,...,10)
  Total: 2 queries

Detecting N+1 in Tests

class TestGraphQLN1Detection:
    """Detect N+1 query patterns in GraphQL resolvers."""

    def test_user_friends_not_n_plus_1(self, graphql_client, query_logger):
        """Fetching users with friends should batch, not N+1."""
        query = """
        query {
          users(first: 10) {
            id
            name
            friends {
              id
              name
            }
          }
        }
        """
        query_logger.reset()
        response = graphql_client.execute(query)

        # Count database queries that were executed
        db_queries = query_logger.get_queries()
        user_count = len(response["data"]["users"])

        # N+1 would be: 1 (list users) + N (fetch friends per user)
        # Correct batching: 1 (list users) + 1 (batch friends)
        assert len(db_queries) <= 3, (
            f"Expected <= 3 DB queries (batched), got {len(db_queries)} "
            f"for {user_count} users. Likely N+1 pattern:\n"
            + "\n".join(q.sql for q in db_queries)
        )

    def test_nested_resolution_performance(self, graphql_client):
        """Complex nested query should complete within SLA."""
        query = """
        query {
          products(first: 20) {
            id
            name
            category { name }
            reviews(first: 5) {
              rating
              author { name }
            }
          }
        }
        """
        import time
        start = time.monotonic()
        response = graphql_client.execute(query)
        duration = time.monotonic() - start

        assert duration < 2.0, (
            f"Nested query took {duration:.2f}s, SLA is 2.0s. "
            f"Possible N+1 in reviews or author resolution."
        )

    def test_dataloader_batching(self, graphql_client, query_logger):
        """Verify DataLoader batches related entity lookups."""
        query = """
        query {
          orders(first: 20) {
            id
            customer {
              id
              name
            }
            items {
              product {
                id
                name
              }
            }
          }
        }
        """
        query_logger.reset()
        response = graphql_client.execute(query)
        db_queries = query_logger.get_queries()

        # With DataLoader: ~4 queries (orders, customers, items, products)
        # Without DataLoader: 1 + 20 + 20*items + ... queries
        assert len(db_queries) <= 6, (
            f"Expected <= 6 DB queries with DataLoader, got {len(db_queries)}. "
            f"Verify DataLoader is configured for customer and product resolvers."
        )

Setting Up Query Logging

# conftest.py
import logging

class QueryLogger:
    """Capture database queries for N+1 detection."""

    def __init__(self):
        self.queries = []
        self._handler = None

    def start(self):
        """Start capturing SQL queries."""
        self._handler = logging.getLogger('sqlalchemy.engine').addHandler(
            self._capture_handler()
        )

    def reset(self):
        self.queries = []

    def get_queries(self):
        return self.queries

    def _capture_handler(self):
        class CaptureHandler(logging.Handler):
            def __init__(self, logger):
                super().__init__()
                self.logger = logger

            def emit(self, record):
                if "SELECT" in record.getMessage():
                    self.logger.queries.append(
                        CapturedQuery(sql=record.getMessage(), timestamp=time.time())
                    )
        return CaptureHandler(self)

@pytest.fixture
def query_logger():
    logger = QueryLogger()
    logger.start()
    return logger