backend / protocols / graphql / 04_resolvers_n_plus_1_dataloader.md

Resolvers, N+1, and DataLoader

6 interview angles 6 min read source

Resolvers, N+1, and DataLoader

The N+1 problem is GraphQL’s number one production pitfall. The single-field resolver model makes it easy to accidentally write code that issues thousands of DB queries per request. DataLoader is the standard fix.

Resolver basics

A resolver is a function called for each field in the query:

# Strawberry-style
@strawberry.type
class User:
    id: strawberry.ID
    name: str

    @strawberry.field
    async def posts(self) -> list["Post"]:
        return await db.get_posts_by_author(self.id)

For each User returned by the parent query, the posts resolver runs once. Now imagine:

query {
  users(limit: 100) {
    name
    posts { title }
  }
}

Server executes:

  1. users resolver — 1 query: SELECT * FROM users LIMIT 100
  2. For each of the 100 users: posts resolver — 100 queries: SELECT * FROM posts WHERE author_id = ?

Total: 101 queries for what should be 2 (one for users, one for posts).

Why this happens

Each field resolver is independent. It receives the parent and arguments; it doesn’t know about siblings or batch context. The framework calls it once per parent.

This is the “N+1” problem from any ORM, amplified by GraphQL’s nested structure — without intervention, every field becomes a potential N+1.

DataLoader — batch + dedupe

Facebook’s solution, ported to every language. Two things:

  1. Batches all requests during one event loop “tick” into one underlying call.
  2. Caches results within the request, so the same key isn’t fetched twice.
from strawberry.dataloader import DataLoader

async def load_posts_for_users(user_ids: list[int]) -> list[list[Post]]:
    # ONE query that fetches posts for all requested user IDs
    rows = await db.fetch_all(
        "SELECT * FROM posts WHERE author_id = ANY($1)", user_ids
    )
    # group by author_id
    by_author = defaultdict(list)
    for row in rows:
        by_author[row["author_id"]].append(Post(**row))
    # return in the same order as requested
    return [by_author[uid] for uid in user_ids]

posts_loader = DataLoader(load_fn=load_posts_for_users)

@strawberry.field
async def posts(self, info) -> list[Post]:
    return await posts_loader.load(self.id)

Now the same query above produces:

  1. users resolver — 1 query.
  2. 100 calls to posts_loader.load(user_id) — DataLoader batches them into 1 query.

Total: 2 queries. The original goal.

How DataLoader batches

Event loop tick 1:
  load(1) → registers, returns a Future
  load(2) → registers
  load(3) → registers
  ...
  load(100) → registers
Event loop tick 2:
  batch_fn([1, 2, 3, ..., 100]) → one DB query
  resolve all Futures with their respective results

The key insight: GraphQL’s executor runs resolvers concurrently within the same level. DataLoader exploits this by deferring the actual fetch until the current tick ends, gathering all keys, then doing one batched fetch.

DataLoader scope — per-request

@app.middleware("http")
async def graphql_context_middleware(request, call_next):
    request.state.loaders = create_fresh_loaders()    # NEW per request
    return await call_next(request)

Critical: DataLoaders are per-request. They cache within a single GraphQL operation. Sharing across requests would leak data between users.

In FastAPI / Starlette, set up context in the GraphQL view:

def get_context(request: Request) -> dict:
    return {
        "request": request,
        "user_loader": DataLoader(load_users),
        "posts_loader": DataLoader(load_posts_for_users),
    }

Each request gets fresh loaders. No cross-request leakage.

DataLoader for to-one relationships

async def load_users_by_ids(user_ids: list[int]) -> list[User | None]:
    rows = await db.fetch_all("SELECT * FROM users WHERE id = ANY($1)", user_ids)
    by_id = {row["id"]: User(**row) for row in rows}
    return [by_id.get(uid) for uid in user_ids]   # preserves order, None for missing

user_loader = DataLoader(load_fn=load_users_by_ids)

# In a Post type:
@strawberry.field
async def author(self) -> User | None:
    return await user_loader.load(self.author_id)

Now posts { author { name } } for 100 posts → 1 batched query for users.

DataLoader is not magic

Cases it doesn’t fix:

  • Filtered/paginated relationships: posts(status: PUBLISHED, limit: 5) per user. DataLoader batches by user_id, but the filter args differ per call. Need a different shape — batch by (user_id, filter_key) or restructure to load all and filter in Python.
  • Aggregations: “count of posts per user” — batchable via one query, but you write the batch function.
  • Cross-service calls: same idea, but the batched call goes to a downstream service that may not support batching.

