Mutations
|
This section documents the current GraphQL specification (October 2021), plus the working draft for
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as GraphQL tooling iterates quickly. |
Queries read data; mutations are the GraphQL operation type used to write it — creating, updating or deleting records, or triggering any other server-side side effect. Aside from that intent, a mutation’s selection set is written and resolved the same way a query’s is: it is still a tree of fields returning a schema-defined shape, not an RPC call with a bespoke response format.
Mutations vs. queries: writing data
Both operation types are just root fields under their respective root type (Mutation instead of Query),
and the client asks for a mutation exactly like it asks for a query, prefixed with the mutation keyword:
mutation AddStar {
addStar(input: { starrableId: "MDEwOlJlcG9zaXRvcnkxMjM=" }) {
starrable {
viewerHasStarred
}
}
}
The convention of naming the field for the write it performs — createOrder, updateOrder,
deleteOrder — rather than overloading a single generic save or write field keeps the schema
self-documenting and lets each mutation declare its own input and payload shape. See
graphql.org/learn/mutations for the canonical introduction to the
operation type, including the server-side expectation that a mutation resolver returns the data the fields
below it need in order to resolve, not just an acknowledgement.
Whether a field belongs under Query or Mutation — and how the two root types relate to Subscription — is covered in more depth in Queries and Fields.
One input object, not many scalar arguments
A query root field is often written with several independent scalar arguments (repository(owner: String!,
name: String!)). Mutation fields instead take a single required input object argument, conventionally
named input and typed as a dedicated <MutationName>Input type:
input CreateOrderInput {
customerId: ID!
lines: [OrderLineInput!]!
discountCode: String
clientMutationId: String
}
input OrderLineInput {
sku: String!
quantity: Int!
}
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
}
A few reasons this convention won over createOrder(customerId: ID!, lines: [OrderLineInput!]!,
discountCode: String):
-
Evolvability without breaking clients. Adding a new optional field to an input object is a non-breaking change; adding a new positional-feeling argument to a field with many parameters is easy to get wrong and harder to keep readable as the list grows.
-
A stable, inspectable unit. Client-side tooling (generated types, form bindings, persisted-query variables) can target one
CreateOrderInputtype instead of tracking a field’s whole argument list. -
Room for cross-cutting metadata. An optional
clientMutationIdfield on the input (echoed back on the payload) lets a client correlate a mutation response with the request that triggered it — useful when several mutations are batched or de-duplicated in flight.
This is one of several payload-shaping conventions; the fuller picture — naming, nullability choices on the payload, and how these inputs interact with schema versioning — lives in Schema Design.
Top-level mutation fields execute serially
A query operation’s top-level fields may be resolved in parallel — a query asking for viewer and
repository side by side implies no ordering dependency between the two, since reading data has no side
effects to sequence. A mutation operation is different: the GraphQL specification requires that the root
fields of a single mutation operation be executed serially, one after another, in the order they appear
in the document, with each field’s side effect completing before the next one begins.
mutation TransferFunds {
withdraw(input: { accountId: "A1", amount: 50 }) {
account { balance }
}
deposit(input: { accountId: "A2", amount: 50 }) {
account { balance }
}
}
That guarantee exists precisely because mutations have side effects: if withdraw and deposit above ran
concurrently, a client could not rely on the debit having committed before the credit is attempted, and two
mutations that both touch the same record could race. Serial top-level execution gives the client a
predictable, left-to-right ordering to build multi-step writes on, at the cost of some latency compared to
a query with the same number of root fields. Note that this ordering guarantee is scoped to the top-level
fields of one mutation operation — nested fields inside a single mutation’s payload, and fields across
separate operations sent independently, follow the normal resolution rules. See
graphql.org/learn/mutations ("Multiple fields in mutations") for the
specification language behind this.
Returning the affected object in the payload
Because the client already sent the mutation and is about to receive a response, a well-designed payload returns the object(s) the mutation just changed — not merely a boolean or an id — so the client can update its local cache (Apollo Client’s normalized cache, Relay’s store, or a hand-rolled cache) directly from the mutation response instead of issuing a follow-up query to re-fetch the same data:
type CreateOrderPayload {
order: Order
userErrors: [UserError!]!
clientMutationId: String
}
mutation CreateOrder($input: CreateOrderInput!) {
createOrder(input: $input) {
order {
id
status
total
}
userErrors {
field
message
}
}
}
Returning the full updated object also matters for fields the server computes or defaults — a generated
id, a server-assigned createdAt, a total computed from line items — values the client could not
otherwise reconstruct locally after the write. Relay in particular leans on this: including the mutated
object’s id (and any fields whose values changed) lets Relay’s store reconcile the mutation result with
every query that already has that object cached, without any manual cache-update code. Deleting a record is
the one case worth returning the id of the now-gone object rather than the object itself, so the client
can still remove it from its cache.
Modelling expected errors as part of the payload
GraphQL’s top-level errors array (alongside data in every response) is meant for the same class of
failures HTTP status codes cover for REST — a malformed query, a failed argument coercion, an unhandled
exception, an authorization failure that prevents the request from being processed at all. It is a poor fit
for expected, business-level failures a mutation can legitimately produce, such as "that discount code has
expired" or "insufficient stock for this SKU": those are not bugs, the client needs to display them
field-by-field next to a form, and a partial failure alongside other data the client still needs is exactly
what errors was not designed to carry cleanly.
The established convention instead models expected failures in the schema itself, as ordinary data on the
mutation’s payload, most often as a userErrors: [UserError!]! list (seen already in CreateOrderPayload
above) or, in richer designs, as a union/interface result type where each mutation returns either a
success shape or one of several named failure shapes:
type UserError {
field: [String!]
message: String!
}
union CreateOrderResult = CreateOrderSuccess | InsufficientStockError | InvalidDiscountCodeError
type CreateOrderSuccess {
order: Order!
}
type InsufficientStockError {
message: String!
sku: String!
available: Int!
}
type InvalidDiscountCodeError {
message: String!
discountCode: String!
}
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderResult!
}
Whichever shape is chosen, the effect is the same: the outer GraphQL response is a clean, unconditional
success (errors stays absent, data is fully populated), and the client branches on the payload’s own
fields to decide whether the write actually succeeded. A response to the userErrors-style mutation above
looks like this on the happy path:
{
"data": {
"createOrder": {
"order": {
"id": "T3JkZXI6NDIx",
"status": "PENDING",
"total": 129.99
},
"userErrors": []
}
}
}
and like this when a business rule rejects the write — still 200 OK, still no top-level errors, order
simply null and the reason surfaced as data instead:
{
"data": {
"createOrder": {
"order": null,
"userErrors": [
{
"field": ["input", "discountCode"],
"message": "Discount code 'SUMMER25' has expired."
}
]
}
}
}
This is a design choice layered on top of, not a replacement for, the top-level errors array: a request
that fails to execute at all (a validation error, a thrown exception the resolver did not anticipate, a
denied authorization check) still belongs in errors, with a non-2xx-adjacent shape the client’s transport
layer already knows how to treat as a hard failure. The full contrast between the two error channels — including how each maps onto Apollo Client’s and Relay’s error handling — is covered in
Response and Error Handling.
Related pages
-
Queries and Fields — how
Query,MutationandSubscriptionrelate as root operation types. -
Schema Design — naming and nullability conventions for inputs and payloads in more depth.
-
Response and Error Handling — the top-level
errorsarray versus errors modelled in the schema, and how clients handle each.