backend / protocols / graphql / 03_queries_mutations_subscriptions.md

Queries, Mutations, Subscriptions

6 interview angles 6 min read source

Queries, Mutations, Subscriptions

GraphQL has three root operation types. Each maps to a different access pattern. The schema declares them; clients pick one per request.

Query — read

query GetUser($id: ID!) {
  user(id: $id) {
    name
    email
    posts(limit: 5) {
      title
      createdAt
    }
  }
}

Variables passed separately:

{"query": "...", "variables": {"id": 42}}

Queries should be side-effect-free (like HTTP GET). The server might run them in parallel; it can cache them; it can retry them. Don’t mutate state in a query.

Multiple queries in one operation:

query {
  me { name }
  popularPosts: posts(sort: VIEWS, limit: 10) { title }
  notifications { count }
}

All three execute in parallel (typically). One round trip, three results.

Aliases

When you call the same field twice with different args:

query {
  alice: user(id: 1) { name email }
  bob: user(id: 2) { name email }
}

Without aliases, the result would have duplicate user keys. Aliases give them distinct names:

{
  "data": {
    "alice": {"name": "Alice", "email": "a@b.com"},
    "bob": {"name": "Bob", "email": "b@c.com"}
  }
}

Fragments — reuse query shape

fragment UserCard on User {
  id
  name
  avatar
}

query {
  current: me { ...UserCard }
  team: users(role: ADMIN) { ...UserCard }
}

Define the field set once; reuse it. Frontend codegen tools (graphql-codegen, Apollo) often generate typed fragments per component.

Variables and operation name

query GetUser($id: ID!, $postLimit: Int = 5) {
  user(id: $id) {
    name
    posts(limit: $postLimit) { title }
  }
}

Variables:

  • Declared in the operation signature with type annotations.
  • Can have default values ($postLimit: Int = 5).
  • Sent as a separate JSON object — keeps the query string stable for caching and logging.

GetUser is the operation name. Useful when a document has multiple operations:

{"query": "...two operations...", "operationName": "GetUser", "variables": {...}}

Always name operations in production code — server logs and metrics use the name to group.

Mutation — write

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

Same shape as queries, just rooted at Mutation instead of Query. The difference: mutations execute serially, not in parallel. The spec guarantees this so that mutation { deleteUser ... createUser ... } runs deterministically.

Common patterns:

type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): DeleteResult!
}

The mutation returns the affected resource (or an envelope) — clients can immediately update local state without a follow-up query.

Mutation envelopes — for richer errors

Plain mutation returning the model:

type Mutation {
  createUser(input: CreateUserInput!): User!
}

Throws on failure (top-level errors array). Better pattern — return a result type:

type Mutation {
  createUser(input: CreateUserInput!): CreateUserResult!
}

union CreateUserResult = CreateUserSuccess | ValidationError | EmailTaken

type CreateUserSuccess {
  user: User!
}

type ValidationError {
  fields: [FieldError!]!
}

type EmailTaken {
  message: String!
}

Now business errors are part of the schema. Clients exhaustively handle each case:

mutation {
  createUser(input: {...}) {
    __typename
    ... on CreateUserSuccess { user { id name } }
    ... on ValidationError { fields { name message } }
    ... on EmailTaken { message }
  }
}

This is the “errors as data” pattern — pioneered by Relay, increasingly common. Trade-off: more schema surface area but cleaner client error handling.

Subscription — push

subscription OnMessageAdded($roomId: ID!) {
  messageAdded(roomId: $roomId) {
    id
    author { name }
    text
    createdAt
  }
}

The client receives updates as they happen. Unlike queries/mutations (which return once), subscriptions stream — typically over WebSocket.

Transport

The GraphQL spec doesn’t define the transport. Two common:

Transport Status
graphql-ws (the newer protocol over WebSocket) current standard
subscriptions-transport-ws (older WebSocket protocol) deprecated
SSE-based also possible (Apollo, GraphQL-Yoga support it)

