Queries, Mutations, and Subscriptions

8 min read

GraphQL has three operation types — queries for reading data, mutations for changing it, and subscriptions for receiving live updates. Each maps loosely to a REST equivalent (GET, POST/PUT/DELETE, WebSocket events) but the shape and conventions are different enough to be worth treating separately. This lesson walks through each operation type, the variables and fragments you'll use to keep queries clean, and how a GraphQL request actually flows through the server.

Queries — reading data

A query asks for data. Nothing changes on the server; the response shape exactly mirrors the request.

query {
  user(id: 42) {
    id
    name
    email
    orders {
      id
      total
      status
    }
  }
}

Notice three things:

  • The top-level word is query. (You can omit it — bare { ... } is treated as an anonymous query — but explicit is better.)
  • user(id: 42) is calling a field named user with an argument id: 42.
  • Inside user { ... } you list the fields you want. Each field can itself have nested selections (like orders { id total status }).

The server returns exactly the requested fields:

{
  "data": {
    "user": {
      "id": "42",
      "name": "Alice",
      "email": "alice@test.com",
      "orders": [
        { "id": "9001", "total": 49.99, "status": "shipped" }
      ]
    }
  }
}

If you ask for name, you get name. If you don't, you don't. There's no concept of "default fields."

Mutations — modifying data

A mutation changes data. Same syntax as a query, just mutation at the top:

mutation {
  createUser(input: { name: "Alice", email: "alice@test.com" }) {
    id
    name
    email
    createdAt
  }
}

Two points worth emphasising:

  • The block after createUser(...) is what fields the server should return after the operation. It's not part of the input; it's the projection of the new user. That's why GraphQL mutations almost always return something useful — you can ask for the fresh state in the same round trip.
  • Mutations run sequentially, queries can run in parallel. If you put two mutations in one request, the server runs them in order. Two queries in one request can run concurrently.

Conventionally, the input is wrapped in a single input object — which makes deprecation easier as the schema evolves.

Subscriptions — live updates

A subscription opens a long-lived connection (usually WebSocket) and pushes updates whenever the underlying data changes:

subscription {
  orderUpdated(userId: 42) {
    id
    status
    updatedAt
  }
}

Every time an order owned by user 42 changes status, the server sends a new payload through the same connection. Useful for live dashboards, chat, real-time notifications.

Subscriptions are far less common than queries and mutations, and testing them requires WebSocket-aware tooling. We won't go deep here — but know they exist and ask the team early whether the API uses them.

Variables — pass parameters cleanly

Hardcoding values into the query string works for one-off calls, but for tests you'll typically parameterise the query. GraphQL supports this with variables:

query GetUser($userId: ID!) {
  user(id: $userId) {
    name
    email
  }
}

Two new pieces:

  • $userId — a variable, declared at the top of the query.
  • ID! — its type. The ! means non-null (required). Other types: String, Int, Float, Boolean, plus enums and custom input types.

Send the variables as a separate JSON field alongside the query:

{
  "query": "query GetUser($userId: ID!) { user(id: $userId) { name email } }",
  "variables": { "userId": "42" }
}

In curl:

curl -X POST https://api.example.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"query GetUser($userId: ID!) { user(id: $userId) { name email } }","variables":{"userId":"42"}}'

Variables save you from string concatenation, prevent injection-style bugs, and let you reuse the same query string with different inputs across many tests.

Fragments — reuse selections

When the same set of fields appears in multiple queries, define them once as a fragment:

fragment UserSummary on User {
  id
  name
  email
}
 
query {
  me { ...UserSummary }
  recentVisitors { ...UserSummary }
}

Both me and recentVisitors now return the three fields without duplicating the list. Useful in test code where you want a consistent shape across queries.

How a request flows through the server

Step 1 of 6

Client builds the request

Query string (and optional variables) JSON-wrapped in the body. POST /graphql with Content-Type: application/json.

Five steps, repeated billions of times daily across the web. Knowing the shape helps you debug: a 400 means parse failure; a 200 with errors means validation or resolver failure; a 200 with clean data is real success.

Aliases — when you need the same field twice

Occasionally you'll want to call the same field with different arguments in one query:

query {
  alice: user(id: "42") { name }
  bob:   user(id: "43") { name }
}

alice and bob are aliases — labels in the response so the two user fields don't collide. The response is:

{
  "data": {
    "alice": { "name": "Alice" },
    "bob":   { "name": "Bob" }
  }
}

A neat trick for tests: fetch two records in one request and compare their fields without writing two requests' worth of plumbing.

Operation names — readable failures

Naming the operation (query GetUser rather than just query) is optional but valuable in tests:

  • Server logs typically include the operation name — easier to grep for.
  • Error messages often include it — "errors in operation GetUser" beats "errors in anonymous operation".
  • Tools like Apollo Studio group requests by name for analytics.

A small habit, big returns. Always name your operations.

⚠️ Common mistakes

  • Forgetting the response selection on mutations. mutation { createUser(input: {...}) } is a parse error — you must select fields from the result. { id } at minimum.
  • Sending the query as a plain text body. GraphQL goes inside {"query": "..."} JSON. A raw GraphQL string in the body returns a 400.
  • Trying to send mutations through a subscription endpoint, or vice versa. Each operation type has its own transport assumptions. Mutations belong on the regular /graphql POST.

🎯 Practice task

Run a query, a mutation, and a parameterised query. 30 minutes.

  1. Use the Countries GraphQL API (no auth required) — open https://countries.trevorblades.com/ in a browser to access its playground.
  2. Run a basic query: { country(code: "GB") { name capital currency } }. Confirm the response.
  3. Modify it to use a variable: declare query GetCountry($code: ID!) { country(code: $code) { ... } } and pass {"code": "GB"} as variables.
  4. Try invalid arguments: { country(code: "ZZ") { name } }. Inspect the errors array.
  5. Use aliases: fetch GB and US in one query under aliases uk: and us:.
  6. Stretch: find a public GraphQL API that supports mutations (or use your own). Run a mutation that returns the created object's id, then a follow-up query for that id, all in your test code.

You can now read and write GraphQL operations comfortably. The next lesson maps these onto a concrete test plan — what to assert, what to negative-test, and what tools to lean on.

// tip to track lessons you complete and pick up where you left off across devices.