DataLoader handles the common case (load N entities by their IDs); for everything else, you write the batch logic.

SQL-level batching alternatives

For ORM-backed APIs, you can sometimes skip DataLoader and use the ORM’s eager loading:

# SQLAlchemy-style: load posts with their authors in two queries (not N+1)
stmt = select(Post).options(selectinload(Post.author))
posts = session.scalars(stmt).all()

Then your resolver just returns post.author — no DB hit, the ORM already loaded it.

The catch: GraphQL queries are dynamic; the server doesn’t know in advance which fields are requested. Two approaches:

  1. Look ahead at the query: parse the AST, see what fields are requested, generate the right selectinload. Libraries like graphene-sqlalchemy, strawberry-sqlalchemy-mapper do this.
  2. Use DataLoader as the universal fix: each relationship has its own loader; the resolver always uses it; let DataLoader batch.

Look-ahead is fragile (deep nesting, fragments make AST analysis hard). DataLoader is simpler and more predictable.

Caching within and across requests

Cache Scope Tool
Per-request resolver cache this GraphQL request DataLoader’s built-in cache
Per-user cache user’s session / cookie server-side session
Cross-request cache all users (where appropriate) Redis with manual keys
HTTP response cache GET requests with stable URL persisted queries + HTTP cache

DataLoader handles only the first. For the others, layer manually.

A common production stack:

  1. CDN caches ?queryId=... GETs for public queries.
  2. Server-side Redis for “popular post” or “trending users” — cached output.
  3. DataLoader per request for the rest.

Detecting N+1 in tests

@pytest.fixture
def query_counter(db_engine):
    count = 0
    @event.listens_for(db_engine, "before_cursor_execute")
    def _(*args): nonlocal count; count += 1
    yield lambda: count

def test_users_with_posts_no_nplus1(client, query_counter):
    create_users(20)
    response = client.post("/graphql", json={"query": "{ users { posts { title } } }"})
    assert response.status_code == 200
    assert query_counter() <= 3   # users + posts + maybe one more

Tests like this catch regressions when someone adds a new field with a naive resolver. Required practice for any serious GraphQL backend.

DataLoader anti-patterns

  • Using one global loader across requests — leaks data; users see each other’s cached results.
  • Forgetting awaitloader.load(id) returns a future, not the value. Without await, you have a coroutine that never runs.
  • Sync resolver, async loader — can’t await in sync. Make resolvers async.
  • Batching the wrong keyload(user.id) then in the batch fn returning by name — Order mismatch breaks correlation. Always return in the same order as the input keys.
  • Cache not invalidated on mutation — same request mutates a user; later resolver reads stale cached value. Either bust DataLoader cache after mutation or accept staleness within a request.

Common interview confusions

  • “GraphQL has N+1 built in.” — the resolver model is fine; the problem is not using a batcher. DataLoader fixes it.
  • “DataLoader is a query language.” — it’s a batch + cache library. The query language is GraphQL.
  • “DataLoader is GraphQL-specific.” — useful anywhere you have N+1 patterns. Originated in GraphQL but works for any async fan-out.

Interview angle

  • “What’s the N+1 problem in GraphQL?” — fetching a list of N parents then calling a per-parent resolver that hits the DB → 1 + N queries instead of 2. Universal in nested queries unless you batch.
  • “What’s DataLoader?” — Facebook’s batcher: collects all load(key) calls during one event loop tick, calls a user-provided batch function with the full key list, resolves each Future with the corresponding result. Plus an in-memory cache within the loader’s lifetime.
  • “Why is DataLoader per-request?” — sharing across requests would cache one user’s data and serve it to another. Each request gets fresh loaders, scoped to that operation’s lifetime.
  • “DataLoader alternatives?” — ORM eager loading with query AST inspection (e.g. graphene-sqlalchemy). Works for simple cases; gets brittle with deep nesting, fragments, and conditional fields. DataLoader is more general.
  • “You add a new field to a type and CI breaks with too-many-queries — what happened?” — the new field is a relationship and its resolver does a naive DB lookup. Add a DataLoader for it.
  • “How do you test for N+1?” — count queries during a test (SQLAlchemy before_cursor_execute event); assert ≤ a budget. Catches new N+1s when fields are added.