βοΈ Queries and Mutations
A read-only API is a museum: you can look, but not touch. Real applications create accounts, post comments, and update carts. In this lesson you'll learn to ask precisely with queries β arguments, variables, and the field-by-field resolution flow β and to change data safely with mutations, then make it all fast by defeating the N+1 problem.
Week 14 · Wednesday: GraphQL · Lecture 3
π― Learning Objectives
By the end of this lesson, you will be able to:
- Pass data into queries with arguments and reusable variables
- Trace the query β resolver β data flow that produces a response
- Write data with mutations, using
inputtypes and returning the modified object - Name and structure operations well β verb-prefixed mutations, payload return types
- Diagnose the N+1 problem and eliminate it with DataLoader batching
Estimated Time: 70 minutes
Practice: Add a create-and-update mutation pair and wire up a DataLoader.
In This Lesson
Two Kinds of Operation
Every GraphQL request is one of three operation types. Two of them matter today:
- Query β a read. It fetches data and, by convention, never changes anything. Queries can run in parallel.
- Mutation β a write. It creates, updates, or deletes, then returns the affected data. Mutations run in series, top to bottom, so one can't stomp another.
(The third, Subscription, pushes real-time updates over a WebSocket β a topic for later.) The syntax is nearly identical; the difference is intent and the root type each hangs off: type Query versus type Mutation.
# A read
query {
book(id: "1") { title }
}
# A write β note the "mutation" keyword and Mutation-type field
mutation {
addBook(title: "Dune", author: "Frank Herbert") { id title }
}
Arguments
Fields can accept arguments β the way a client narrows or parameterises what it wants. You've already used book(id: "1"); arguments also power filtering, limiting, and sorting.
query {
# arguments filter and limit the result
books(genre: "SCIFI", limit: 5) {
title
year
}
}
On the server, arguments arrive as the resolver's second parameter:
const resolvers = {
Query: {
books: (_parent, { genre, limit }) => {
let result = books;
if (genre) result = result.filter((b) => b.genre === genre);
if (limit) result = result.slice(0, limit);
return result;
},
},
};
Arguments are declared in the schema with their types, and can carry defaults:
type Query {
books(genre: String, limit: Int = 10): [Book!]!
}
π‘ Arguments live on any field, not just root ones
You can put arguments on nested fields too β author { posts(last: 3) { title } }. Each field's resolver receives its own arguments, so pagination and filtering can happen at every level of the graph.
Variables
Hard-coding id: "1" into a query string is fine in the Sandbox, but a real app needs the id to change at runtime. Variables let you write the query once and supply values separately β the GraphQL equivalent of parameterised SQL. They keep queries reusable and dodge string-concatenation bugs.
Declare variables in the operation signature with a $ prefix and a type, then use them by name:
query GetBook($id: ID!, $withReviews: Boolean!) {
book(id: $id) {
title
year
reviews @include(if: $withReviews) {
rating
text
}
}
}
The values travel alongside the query as a separate JSON object β never spliced into the string:
{
"id": "1",
"withReviews": true
}
That @include(if: ...) is a built-in directive: it conditionally includes a field based on a variable. Its sibling @skip(if: ...) does the reverse. Together they let one query serve several UI states.
β Why variables matter for security & caching
Because values are sent apart from the query text, the query string stays identical across requests β which makes it cacheable and safe to allow-list as a persisted query. It also removes any chance of "injection" through string building, the way parameterised queries protect SQL.
Query β Resolver β Data
Here's the mental model that makes GraphQL click. When a query arrives, Apollo doesn't run one big function β it walks the query field by field, calling the resolver for each, passing each result down as the parent of its children. The response is assembled from the bottom up.
book resolver runs first; its return value becomes the parent passed to Book.author, which fetches from a second source. GraphQL stitches the results into the exact shape the query requested.Mutations: Writing Data
A mutation is structurally just a field under type Mutation, but it carries a promise: running it changes server state. By convention its return type is the thing it affected, so the client can read back the fresh state in the same round trip.
type Mutation {
addBook(title: String!, author: String!, year: Int): Book!
updateBook(id: ID!, title: String): Book
deleteBook(id: ID!): Boolean!
}
The resolvers live under a Mutation key, mirroring Query:
const resolvers = {
Mutation: {
addBook: (_, { title, author, year }, { user }) => {
if (!user) throw new Error('You must be signed in');
const book = { id: String(books.length + 1), title, author, year };
books.push(book);
return book; // return the created object
},
updateBook: (_, { id, title }, { user }) => {
if (!user) throw new Error('You must be signed in');
const book = books.find((b) => b.id === id);
if (!book) throw new Error(`Book ${id} not found`);
if (title) book.title = title;
return book; // return the updated object
},
deleteBook: (_, { id }, { user }) => {
if (!user?.isAdmin) throw new Error('Admins only');
const i = books.findIndex((b) => b.id === id);
if (i === -1) return false;
books.splice(i, 1);
return true; // report success as a Boolean
},
},
};
Calling it, and reading back exactly the fields you care about:
mutation AddBook($title: String!, $author: String!) {
addBook(title: $title, author: $author) {
id
title
}
}
Input Types & Payloads
Once a mutation takes more than two or three arguments, listing them all gets unwieldy. GraphQL's answer is the input type β an object type used specifically for arguments. It groups related fields into one tidy parameter.
input AddBookInput {
title: String!
author: String!
genre: String
year: Int
}
type Mutation {
addBook(input: AddBookInput!): Book!
}
mutation AddBook($input: AddBookInput!) {
addBook(input: $input) {
id
title
}
}
π‘ Input types are not object types
You can't reuse a regular type as an argument β inputs need their own input keyword. The reason: output types can have resolvers and circular relationships, while input types must be plain, serialisable data. Keeping them separate is a deliberate part of the type system.
The payload pattern
For production mutations, many teams return a payload type instead of the bare object β a wrapper that can carry the entity and structured, per-field errors. This lets a mutation partially succeed and report validation problems the client can act on, rather than throwing a blanket error.
type UserError {
message: String!
field: String
}
type AddBookPayload {
book: Book # null if it failed
errors: [UserError!]! # empty on success
}
type Mutation {
addBook(input: AddBookInput!): AddBookPayload!
}
addBook: (_, { input }) => {
const errors = [];
if (!input.title) errors.push({ message: 'Title is required', field: 'title' });
if (errors.length) return { book: null, errors };
const book = { id: String(books.length + 1), ...input };
books.push(book);
return { book, errors: [] };
},
The N+1 Problem
Now the performance trap the whole GraphQL world talks about. Consider this innocent-looking query:
query {
books { # 1 query: fetch all books
title
author { # for EACH book, fetch its author...
name
}
}
}
If there are 50 books, the naΓ―ve resolvers fire 1 query for the list of books, then 50 more β one per book β to fetch each author. That's N+1 database round trips for a single request. It scales linearly with your data and quietly destroys performance.
The root cause is the resolver-per-field model you learned last lesson: Book.author runs once per book, and each call looks up its author in isolation, with no idea the others are happening at the same time.
Fixing It with DataLoader
DataLoader is a tiny utility (from the GraphQL team) that solves N+1 with two tricks: batching and per-request caching. Instead of firing a query the instant each Book.author runs, DataLoader collects all the author ids requested during one tick of the event loop, then makes a single batched lookup for all of them.
npm install dataloader
import DataLoader from 'dataloader';
// The batch function receives an array of ALL keys requested this tick
function createLoaders(db) {
return {
authorLoader: new DataLoader(async (authorIds) => {
// ONE query for every author id collected β not N queries
const authors = await db.author.findMany({
where: { id: { in: authorIds } },
});
// Must return results in the SAME order as the incoming ids
return authorIds.map((id) => authors.find((a) => a.id === id));
}),
};
}
Build a fresh set of loaders per request in the context β the cache must not leak between users:
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => ({
user: await getUserFromToken(req),
loaders: createLoaders(db), // new loaders every request
}),
});
Then the Book.author resolver simply defers to the loader:
const resolvers = {
Book: {
author: (book, _args, { loaders }) => loaders.authorLoader.load(book.authorId),
},
};
Now those 50 author lookups collapse into one batched query. The .load(id) calls all happen synchronously in the same tick; DataLoader gathers the ids, runs the batch function once, and hands each resolver back its author.
| Scenario | Without DataLoader | With DataLoader |
|---|---|---|
| 50 books + authors | 1 + 50 = 51 queries | 1 + 1 = 2 queries |
| Duplicate ids in one request | Refetched every time | Cached, fetched once |
| Scales as data grows | Linearly worse | Stays flat |
β οΈ Two rules DataLoader will bite you on
1. Order matters. The batch function must return values in the exact order of the keys it was given β map over the keys, don't just return the raw DB rows. 2. One loader per request. Create loaders inside context, never as a module-level singleton, or one user's cached data will bleed into another's.
Practice & Quiz
ποΈ Exercise 1: A parameterised query with variables
Goal: Write a named query SearchBooks that takes a $genre (String, required) and a $limit (Int, optional) variable, and requests the title and year of matching books.
π‘ Hint
Declare the variables in the operation signature after the name: query SearchBooks($genre: String!, $limit: Int). Then pass them to the books field's arguments.
β Solution
query SearchBooks($genre: String!, $limit: Int) {
books(genre: $genre, limit: $limit) {
title
year
}
}
{ "genre": "SCIFI", "limit": 5 }
ποΈ Exercise 2: A create mutation with an input type
Goal: Define an AddAuthorInput input type (name required, bio optional), an addAuthor(input: AddAuthorInput!): Author! mutation, and its resolver against an in-memory authors array.
β Solution
input AddAuthorInput {
name: String!
bio: String
}
type Mutation {
addAuthor(input: AddAuthorInput!): Author!
}
Mutation: {
addAuthor: (_, { input }, { user }) => {
if (!user) throw new Error('You must be signed in');
const author = { id: String(authors.length + 1), ...input };
authors.push(author);
return author;
},
},
π― Quick Quiz
Question 1: Why send values as GraphQL variables instead of hard-coding them into the query string?
Question 2: A query for 30 posts, each requesting its author, fires 31 database queries. This is calledβ¦
Question 3: Where should DataLoader instances be created?
Best Practices & Pitfalls
β Do
- Name your operations (
query GetBook,mutation AddBook) β it aids debugging, caching, and analytics - Pass runtime values as variables, never by concatenating them into the query string
- Give mutations verb-prefixed names (
addBook,updateBook,deleteBook) and return the affected object - Group multi-field mutation arguments into an
inputtype - Wrap every related-entity resolver in a DataLoader from day one
β Don't
- Do reads inside a mutation resolver's side effects, or writes inside a query β keep the intent honest
- Return only a
Booleanfrom a create/update when the client needs the fresh object back - Share a DataLoader across requests, or return batch results in the wrong order
- Reuse an output
typeas a mutation argument β inputs need theinputkeyword
β οΈ Guard mutations with the context user
Reads are often public, but writes rarely are. Check context.user at the top of every mutation resolver, and check roles (user.isAdmin) for destructive ones like delete. The type system validates shape; only your resolver enforces permission.
Summary
π Key Takeaways
- Queries read and mutations write; mutations return the object they changed
- Arguments parameterise fields; variables supply their values separately for reusable, safe queries
- GraphQL resolves field by field β
query β resolver β data, parent feeding child - Group mutation arguments into input types; consider a payload return type for structured errors
- The N+1 problem is inherent to per-field resolution β batch it away with DataLoader, one loader per request
π Additional Resources
- graphql.org β Queries and Mutations
- Apollo Server β Resolvers & arguments
- Apollo Server β Fetching data & batching
- DataLoader β official repository
π What's Next?
You've now built a complete, performant GraphQL API β reads, writes, and no N+1. Next we shift from the server to the whole app experience: PWA principles, where you'll learn to make web apps installable, offline-capable, and native-feeling.
π You can read, write, and scale!
Arguments, variables, mutations, and DataLoader are the day-to-day tools of every professional GraphQL developer. You've got the full round trip now.