Skip to content

Batching GraphQL Resolvers with DataLoader

GraphQL lets a client ask for users and, for each user, their posts — and a naive resolver runs one query for the users and then one more per user: the N+1 problem. Async resolvers change its shape without removing it: the per-user queries run concurrently, so latency grows more slowly, but the database still receives N+1 round trips. DataLoader fixes the count by collecting every key requested in the same tick and loading them with one query. Measured with Strawberry 0.329 and asyncpg against PostgreSQL 17 on Python 3.14, for a query returning 1,000 users and their 10,000 posts: without a loader the server issued 1,001 SQL queries; with one, 2. With the database on the same host the request went from 178–249 ms to 108–110 ms depending on pool size; with a simulated 5 ms round trip to the database it went from 619 ms to 128 ms. Even at zero latency, about 2.4 µs of Strawberry's own work per resolved field — 78 ms for those 32,000 fields — set a floor no loader can remove. This guide adds DataLoader batching and sizes what it buys.

Prerequisites

1. Count the queries a nested field causes

A resolver for User.posts that queries by its own user's ID runs once per user in the result:

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

    @strawberry.field
    async def posts(self, info: strawberry.Info) -> list[Post]:
        rows = await info.context.fetch(
            "SELECT id, title, likes FROM gql_post WHERE author_id = $1", self.id)
        return [Post(id=r["id"], title=r["title"], likes=r["likes"]) for r in rows]

Measured for { users(limit: N) { id name posts { id title likes } } }: 101 SQL queries for 100 users, 1,001 for 1,000. Because the resolvers are async, Strawberry starts all the posts resolvers together and they share the connection pool; with a pool of 10 connections the 1,001 queries took 178–202 ms locally, and with a pool of 1, 249 ms. The concurrency hides some of the latency but none of the load: the database parses, plans and executes a thousand statements where one would do. Counting queries per request — a counter in the context's fetch — is the simplest way to see it.

Verify: log the number of SQL statements per GraphQL request; a count that grows with the number of items returned is an N+1.

SQL queries for 1,000 users with their posts 2 horizontal bars comparing per-user resolver query with the others. SQL queries for 1,000 users with their posts per-user resolver query 1,001 queries DataLoader batch 2 queries Strawberry 0.329, asyncpg, PostgreSQL 17; 10,000 posts in the result. The loader collects every author ID requested in one tick into a single ANY($1) query.

2. Batch with a DataLoader per request

A DataLoader takes a batch function — keys in, results in the same order out — and coalesces every load(key) made before the event loop next runs into one call:

from collections import defaultdict
from strawberry.dataloader import DataLoader


class Context:
    def __init__(self, pool) -> None:
        self.pool = pool
        self.posts_by_author = DataLoader(load_fn=self._load_posts)    # one per request

    async def _load_posts(self, author_ids: list[int]) -> list[list[Post]]:
        rows = await self.fetch(
            "SELECT id, author_id, title, likes FROM gql_post WHERE author_id = ANY($1::int[])",
            list(author_ids))
        by_author = defaultdict(list)
        for r in rows:
            by_author[r["author_id"]].append(Post(id=r["id"], title=r["title"], likes=r["likes"]))
        return [by_author[a] for a in author_ids]      # same order as the keys, [] if none


@strawberry.type
class User:
    @strawberry.field
    async def posts(self, info: strawberry.Info) -> list[Post]:
        return await info.context.posts_by_author.load(self.id)

Measured: 2 SQL queries for 100 users or for 1,000. The batch function must return exactly one result per key, in key order — here an empty list for authors with no posts — or the loader raises. Creating the loader in the request's context, not at module level, keeps its cache scoped to one request; the reasons are measured in scoping DataLoaders per request.

Verify: the per-request SQL count stays constant as the number of returned users grows.

3. Measure what batching buys at your database latency

How much faster batching makes a request depends on the round-trip time to the database and the pool size. Measured for 1,000 users, with round-trip latency added on the client side to simulate a database across a network:

async def fetch(self, sql, *args):
    async with self.pool.acquire() as conn:
        await asyncio.sleep(RTT)                      # simulated network round trip
        return await conn.fetch(sql, *args)

At 0 ms the loader version took 108–110 ms against 178–249 ms without it; at 1 ms, 118–141 ms against 189–202 ms; at 5 ms with a pool of 10, 128 ms against 619 ms. The N+1 version's time scales with queries × RTT ÷ pool size; the loader's with the number of nesting levels. In production, where the database is rarely on the same host and pools are shared by many requests, the gap is usually closer to the 5 ms row — and the database load difference, a thousand statements against two, applies at every latency.

