intermediate·7 min read·Updated May 1, 2026

GraphQL Queries vs Mutations: When and How to Use Each

Queries read. Mutations change state. Here's the divide — and how to write both correctly.

The read/write divide

GraphQL has two primary operation types:

  • Query — read data. Should never change server state. Multiple queries in one request are run in parallel.
  • Mutation — change data. Runs sequentially (first mutation completes before the second starts). Used for create, update, and delete.

Subscriptions (real-time) are a third type, covered in the WebSockets intro lesson because they ride on WebSocket connections in most implementations.

Anatomy of a query

query GetUsers {
  users(limit: 10) {
    items {
      id
      name
      email
    }
    pageInfo {
      total
      hasNext
    }
  }
}

Parts:

  • Operation type (query) — optional for simple cases, required when you want to name the operation or use variables.
  • Operation name (GetUsers) — optional but strongly recommended. Shows up in server logs. Makes debugging easier.
  • Field (users) — the root-level query defined in the schema.
  • Arguments (limit: 10) — inputs to the query.
  • Selection set ({ items { id name email } ... }) — the fields you want back, nested for related types.

Variables — the professional way

Hardcoding values into the query string works for demos but fails in production. Use variables:

query Users($limit: Int!) {
  users(limit: $limit) {
    items { id name }
  }
}

And send the values separately:

{
  "query": "query Users($limit: Int!) { ... }",
  "variables": { "limit": 3 }
}
POST/graphql
A parameterized query using variables — the professional way.
curl -X POST 'https://demo.totalshiftleft.ai/graphql' \
  -H 'Content-Type: application/json' \
  -d '{"query":"query Users($limit: Int!) { users(limit: $limit) { items { id name } } }","variables":{"limit":3}}'

Why variables matter:

  • Security — prevents query injection if inputs ever come from user data.
  • Caching — same query shape with different variables can be cached as one "parsed query plan" server-side.
  • Readability — the query stays static; only the data changes.

Anatomy of a mutation

mutation CreateUser($input: CreateUserInput!) {
  createUser(input: $input) {
    id
    name
    email
    created_at
  }
}

Parts:

  • Operation type (mutation) — required.
  • Input type (CreateUserInput) — a structured object defined in the schema.
  • Selection set on the return — even though this is a write, you pick the fields you want back.
POST/graphql
A mutation with an input type.
curl -X POST 'https://demo.totalshiftleft.ai/graphql' \
  -H 'Content-Type: application/json' \
  -d '{"query":"mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id name email } }","variables":{"input":{"name":"Zara","email":"zara@example.com","role":"user"}}}'

Notice the pattern: you sent an input, got back an ID and echoed fields. GraphQL writes always return a shape the client specifies.

Input types

GraphQL distinguishes between output types (things you read) and input types (things you write). The schema has both:

type User {           # output type — readable
  id: ID!
  name: String!
  email: String!
  role: UserRole
}

input CreateUserInput {   # input type — for writes
  name: String!
  email: String!
  role: UserRole = USER   # default value
}

The separation exists because inputs and outputs have different constraints. Outputs can have computed fields (fullName: name + ' ' + surname), which don't make sense as inputs. Inputs can have defaults and validation, which outputs don't need.

Mutations run in sequence

If you send multiple mutations in one request, they run sequentially:

mutation {
  first:  createUser(input: {...}) { id }
  second: createUser(input: {...}) { id }
}

second doesn't start until first completes. This is GraphQL's answer to transaction-like semantics — but it's not a database transaction. If second fails, first is not rolled back. For true atomicity, the server must implement it explicitly.

Testing queries vs mutations

Query tests:

  • Assert HTTP 200.
  • Assert no errors key in the response (or the array is empty).
  • Assert the exact shape and values of data.
  • Assert the query is read-only — a common pattern is calling the query twice and asserting no side-effects.

Mutation tests:

  • Assert HTTP 200 + no errors for happy paths.
  • Assert the returned fields reflect the changes (create returns an ID; update returns new values).
  • Test validation errors — bad inputs should surface in the errors array.
  • Assert side-effects by querying afterward (create a user, then query users and verify presence).

Common mistakes

1. Treating mutations as queries. If your mutation doesn't return meaningful data, at least return { success: true, id } so the client can confirm success without a follow-up query.

2. Forgetting error-array checks. GraphQL returns 200 even when { data: null, errors: [...] }. Tests that only check HTTP status silently pass on broken mutations.

3. Over-specified selection sets in tests. Asking for 40 fields and asserting on all of them creates brittle tests. Ask only for what you actually assert on.

4. Variables as strings. { "variables": "{ \"limit\": 3 }" } is wrong — variables is an object, not a stringified one. Silent 400s often trace back to this.

What's next

Next up: the GraphQL testing guide — patterns specific to testing GraphQL APIs, including contract testing, schema validation, and N+1 detection.

Related lessons

Read more on the blog