Python GraphQL DataLoader: Solving the N+1 Problem

GraphQL lets clients request exactly the data they need, but that precision hides a trap. Without batching, a single query that fetches a list of users and their associated posts can trigger hundreds of separate database calls behind the scenes. DataLoader is the established pattern for fixing this. It collects individual data requests within a single execution frame, batches them into one efficient call, and caches the results for the remainder of the request.

The DataLoader concept originated at Facebook around 2010 as part of their internal "Loader" API, which later became a core piece of the infrastructure behind their GraphQL server. The JavaScript reference implementation was open-sourced and has since been ported to Python through libraries like aiodataloader and Strawberry's built-in DataLoader. The core idea is simple: instead of letting each resolver independently fetch its own data, you collect all the keys that need fetching within a single execution tick and make one batched call for the entire set.

The N+1 Query Problem Explained

Consider a GraphQL schema with Band and Album types, where each band has multiple albums. A client sends this query:

# GraphQL query that triggers the N+1 problem
# {
#   bands {
#     name
#     albums {
#       title
#     }
#   }
# }

Without any optimization, the server executes one query to fetch all bands, then executes a separate query for each band to fetch its albums. If there are 100 bands, that is 101 database queries: 1 for the band list plus 100 individual album lookups. This is the N+1 problem. The "1" is the initial query, and "N" is the number of follow-up queries -- one for each result.

The issue is not immediately obvious because GraphQL resolvers are designed to be independent functions. Each resolver only knows about its own parent object and has no awareness that dozens of sibling resolvers are all about to ask for the same type of data. The resolver for Band.albums runs once per band and makes its own database call each time. From any single resolver's perspective, the code looks perfectly reasonable. The waste only becomes visible when you look at the total number of queries generated across all resolvers during a single request.

Note

The N+1 problem is not unique to GraphQL. It appears in any system where related data is fetched lazily, including ORM-backed REST APIs. GraphQL makes it more common because the nested query structure naturally encourages resolver-per-field patterns that trigger lazy loading.

How DataLoader Works

DataLoader solves the N+1 problem through two mechanisms: batching and caching.

Batching is the primary feature. When a resolver calls loader.load(key), the DataLoader does not immediately execute a database query. Instead, it records the requested key and returns a future (or awaitable) representing the eventual result. Within a single execution frame -- the time it takes for the current set of resolvers to finish running -- the DataLoader collects all requested keys. Once the frame completes, it calls your batch function once with the full list of collected keys. Your batch function performs a single query (like SELECT * FROM albums WHERE band_id IN (1, 2, 3, ...)) and returns all results at once.

Caching is the secondary feature. After a key has been loaded once during a request, the DataLoader memoizes the result. If a different resolver requests the same key later in the same request, the cached value is returned immediately without calling the batch function again. This is a per-request in-memory cache, not a shared application cache like Redis.

There are two strict rules that every batch function must follow. First, the returned list of values must be the same length as the list of keys passed in. Second, each index in the returned list must correspond to the same index in the input key list. If key index 0 is 42, then value index 0 must be the result for key 42. Getting this mapping wrong is one of the most common sources of DataLoader bugs.

DataLoader with Strawberry GraphQL

Strawberry ships with a built-in DataLoader class, so there is no need for external packages in async environments. Here is a complete example showing how to define a batch loading function and wire it into a resolver.

from typing import List
import strawberry
from strawberry.dataloader import DataLoader


# Simulated database of users
USERS_DB = {
    1: {"id": 1, "name": "Ada Lovelace", "team_id": 10},
    2: {"id": 2, "name": "Grace Hopper", "team_id": 10},
    3: {"id": 3, "name": "Alan Turing", "team_id": 20},
}


@strawberry.type
class User:
    id: int
    name: str
    team_id: int


async def batch_load_users(keys: List[int]) -> List[User]:
    """
    Batch function: receives ALL requested user IDs at once.
    Must return results in the same order as the input keys.
    """
    print(f"Batch loading users: {keys}")
    results = []
    for key in keys:
        data = USERS_DB.get(key)
        if data:
            results.append(User(**data))
        else:
            results.append(ValueError(f"User {key} not found"))
    return results


# Create the loader
user_loader = DataLoader(load_fn=batch_load_users)


@strawberry.type
class Query:
    @strawberry.field
    async def user(self, id: int) -> User:
        return await user_loader.load(id)

When a client sends a query that requests multiple users through field aliases, the DataLoader collects all the IDs and calls batch_load_users just once with the full list. A query like { a: user(id: 1) { name } b: user(id: 2) { name } } produces a single print statement: Batch loading users: [1, 2].

