The GraphQL N+1 Problem in Python and How to Fix It

GraphQL gives clients the power to request exactly the data they need, but that flexibility comes with a hidden cost. When your resolvers fetch related data one record at a time, a single query can silently explode into hundreds of database calls. This is the N+1 problem, and it is one of the sneakiest performance traps in any Python GraphQL API.

In a REST API, the server controls the shape of every response. If a particular endpoint is slow, you know exactly which query to optimize. GraphQL flips this dynamic. The client constructs the query, and the server resolves each field independently. That independence is powerful for decoupling your code, but it also means that a single nested query can trigger a cascade of redundant database hits without any obvious warning.

This article walks through the problem, shows what it looks like inside a Python GraphQL server, and then demonstrates the standard fix: the DataLoader pattern. Code examples cover both Graphene (with Django) and Strawberry, the two major Python GraphQL libraries.

What Is the N+1 Problem?

The name describes the math. You make 1 query to fetch a list of parent records, and then N additional queries to fetch related data for each record in that list. If the list contains 100 items, you end up with 101 database round-trips instead of the 2 that a well-optimized system would need.

Consider a blog application. You have Author objects and each author has written several Book objects. A client sends this query:

# GraphQL query
{
  authors {
    name
    books {
      title
    }
  }
}

On the server, the execution engine first resolves the authors field with a single database query. It gets back, say, 50 authors. Then for each of those 50 authors, the books resolver fires individually, producing 50 separate queries like SELECT * FROM books WHERE author_id = 1, SELECT * FROM books WHERE author_id = 2, and so on.

The total: 1 + 50 = 51 queries. Replace 50 with 500 or 5,000 and you can see how this becomes a serious bottleneck.

Why This Is Easy to Miss

The N+1 problem does not raise exceptions. Your API returns correct data. It just does so painfully slowly as the dataset grows. Without query logging or profiling enabled, you might not notice it until users start complaining about response times.

Seeing It in Python Code

To make this concrete, here is a simplified Graphene-Django setup. The models are straightforward:

# models.py
from django.db import models

class Author(models.Model):
    name = models.CharField(max_length=255)

    def __str__(self):
        return self.name

class Book(models.Model):
    title = models.CharField(max_length=255)
    author = models.ForeignKey(
        Author,
        related_name="books",
        on_delete=models.CASCADE,
    )

    def __str__(self):
        return self.title

And the GraphQL types look like this:

# schema.py
import graphene
from graphene_django import DjangoObjectType
from .models import Author, Book

class BookType(DjangoObjectType):
    class Meta:
        model = Book

class AuthorType(DjangoObjectType):
    class Meta:
        model = Author

class Query(graphene.ObjectType):
    authors = graphene.List(AuthorType)

    def resolve_authors(self, info):
        return Author.objects.all()

schema = graphene.Schema(query=Query)

When the client queries authors { name books { title } }, Django's ORM will execute the following SQL under the hood:

# Query 1: fetch all authors
SELECT * FROM myapp_author;

# Queries 2 through N+1: fetch books per author
SELECT * FROM myapp_book WHERE author_id = 1;
SELECT * FROM myapp_book WHERE author_id = 2;
SELECT * FROM myapp_book WHERE author_id = 3;
...
SELECT * FROM myapp_book WHERE author_id = N;
Pro Tip

Enable Django's django-debug-toolbar or set settings.DEBUG = True and inspect django.db.connection.queries to see every SQL statement your resolvers generate. This is the fastest way to confirm whether N+1 is happening.

Each of those per-author queries is individually fast, but the accumulated overhead of opening connections, serializing results, and transferring data across the network adds up quickly. In real-world tests with Django and Graphene, fetching 500 authors with their related articles via naive resolvers can take over 2 seconds, compared to under 100 milliseconds when the queries are properly batched.

The DataLoader Pattern

The DataLoader is the standard solution to the N+1 problem across the GraphQL ecosystem. Originally created by Facebook's engineering team for their JavaScript GraphQL server, the concept has been ported to every major language, including Python.

A DataLoader sits between your resolvers and your data source. Instead of each resolver immediately fetching its own data, the resolver asks the DataLoader for a value by key. The DataLoader collects all of these individual requests within a single tick of the event loop and then executes one batched query to satisfy them all.