Verify: compare the request's database time (sum of query durations) with its wall time; with a loader they should be close to the number of nesting levels times one round trip.

1,000 users and their posts, by database round-trip time A grid of 3 rows by 4 columns. 1,000 users and their posts, by database round-trip time round trip pool without loader (1,001 queries) with loader (2 queries) 0 ms (same host) 1 to 50 178-249 ms 108-110 ms 1 ms (simulated) 10 to 50 189-202 ms 118-141 ms 5 ms (simulated) 10 619 ms 128 ms Best of 3 runs; Strawberry's per-field work, about 78 ms here, is the floor in every row.

4. Know the floor: per-field execution cost

Batching removes database round trips; it does not remove GraphQL execution. To isolate it, the same query was resolved from in-memory objects with no database:

DATA = [User(id=i, name=f"u{i}", posts=[Post(id=i * 10 + j, title=f"p{j}", likes=j)
                                        for j in range(10)]) for i in range(1000)]

# { users { id name posts { id title likes } } }  ->  32,000 fields

Measured: 77.6 ms with sync resolvers and 80.3 ms with an async root resolver — 2.4–2.5 µs per field. Every field in the response, even a plain attribute, passes through the executor. That is why the loader version stayed above 100 ms at zero database latency, and why the biggest lever for large responses is returning fewer fields: pagination, smaller default page sizes, or a dedicated non-GraphQL endpoint for bulk exports. Limits on what clients may request are covered in limiting GraphQL query depth and aliases.

Verify: for your largest typical response, fields × 2.5 µs is well within the latency budget; if not, paginate.

5. Batch every repeated lookup, not just lists

Any resolver that loads by key benefits — the author of each post, the price of each product, permission checks per object:

class Context:
    def __init__(self, pool) -> None:
        self.pool = pool
        self.users = DataLoader(load_fn=self._load_users)
        self.users_by_post = DataLoader(load_fn=self._load_post_authors)

    async def _load_users(self, ids: list[int]) -> list[User | None]:
        rows = await self.fetch("SELECT id, name FROM gql_user WHERE id = ANY($1::int[])", list(ids))
        found = {r["id"]: User(id=r["id"], name=r["name"]) for r in rows}
        return [found.get(i) for i in ids]             # None for missing keys, not an exception

Returning None for missing keys (with an optional field type) keeps one bad ID from failing the whole batch; returning an Exception instance for a key fails only that key's field. Loaders also deduplicate within a request: a thousand posts by twenty authors load twenty users. The batching pattern outside GraphQL — collecting individual calls into one — is in batching individual calls with futures.

Verify: every resolver that issues a query keyed by its parent's ID goes through a loader.

How one tick of resolvers becomes one query A flow of 5 stages. How one tick of resolvers becomes one query users resolver 1 query, 1,000 users 1,000 posts resolvers each: await loader.load(id) end of tick batch_fn(1,000 ids) one SQL query WHERE author_id = ANY($1) futures resolved each gets its own list Two queries total, however many users the request returns.

Verification

DataLoader batching is in place when:

  • SQL statements per request stay constant as the result size grows.
  • Loaders are created per request in the context, never at module level.
  • Batch functions return one result per key, in order, with None or an exception for missing keys.
  • Large responses are paginated, because per-field execution cost remains.

Diagnostic Hook: export two numbers per GraphQL operation name: SQL statements and resolved fields. Statements that track fields point at a missing loader; fields in the tens of thousands point at a response that needs pagination more than batching.

Pitfalls & edge cases

  • N+1 hidden by async concurrency. Latency looks acceptable locally; the database still runs a query per item.
  • Batch results out of key order. The loader assigns them to the wrong keys.
  • Module-level loaders. Their cache spans requests: stale data and memory growth.
  • Huge responses. About 2.5 µs per field is paid with or without a loader.

Frequently Asked Questions

How do I fix the N+1 problem in Strawberry GraphQL?

Create a DataLoader per request whose batch function loads all requested keys with one query, and call await loader.load(key) in the resolver. For 1,000 users and their posts it reduced 1,001 SQL queries to 2.

Do async resolvers solve N+1 queries?

No. They run the per-item queries concurrently, which hides some latency, but the database still receives one query per item: 1,001 statements for 1,000 users in testing.

How much faster does DataLoader make GraphQL?

It depends on database latency: with the database on the same host a 1,000-user request went from about 180 ms to 110 ms; with a simulated 5 ms round trip, from 619 ms to 128 ms.

Why is my GraphQL query still slow with DataLoader?

Execution itself costs about 2.4–2.5 µs per resolved field in Strawberry; a response with 32,000 fields took 78 ms with no database at all. Paginate large lists.