GraphQL Resolvers: A Complete Guide
Master GraphQL resolvers: resolver functions, context, the N+1 problem, DataLoader for batching, error handling, authorization in resolvers, and performance patterns.
What you'll learn
- ✓How resolver functions map to your schema
- ✓Using context for authentication and shared services
- ✓The N+1 problem and why it kills performance
- ✓DataLoader for batching and caching database queries
- ✓Error handling and authorization patterns in resolvers
Prerequisites
- •Basic GraphQL schema knowledge (types, queries, mutations)
- •Familiarity with async/await in JavaScript or Python
- •Basic database query experience
Resolvers are the functions that actually fetch data for your GraphQL schema. Every field in your schema has a resolver, whether you write one explicitly or rely on the default. Understanding how resolvers work, how they compose, and how to avoid their performance traps is essential for building a GraphQL API that performs well.
How resolvers work
A resolver is a function that returns the data for a single field. It receives four arguments:
// JavaScript/Node.js resolver signature
function resolver(parent, args, context, info) {
// parent: the result from the parent resolver
// args: arguments passed to this field
// context: shared context (auth, database, etc.)
// info: field-level metadata (rarely used directly)
}
# Python (Strawberry/Ariadne) resolver signature
def resolver(parent, info, **kwargs):
# parent: (called 'root' or 'obj' in some frameworks)
# info: contains context and field metadata
# kwargs: field arguments
pass
Default resolvers
If you do not write a resolver for a field, GraphQL uses a default resolver that returns parent[fieldName]. This is why simple object fields “just work.”
// Schema
// type User {
// id: ID!
// name: String!
// email: String!
// }
const resolvers = {
Query: {
user: (_, { id }, { db }) => db.users.findById(id),
// Returns { id: "1", name: "Alice", email: "alice@example.com" }
},
// No User resolvers needed!
// User.id resolves to parent.id automatically
// User.name resolves to parent.name automatically
// User.email resolves to parent.email automatically
};
You only write a resolver when the field’s data is not a simple property access on the parent.
Resolver chains
Resolvers execute in a tree. The root query resolver runs first, then child resolvers run with the parent’s result.
const resolvers = {
Query: {
post: async (_, { id }, { db }) => {
return db.posts.findById(id);
// Returns { id, title, content, authorId, tagIds }
},
},
Post: {
// This resolver runs with the post object as parent
author: async (post, _, { db }) => {
return db.users.findById(post.authorId);
},
// Tags also resolve from the parent post
tags: async (post, _, { db }) => {
return db.tags.findByIds(post.tagIds);
},
// Computed field: no database query needed
excerpt: (post) => {
return post.content.substring(0, 200) + "...";
},
},
};
When a client queries:
query {
post(id: "1") {
title
author {
name
}
tags {
name
}
excerpt
}
}
The execution order is:
Query.postresolves the postPost.author,Post.tags, andPost.excerptrun in parallel with the post as parent- Default resolvers handle
title,nameon User,nameon Tag
Context
Context is created once per request and shared across all resolvers. It is the right place for authentication, database connections, and service instances.
// Server setup
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => {
const token = req.headers.authorization?.replace("Bearer ", "");
const user = token ? await verifyToken(token) : null;
return {
user,
db: database,
loaders: createLoaders(), // DataLoaders (covered below)
};
},
});
// Using context in resolvers
const resolvers = {
Query: {
me: (_, __, { user }) => {
if (!user) throw new AuthenticationError("Not authenticated");
return user;
},
myPosts: async (_, __, { user, db }) => {
if (!user) throw new AuthenticationError("Not authenticated");
return db.posts.findMany({ where: { authorId: user.id } });
},
},
};
The N+1 problem
The N+1 problem is the biggest performance trap in GraphQL. It happens when a list query triggers individual database queries for each item’s relationships.
// This looks innocent...
const resolvers = {
Query: {
posts: (_, __, { db }) => db.posts.findMany({ take: 50 }),
// 1 query: SELECT * FROM posts LIMIT 50
},
Post: {
author: (post, _, { db }) => db.users.findById(post.authorId),
// 50 queries: SELECT * FROM users WHERE id = ? (one per post)
},
};
A query for 50 posts with their authors generates 51 database queries: 1 for the posts and 50 for the authors. With nested relationships (posts -> authors -> company), it multiplies further.
DataLoader: the solution
DataLoader batches and caches individual lookups within a single request. Instead of 50 separate findById calls, DataLoader collects all the IDs and makes one findByIds call.
import DataLoader from "dataloader";
function createLoaders() {
return {
userLoader: new DataLoader(async (userIds) => {
// One query instead of N
const users = await db.users.findMany({
where: { id: { in: userIds } },
});
// DataLoader requires results in the same order as the keys
const userMap = new Map(users.map((u) => [u.id, u]));
return userIds.map((id) => userMap.get(id) || null);
}),
tagLoader: new DataLoader(async (tagIds) => {
const tags = await db.tags.findMany({
where: { id: { in: tagIds } },
});
const tagMap = new Map(tags.map((t) => [t.id, t]));
return tagIds.map((id) => tagMap.get(id) || null);
}),
};
}
Use the loaders in resolvers:
const resolvers = {
Query: {
posts: (_, __, { db }) => db.posts.findMany({ take: 50 }),
},
Post: {
author: (post, _, { loaders }) => {
return loaders.userLoader.load(post.authorId);
// DataLoader batches: all 50 authorIds are fetched in ONE query
},
tags: async (post, _, { loaders }) => {
return Promise.all(
post.tagIds.map((id) => loaders.tagLoader.load(id))
);
},
},
};
Now the same query generates 3 database queries instead of 51:
SELECT * FROM posts LIMIT 50SELECT * FROM users WHERE id IN (id1, id2, ..., id50)(batched by DataLoader)SELECT * FROM tags WHERE id IN (...)(batched by DataLoader)
DataLoader caching
DataLoader caches results within a single request. If two posts have the same author, the author is fetched once.
// Post 1 has authorId: "alice"
// Post 2 has authorId: "alice"
// Post 3 has authorId: "bob"
loaders.userLoader.load("alice"); // queued
loaders.userLoader.load("alice"); // cache hit, same promise
loaders.userLoader.load("bob"); // queued
// Actual query: SELECT * FROM users WHERE id IN ('alice', 'bob')
// Only 2 users fetched, not 3
Python DataLoader
from aiodataloader import DataLoader
class UserLoader(DataLoader):
async def batch_load_fn(self, user_ids):
users = await db.users.find_many(
where={"id": {"in": list(user_ids)}}
)
user_map = {u.id: u for u in users}
return [user_map.get(uid) for uid in user_ids]
# In resolver
async def resolve_author(post, info):
return await info.context["user_loader"].load(post.author_id)
Error handling
Field-level errors
Throw errors in resolvers. GraphQL returns them alongside partial data.
const resolvers = {
Query: {
post: async (_, { id }, { db }) => {
const post = await db.posts.findById(id);
if (!post) {
throw new UserInputError("Post not found", {
argumentName: "id",
});
}
return post;
},
},
Post: {
viewCount: async (post, _, { user }) => {
// Only the author can see view counts
if (!user || user.id !== post.authorId) {
throw new ForbiddenError("Not authorized to view this field");
}
return post.viewCount;
},
},
};
Union-based errors (recommended for mutations)
// Schema:
// union CreatePostResult = Post | ValidationError | AuthError
const resolvers = {
Mutation: {
createPost: async (_, { input }, { user, db }) => {
if (!user) {
return {
__typename: "AuthError",
message: "Authentication required",
};
}
if (!input.title.trim()) {
return {
__typename: "ValidationError",
field: "title",
message: "Title cannot be empty",
};
}
const post = await db.posts.create({ data: { ...input, authorId: user.id } });
return { __typename: "Post", ...post };
},
},
};
Authorization in resolvers
Field-level authorization
function requireAuth(resolverFn) {
return (parent, args, context, info) => {
if (!context.user) {
throw new AuthenticationError("Authentication required");
}
return resolverFn(parent, args, context, info);
};
}
function requireRole(...roles) {
return (resolverFn) => {
return (parent, args, context, info) => {
if (!context.user) {
throw new AuthenticationError("Authentication required");
}
if (!roles.includes(context.user.role)) {
throw new ForbiddenError(`Requires role: ${roles.join(" or ")}`);
}
return resolverFn(parent, args, context, info);
};
};
}
const resolvers = {
Query: {
me: requireAuth((_, __, { user }) => user),
adminStats: requireRole("ADMIN")((_, __, { db }) => {
return db.stats.getAll();
}),
},
Mutation: {
deleteUser: requireRole("ADMIN")((_, { id }, { db }) => {
return db.users.delete(id);
}),
},
};
Performance tips
Only resolve what is queried. Use the info argument to check which fields the client requested, and skip expensive computations for fields not in the selection set.
Post: {
commentCount: async (post, _, { db }, info) => {
// Only hit the database if this field was actually requested
return db.comments.count({ where: { postId: post.id } });
},
}
Create DataLoaders per request. DataLoader caches are scoped to a single request. Creating loaders in the context factory ensures no cross-request cache pollution.
Avoid resolver waterfalls. If a resolver depends on the result of a sibling resolver, restructure so the parent provides what both need.
Use database joins when appropriate. For deeply nested queries that always fetch the same shape, a single SQL join can be faster than multiple DataLoader batches.
Resolvers are where your schema meets your data. Keep them thin (delegate to services), batch with DataLoader, handle errors explicitly, and authorize at the field level. A well-structured resolver layer makes your GraphQL API both fast and maintainable.
Related articles
- GraphQL GraphQL Schema Design Best Practices
Design GraphQL schemas that scale: types, queries, mutations, connections, pagination, nullability, naming conventions, and patterns for evolving your API over time.
- GraphQL GraphQL Resolver Patterns Explained
Compare resolver patterns in GraphQL: thin resolvers, service layers, DataLoader batching, and error handling that scales.
- GraphQL GraphQL Schemas and Resolvers
A practical guide to GraphQL schemas — SDL, scalar types, Query and Mutation roots, custom object types, resolver functions, and how to avoid the N+1 problem with DataLoader.
- GraphQL Authentication and Authorization in GraphQL
Implement auth in GraphQL using context-based authentication, custom directives, and field-level authorization patterns.