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:
usersresolver — 1 query:SELECT * FROM users LIMIT 100- For each of the 100 users:
postsresolver — 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:
- Batches all requests during one event loop “tick” into one underlying call.
- 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:
usersresolver — 1 query.- 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 byuser_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:
- Look ahead at the query: parse the AST, see what fields are requested, generate the right
selectinload. Libraries likegraphene-sqlalchemy,strawberry-sqlalchemy-mapperdo this. - 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:
- CDN caches
?queryId=...GETs for public queries. - Server-side Redis for “popular post” or “trending users” — cached output.
- 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
await—loader.load(id)returns a future, not the value. Without await, you have a coroutine that never runs. - Sync resolver, async loader — can’t
awaitin sync. Make resolvers async. - Batching the wrong key —
load(user.id)then in the batch fn returning byname— 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_executeevent); assert ≤ a budget. Catches new N+1s when fields are added.