The WebSocket handshake includes auth (typically via Connection Init payload). Each subscription gets an ID; server pushes events tagged with that ID.

Implementation

Subscriptions need a publish-subscribe broker. Pure in-process pub/sub works for single-server demos; for production, Redis pub/sub, NATS, or Kafka.

# Strawberry example
import strawberry
import asyncio
from typing import AsyncIterator

@strawberry.type
class Subscription:
    @strawberry.subscription
    async def message_added(self, room_id: strawberry.ID) -> AsyncIterator[Message]:
        # subscribe to a Redis channel for this room
        async for message in pubsub.subscribe(f"room:{room_id}"):
            yield Message(**message)

When to use subscriptions

  • Real-time UI updates (chat, notifications, live dashboards, collaborative editing).
  • Low-frequency events (~ <10/sec per client).
  • Per-user data where the client already has session context.

When NOT:

  • High-frequency streams (>100/sec) — WebSocket per client gets expensive; consider SSE or dedicated streaming protocols.
  • Mostly-static data with rare updates — polling is simpler.
  • Massive fan-out (one event to 100k clients) — needs careful broker design; consider WebSocket-aware load balancers.

Operation type choice

Need Use
read data Query
mutate state Mutation
stream updates Subscription
trigger a side-effect (send email, kick off job) Mutation, even if no “data” changes
webhook-style notify Mutation that returns success/failure

Persisted queries — for performance + security

Instead of sending the full query string each time:

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

You can register the query at deploy time and reference by hash:

GET /graphql?queryId=sha256:abc123...&variables=...

Pros:

  • Smaller request body.
  • Cacheable (GET with stable URL).
  • Security: server only accepts known queries — clients can’t run arbitrary expensive queries.

Cons:

  • Build step required (registration).
  • Less flexible for one-off queries.

Apollo and Relay support this; “automatic persisted queries” hash the query client-side and register on first request.

Common pitfalls

  • Mutating data in a query resolver — breaks the parallel execution guarantee; intermittent bugs.
  • No operation name — server logs show anonymous; can’t tell which operation is slow.
  • Subscription without auth — anyone can subscribe to any channel. Auth at connection init AND per-subscription.
  • Subscription channel without filtering — server pushes every event to every subscriber; clients filter client-side. Server-side filtering by subscription args.
  • Unbounded list argsusers(ids: [ID!]!) with no limit; clients send 10000 IDs.

Common interview confusions

  • “Subscriptions are like polling.” — opposite: server pushes when events occur. Polling is the client asking repeatedly.
  • “You can mutate in a query.” — the spec allows it but doesn’t guarantee execution order. Don’t do it.
  • “Mutations always succeed unless network fails.” — they can fail business rules; check the response.

Interview angle

  • “Difference between query and mutation in execution?” — fields in a query may execute in parallel; mutation fields execute serially (in declared order). Spec guarantees this so multiple mutations in one request behave predictably.
  • “What are fragments and why use them?” — reusable selection sets. Define fragment UserCard on User { id name avatar } once; reuse in multiple queries. Pairs with frontend codegen to type per-component data.
  • “What’s a GraphQL subscription?” — long-lived operation that streams updates as events happen. Typically WebSocket transport via graphql-ws. Needs a server-side pub/sub broker (Redis, NATS, Kafka).
  • “When should you use subscriptions vs polling vs webhooks?” — subscriptions for in-app real-time UI (browser, mobile). Polling for simple low-frequency cases. Webhooks for server-to-server.
  • “What’s an ‘errors as data’ mutation pattern?” — instead of throwing top-level errors, the mutation returns a union of success/error result types. Clients exhaustively handle each — cleaner error UX, but more schema surface area.
  • “What are persisted queries?” — query strings registered at deploy time, referenced by hash. Saves bandwidth, enables GET caching, and prevents arbitrary client queries (a security win).