Notice the error handling pattern in the batch function. Instead of raising an exception when a key is not found, the function places a ValueError instance into the results list at the corresponding index. This allows the DataLoader to propagate errors on a per-key basis. A call to await loader.load(1) returns a User, while await loader.load(999) raises the ValueError. If the batch function itself raises an unhandled exception, every pending load() call in that batch will fail.

Pro Tip

Use load_many when you need to load several keys from a single resolver. Instead of calling loader.load() in a loop, call await loader.load_many([1, 2, 3]) to get all results in one batch.

Per-Request Loaders and the Context Pattern

The example above creates a user_loader at module level. This works for a one-off script, but it creates a serious problem in a server environment: the DataLoader's cache persists across requests. User data loaded during Request A stays cached when Request B arrives. This can serve stale data or, worse, leak data between users who should have different access levels.

The standard solution is to create a fresh DataLoader instance for every incoming request by attaching it to the GraphQL context. Here is how to set this up with Strawberry and an ASGI framework like FastAPI or Starlette:

from typing import List, Union, Any, Optional
import strawberry
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


@strawberry.type
class User:
    id: strawberry.ID
    name: str


async def batch_load_users(keys: List[int]) -> List[User]:
    # In production, query your database here:
    # rows = await db.fetch("SELECT * FROM users WHERE id = ANY($1)", keys)
    return [User(id=key, name=f"User {key}") for key in keys]


async def get_context(
    request: Request = None,
    ws: WebSocket = None,
) -> dict:
    return {
        "user_loader": DataLoader(load_fn=batch_load_users),
    }


@strawberry.type
class Query:
    @strawberry.field
    async def user(self, info: strawberry.types.Info, id: int) -> User:
        return await info.context["user_loader"].load(id)


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

Every request now gets its own user_loader instance with a clean cache. Within that single request, batching and caching work as expected. Once the request finishes, the loader and its cache are garbage collected. This is the recommended pattern in every Python GraphQL library, not just Strawberry.

Important

DataLoader's built-in cache is not a replacement for Redis, Memcached, or any other shared application cache. It exists solely to prevent redundant fetches within a single request. For cross-request caching, use a dedicated caching layer in your batch function.

Foreign Key Relationships and Nested Loaders

Tutorials frequently demonstrate DataLoader by loading records by their primary key. That is useful, but the N+1 problem typically shows up when resolving relationships -- fetching all albums for a band, or all comments for a post. In these cases, the lookup key is a foreign key, and a single key can map to multiple results.

The trick is to have the batch function return a list of lists. Each position in the outer list corresponds to one input key, and its value is the list of related items for that key.

from typing import List
from collections import defaultdict
import strawberry
from strawberry.dataloader import DataLoader
from strawberry.types import Info


# Simulated data
ALBUMS_DB = [
    {"id": 1, "title": "OK Computer", "band_id": 10},
    {"id": 2, "title": "Kid A", "band_id": 10},
    {"id": 3, "title": "Nevermind", "band_id": 20},
    {"id": 4, "title": "In Utero", "band_id": 20},
    {"id": 5, "title": "Rumours", "band_id": 30},
]


@strawberry.type
class Album:
    id: int
    title: str
    band_id: int


async def batch_load_albums_by_band(
    band_ids: List[int],
) -> List[List[Album]]:
    """
    Given a list of band IDs, return a list of lists.
    Each inner list contains the albums for one band.
    Order must match the input band_ids.
    """
    # In production: SELECT * FROM albums WHERE band_id IN (...)
    albums_by_band = defaultdict(list)
    for album_data in ALBUMS_DB:
        if album_data["band_id"] in band_ids:
            albums_by_band[album_data["band_id"]].append(
                Album(**album_data)
            )

    # Return results in the same order as input keys
    return [albums_by_band.get(band_id, []) for band_id in band_ids]


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

    @strawberry.field
    async def albums(self, info: Info) -> List[Album]:
        loader = info.context["albums_by_band_loader"]
        return await loader.load(self.id)

The critical detail is in the last line of the batch function: [albums_by_band.get(band_id, []) for band_id in band_ids]. This guarantees that the output list has exactly the same length and order as the input list, even for band IDs that have no albums (they receive an empty list). Without this step, the DataLoader's key-to-value mapping breaks and results get assigned to the wrong parents.

Synchronous DataLoaders for Django

Django's ORM is synchronous by default. While Django has added async support over recent versions, many projects still run their GraphQL layer synchronously, especially those using Graphene-Django. The standard aiodataloader and Strawberry's built-in DataLoader both require an async context, which creates friction in synchronous Django setups.