The process works in three steps. First, each resolver calls loader.load(key) and receives a promise (or awaitable) back. Second, the DataLoader waits until all resolvers in the current execution frame have registered their keys. Third, it calls a batch function you define, passing in every collected key at once. Your batch function runs a single query like SELECT * FROM books WHERE author_id IN (1, 2, 3, ...) and returns the results in the same order as the keys.

This transforms N+1 queries into exactly 2 queries: one for the parent list and one batched query for all related records.

DataLoader Rules

Your batch function must follow two constraints: the returned list must be the same length as the input list of keys, and each position in the returned list must correspond to the same position in the keys list. If a key has no matching data, return None (or an empty list for one-to-many relationships) at that position.

DataLoaders in Graphene-Django

Graphene works with the aiodataloader package (for async) or the promise library's built-in DataLoader (for sync). Here is a complete example using the promise-based approach, which is the more common pattern in Django projects:

# loaders.py
from collections import defaultdict
from promise import Promise
from promise.dataloader import DataLoader
from .models import Book

class BooksByAuthorLoader(DataLoader):
    def batch_load_fn(self, author_ids):
        # One query for all authors
        books_by_author = defaultdict(list)
        for book in Book.objects.filter(
            author_id__in=author_ids
        ).iterator():
            books_by_author[book.author_id].append(book)

        # Return results in the same order as keys
        return Promise.resolve([
            books_by_author.get(author_id, [])
            for author_id in author_ids
        ])

The batch_load_fn receives a list of author IDs, runs a single WHERE author_id IN (...) query, groups the results into a dictionary, and returns them matched to the original key order.

Next, you need to make the loader available to your resolvers. The recommended approach is to create a fresh DataLoader instance per request by attaching it to the GraphQL context:

# views.py
from graphene_django.views import GraphQLView
from .loaders import BooksByAuthorLoader

class CustomGraphQLView(GraphQLView):
    def get_context(self, request):
        context = request
        context.loaders = {
            "books_by_author": BooksByAuthorLoader(),
        }
        return context

Finally, update your AuthorType to use the loader instead of letting Django lazily fetch books:

# schema.py
class AuthorType(DjangoObjectType):
    books = graphene.List(BookType)

    class Meta:
        model = Author

    def resolve_books(self, info):
        return info.context.loaders[
            "books_by_author"
        ].load(self.id)

Now when a client queries 50 authors with their books, the server executes exactly 2 database queries instead of 51. The first query fetches all authors. The DataLoader collects all 50 self.id values and fires a single batched query for every book belonging to those authors.

Pro Tip

Always create a new DataLoader instance per request. DataLoaders cache their results, which is helpful within a single request to avoid duplicate fetches. But sharing a DataLoader across requests means stale data and potential data leaks between users with different permissions.

DataLoaders in Strawberry

Strawberry, the modern Python GraphQL library built on type annotations, includes a built-in DataLoader class. Because Strawberry is async-first, the DataLoader integrates naturally with async/await syntax.

Here is the same author-books example in Strawberry:

# schema.py
from typing import List
import strawberry
from strawberry.dataloader import DataLoader

# Simulated database
AUTHORS_DB = {
    1: {"name": "Octavia Butler"},
    2: {"name": "Ursula Le Guin"},
    3: {"name": "N.K. Jemisin"},
}

BOOKS_DB = [
    {"title": "Kindred", "author_id": 1},
    {"title": "Parable of the Sower", "author_id": 1},
    {"title": "The Left Hand of Darkness", "author_id": 2},
    {"title": "The Dispossessed", "author_id": 2},
    {"title": "The Fifth Season", "author_id": 3},
]


async def load_books_by_author(
    keys: List[int],
) -> List[List["Book"]]:
    """Batch function: one call for all author IDs."""
    result = {key: [] for key in keys}
    for book in BOOKS_DB:
        if book["author_id"] in result:
            result[book["author_id"]].append(
                Book(title=book["title"])
            )
    return [result[key] for key in keys]


@strawberry.type
class Book:
    title: str


@strawberry.type
class Author:
    id: int
    name: str

    @strawberry.field
    async def books(self, info: strawberry.Info) -> List[Book]:
        return await info.context[
            "books_loader"
        ].load(self.id)

