Skip to main content

✍️ 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 input types 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.

A query field maps to a resolver which fetches from a data source and returns a value up the tree Query book(id: "1") author Resolver Query.book(_, args) Book.author(parent) Data books table authors table Each field resolves independently; parent feeds child. Results assemble into one shaped response.
The 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.
sequenceDiagram participant C as Client participant A as Apollo Server participant R as Resolvers participant D as Data C->>A: Send query for book and its author A->>A: Parse and validate against schema A->>R: Call Query.book with the id argument R->>D: Look up the book D-->>R: Return the book row A->>R: Call Book.author with the book as parent R->>D: Look up the author D-->>R: Return the author row A-->>C: Send the assembled shaped response

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.

graph TD A["Query: books with authors"] --> B["1 query - fetch all books"] B --> C["Book 1"] B --> D["Book 2"] B --> E["Book 3 ... Book N"] C --> F["Query for author of book 1"] D --> G["Query for author of book 2"] E --> H["Query for author of book N"]

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.

ScenarioWithout DataLoaderWith DataLoader
50 books + authors1 + 50 = 51 queries1 + 1 = 2 queries
Duplicate ids in one requestRefetched every timeCached, fetched once
Scales as data growsLinearly worseStays 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 input type
  • 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 Boolean from 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 type as a mutation argument β€” inputs need the input keyword

⚠️ 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

πŸš€ 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.