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 }
}
/graphqlcurl -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.
/graphqlcurl -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
errorskey 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
errorsarray. - 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
GraphQL lets the client decide what data to fetch. Here's how it works and when it beats REST.
GraphQL tests fail in ways REST tests never do. Here's what to cover and why.
The same operation in three protocols. Pick the right one for the job — and test it right.