The context setup happens in your ASGI application. When using Strawberry with FastAPI, it looks like this:

# app.py
from typing import Any, Union, Optional
from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter
from strawberry.dataloader import DataLoader
from starlette.requests import Request
from starlette.websockets import WebSocket
from starlette.responses import Response

async def get_context(
    request: Union[Request, WebSocket],
    response: Optional[Response] = None,
) -> dict:
    return {
        "books_loader": DataLoader(
            load_fn=load_books_by_author
        ),
    }

schema = strawberry.Schema(query=Query)
graphql_router = GraphQLRouter(
    schema,
    context_getter=get_context,
)

app = FastAPI()
app.include_router(graphql_router, prefix="/graphql")

The pattern is identical to the Graphene approach. A fresh DataLoader is created per request inside the context getter. Resolvers call loader.load(key) and the DataLoader batches everything into a single call to load_books_by_author.

Strawberry's DataLoader also supports per-request caching by default. If multiple fields in the same query request the same author's books, the loader returns the cached result without hitting the batch function again.

Additional Prevention Strategies

While DataLoaders are the primary solution, there are several complementary techniques that help keep your Python GraphQL API performing well.

Django's select_related and prefetch_related

If you are using Graphene-Django, you can optimize your root resolvers with Django's built-in query optimization. Using prefetch_related("books") on the authors queryset tells Django to fetch all related books in a second query up front, sidestepping the N+1 pattern at the ORM level before GraphQL resolvers even fire:

class Query(graphene.ObjectType):
    authors = graphene.List(AuthorType)

    def resolve_authors(self, info):
        return Author.objects.prefetch_related("books").all()

This approach works well for simple, predictable queries. However, it becomes less useful when clients can request arbitrary nesting depths, because you cannot always predict which relationships to prefetch.

Query Depth Limiting

Restricting the maximum nesting depth of incoming queries prevents clients from constructing pathologically expensive requests. Libraries like graphene and strawberry both support middleware or extensions that reject queries exceeding a configured depth. A maximum depth of 5 to 7 levels is a reasonable starting point for many applications.

Query Complexity Analysis

Beyond depth, you can assign a cost to each field and reject queries whose total cost exceeds a threshold. A field returning a list of 1,000 items costs more than a scalar string field. Complexity analysis gives you fine-grained control over how much work a single request can demand from your server.

Caching at the DataLoader Level

DataLoaders cache results within a single request by default. For data that changes infrequently, you can implement a custom cache that persists across requests. Strawberry's DataLoader accepts a cache_map parameter where you can plug in your own cache implementation backed by Redis, Memcached, or any other store:

from strawberry.dataloader import DataLoader, AbstractCache

class RedisCache(AbstractCache):
    def __init__(self, redis_client):
        self.client = redis_client

    def get(self, key):
        val = self.client.get(f"loader:{key}")
        return val if val else None

    def set(self, key, value):
        self.client.set(
            f"loader:{key}", value, ex=300
        )

    def delete(self, key):
        self.client.delete(f"loader:{key}")

    def clear(self):
        # Clear all loader keys
        for key in self.client.scan_iter("loader:*"):
            self.client.delete(key)

Key Takeaways

  1. The N+1 problem is silent: Your API returns correct data while generating hundreds of unnecessary database queries behind the scenes. Always profile your resolvers during development.
  2. DataLoaders are the standard fix: They batch individual resolver requests into a single query. Both Graphene (via aiodataloader or promise.dataloader) and Strawberry (via its built-in DataLoader) support this pattern.
  3. Create loaders per request: Attach fresh DataLoader instances to the GraphQL context for each incoming request to prevent stale data and permission leaks between users.
  4. Batch functions have strict rules: The returned list must match the length and order of the input keys. Violating this causes silent data mismatches that are difficult to debug.
  5. Layer your defenses: Combine DataLoaders with Django's prefetch_related, query depth limits, and complexity analysis for a well-rounded performance strategy.

The N+1 problem is one of those issues that rarely shows up during early development when your database has a handful of test records. It emerges at scale, when real users are waiting for responses and your database connection pool is saturated. Adding DataLoaders early in your project is a low-effort investment that prevents a significant class of performance regressions before they have a chance to reach production.

back to articles