The graphql-sync-dataloaders library solves this by providing a SyncDataLoader class and a custom DeferredExecutionContext that enables batching without asyncio. It works with both Graphene-Django and Strawberry.

# Install: pip install graphql-sync-dataloaders

from typing import List
from graphql_sync_dataloaders import SyncDataLoader
from myapp import models  # your Django models


def load_users(keys: List[int]) -> List:
    """Synchronous batch function using Django ORM."""
    qs = models.User.objects.filter(id__in=keys)
    user_map = {user.id: user for user in qs}
    return [user_map.get(key, None) for key in keys]


user_loader = SyncDataLoader(load_users)

To wire this into Strawberry with Django, set the execution_context_class on your schema:

import strawberry
from graphql_sync_dataloaders import DeferredExecutionContext

schema = strawberry.Schema(
    query=Query,
    execution_context_class=DeferredExecutionContext,
)

The DeferredExecutionContext replaces the standard execution engine with one that understands SyncFuture objects -- a synchronous equivalent of asyncio futures. When a resolver returns a SyncFuture from loader.load(), the execution engine collects them, triggers the batch function, resolves the futures, and continues execution. The entire process happens synchronously, with no event loop required.

For Graphene-Django, the setup is similar. Pass DeferredExecutionContext as the execution_context_class to your GraphQLView:

# urls.py
from django.urls import path
from graphene_django.views import GraphQLView
from graphql_sync_dataloaders import DeferredExecutionContext
from .schema import schema

urlpatterns = [
    path(
        "graphql",
        GraphQLView.as_view(
            schema=schema,
            execution_context_class=DeferredExecutionContext,
        ),
    ),
]

Common Pitfalls

Mismatched Return Length

The batch function must return exactly as many values as keys it received, in the same order. If your database query returns fewer rows than keys (because some keys are invalid), you need to fill in the gaps. The user_map.get(key, None) pattern shown earlier handles this correctly. Returning a shorter list causes the DataLoader to silently misalign results, producing subtle bugs that are hard to trace.

Module-Level Loader Instances in Servers

Creating a DataLoader at module level means it lives for the entire lifetime of your server process. Its cache never clears between requests. This leads to stale data, memory leaks, and potential data leakage between users. Always create loaders inside the context factory so each request gets a fresh instance.

Forgetting to Use IN Queries

A batch function that still fetches one record at a time inside a loop defeats the purpose of DataLoader. The whole point is to turn N individual queries into a single WHERE id IN (...) query. If your batch function contains a for-loop that calls db.fetch_one() per key, you have not eliminated the N+1 problem -- you have just moved it inside the batch function.

Mixing Up Primary Key and Foreign Key Loaders

A primary-key loader maps one key to one result. A foreign-key loader maps one key to a list of results. Confusing the two leads to type errors or mismatched data. Be explicit about what each loader returns: List[User] for primary key lookups versus List[List[Album]] for foreign key relationships.

Pro Tip

Strawberry's DataLoader supports a cache_key_fn argument for cases where the key is not a simple scalar. If your keys are composite (like a tuple of (user_id, role)), provide a function that converts them to a hashable cache key so the memoization cache works correctly.

Key Takeaways

  1. DataLoader eliminates the N+1 problem through batching: Individual load() calls within a single execution frame are collected and resolved with one call to your batch function. This turns N+1 database queries into 2 (one for the parent list, one batched query for all related items).
  2. Batch functions have strict rules: The returned list must have the same length and order as the input key list. Break this contract and results silently get misaligned across resolvers.
  3. Always create loaders per-request: Attach DataLoader instances to the GraphQL context so each request gets a fresh cache. Module-level loaders cause stale data and memory issues in production.
  4. Foreign key loaders return lists of lists: When loading one-to-many relationships, each position in the result corresponds to one key and contains a list of related items. Use defaultdict(list) and a comprehension to maintain correct ordering.
  5. Synchronous Django projects have options: The graphql-sync-dataloaders library provides SyncDataLoader and DeferredExecutionContext for projects that cannot run async. It works with both Graphene-Django and Strawberry.
  6. DataLoader caching is per-request only: It prevents redundant fetches within a single request but does not replace Redis or Memcached for cross-request caching. Layer application-level caching inside your batch function when needed.

The N+1 problem is one of the first performance walls you will hit with a GraphQL API, and DataLoader is the standard tool for breaking through it. The pattern is consistent across Python libraries: define a batch function that fetches data by a list of keys, return results in the same order, and create a fresh loader per request. Whether you are using Strawberry with FastAPI, Graphene with Django, or any other combination, the mechanics are the same. Get the batch function right, wire it through the context, and your query count drops from N+1 to a predictable constant.

back to articles