Marks a mutation field as fully generated and picks the SQL shape (INSERT, UPDATE, DELETE, UPSERT). The write runs inside a transaction with a primary-key-only RETURNING; a @table-typed response is then read back by a follow-up SELECT keyed on those primary keys, so the response selection set never widens the write statement itself.
SDL signature
directive @mutation(typeName: MutationType!, multiRow: Boolean = false, table: String) on FIELD_DEFINITION
enum MutationType { UPDATE, DELETE, INSERT, UPSERT }
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
|
|
(required) |
The SQL shape to emit. |
|
|
|
On |
|
|
(none) |
Names the SQL table to write to, on the consuming field. It gives a write that carries no table on its return side a field-relative write target: a bare |
Input shape
A @mutation field takes exactly one input argument. Its fields bind to columns of the write target’s table (@field(name:), or the SDL name by default). The input type carries no table directive of its own (@table on an input is a deprecated location and is ignored): for INSERT and UPDATE the write target is derived from the field’s return (see Naming the INSERT and UPDATE write target), and for DELETE it is named on the field with @mutation(table:) (see Naming the DELETE write target).
An input field may also be a nested input object: a grouping shape whose own fields map onto columns of the same table. The grouping carries no DML semantics; the leaves flatten onto the outer write target as if declared at the top level, and absent-vs-null is honored independently at every nesting layer (an absent or null group skips its whole subtree; a present group descends per leaf). The tutorial’s Grouping fields with nested input types walks a worked example. A nested input introducing a second DML target is a separate compound-mutation shape and is not admitted here.
Canonical example
The tutorial’s film mutations cover the two most common shapes. Neither input carries a directive; the write target is derived from the Film return type (Film carries @table(name: "film")):
input FilmCreateInput {
title: String! @field(name: "title")
languageId: Int! @field(name: "language_id")
}
input FilmUpdateInput {
filmId: Int! @field(name: "film_id")
title: String! @field(name: "title")
}
type Mutation {
createFilm(in: FilmCreateInput!): Film @mutation(typeName: INSERT)
updateFilm(in: FilmUpdateInput!): Film @mutation(typeName: UPDATE)
}
The INSERT shape produces a two-step exchange. The write commits inside a transaction with a primary-key-only RETURNING:
INSERT INTO film (title, language_id)
VALUES (?, ?)
RETURNING film.film_id
and the response selection set is then read back by a follow-up SELECT keyed on the returned primary key:
SELECT film.film_id AS "filmId", film.title AS "title"
FROM film
WHERE film.film_id = ?
The follow-up SELECT narrows to the response selection set: a mutation that asks for { filmId title } retrieves only those columns. Because the write has already committed when the read-back runs, an error during the response SELECT surfaces as a field error and cannot undo the write.
For a bulk mutation (list input, [Film!]! return), the follow-up SELECT joins a VALUES (idx, pk) table over the returned keys and orders by idx. The payload list contains one entry per written row, in the order the rows were written, mirroring @lookupKey's ordering contract ("returns a list of results in the same order").
UPDATE identifies the target row by which input columns cover a primary key or unique key of the write-target table (inferred from the jOOQ catalog, no directive needed): the matched key’s columns become the WHERE clause and the remaining input fields populate the SET clause. UPSERT adds ON CONFLICT keyed on the matched key’s columns.
Most input fields fall wholly on one side of that boundary, but a @nodeId reference to another table need not: it expands into the foreign key’s child columns on the write target, and those can include columns the matched key also covers. Such a field partitions per column rather than being rejected. Its out-of-key columns become SET writes; its in-key columns are the row’s identity, so they contribute to the WHERE clause where no other field supplies them, and are otherwise only checked. Where both the reference and another field decode a value for the same column, the two are compared before any DML runs and a mismatch fails the call with an error naming both input fields, writing nothing. The foreign key forces them equal for well-formed input, so this only catches a caller who sent a reference belonging to a different row.
A reference in that shape must be non-null (ID!), and the build rejects the nullable spelling. Its in-key half is row identity and is therefore never written, so an explicit null would clear only the rest of the foreign key and leave a half-populated tuple that PostgreSQL’s default MATCH SIMPLE accepts. Such a reference can be re-pointed within the same key value; it cannot be cleared. The requirement applies only where the reference actually straddles: the same field is fine as ID wherever the matched key does not intersect the foreign key.
A field carrying the row’s own columns is still rejected when it straddles, because writing only some of a row’s key columns would move the row rather than update it.
DELETE identifies rows the same way (primary-key-or-unique-key coverage), but has no SET clause: every admitted input column is a WHERE filter, and the deleted row’s primary key is returned through RETURNING. Extra input columns beyond the matched key add further AND`ed predicates (they narrow the match, they don’t move to a `SET side). When the input covers no primary key or unique key, the DELETE is rejected at build time so a non-unique filter cannot silently broadcast; set multiRow: true on the @mutation directive to opt into a deliberate broadcast (non-key) delete that may remove more than one row.
Naming the INSERT and UPDATE write target
An INSERT or UPDATE usually names its write target on the return side: the written row’s type is the natural return. Graphitron derives the write target by this precedence:
-
The return’s own
@table(preferred). A direct@tablereturn (createFilm(…): Film,Filmcarrying@table), or a carrier payload whose single data field is a@table-element (createFilms(…): FilmsPayload,FilmsPayload { films: [Film!] }). The input’s fields resolve against the return’s table. -
@mutation(table:)on the field. For the encoded-ID/ scalar-return field whose return names no table (createFilm(in: FilmInput!): ID @mutation(typeName: INSERT, table: "film")).
Where the return names the table, a @mutation(table:) also present must name the same table (the RETURNING projection reads from the write target, so a disagreement cannot emit a coherent statement); a mismatch is rejected at build time. A field that names its table by neither rung is rejected, with the message leading with the preferred return-derived fix.
input FilmInput { # no directive: the write target is derived from the return
title: String! @field(name: "title")
}
type FilmsPayload { films: [Film!] } # Film is @table(name: "film")
type Mutation {
createFilms(in: [FilmInput!]!): FilmsPayload @mutation(typeName: INSERT)
}
Naming the DELETE write target
DELETE cannot use the return-derived rung: it commonly returns a bare ID (the encoded primary key of the deleted row), a Boolean, or a delete count, and it can never return the deleted row’s @table type, because the row is gone after the statement runs. So a DELETE names its write target on the consuming field with @mutation(table:):
input FilmDeleteInput {
filmId: Int! @field(name: "film_id")
}
type Mutation {
deleteFilm(in: FilmDeleteInput!): ID @mutation(typeName: DELETE, table: "film")
}
The input type carries no directive; its fields resolve against the table named on the field. A DELETE that does not name its table with @mutation(table:) is rejected at build time, with the message steering you to the argument. (An input type still carrying a legacy @table builds with a warning; the directive is ignored, so it does not name the table either. See the deprecated location.)
Payload-returning DELETE
@mutation(typeName: DELETE) supports two payload-carrier shapes. Both echo information about the rows the DML actually removed; neither projects non-primary-key columns from the deleted rows (the row is gone before the response can read it).
Echoing the deleted primary key (recommended)
The simplest payload returns the encoded NodeId of each deleted row:
type SlettRegelverksamlingPayload {
deletedIds: [ID!] # implicit @nodeId; encoder resolves against the write-target table's @node
}
extend type Mutation {
slettRegelverksamling(input: [RegelverksamlingDeleteInput!]!): SlettRegelverksamlingPayload
@mutation(typeName: DELETE)
}
The carrier field’s element type must be ID (single DELETE) or [ID!] (bulk DELETE). The list wrapper must be list-of-non-null; [ID] (list-of-nullable) is rejected, because every element of a successful DELETE response is the encoded PK of an actually-deleted row, the slot cannot be null. The encoder is recognised implicitly when the mutation’s write-target table backs a @node type. If the write-target table is not @node-backed, the carrier is rejected with the same diagnostic as the bare-ID DELETE path; register the table’s SDL type as @node first. To pin the encoder explicitly (recommended when grep-ability matters), attach @nodeId(typeName: "…") to the carrier field; the directive’s encoder must resolve to the same table as the mutation’s write target. An @nodeId whose encoder resolves to a different table is rejected: you would be returning IDs of a different entity than the one the DML acted on.
The response contains exactly the IDs of rows the DML actually removed.
Projecting the deleted row’s primary key onto an SDL type
The carrier may also return the @table-backed SDL type, but only when every non-nullable field on the type resolves to a primary-key column:
type Regelverksamling @table(name: "regelverksamling") @node {
id: ID! # PK; admits
navn: String # non-PK, nullable; admits, runtime returns null
beskrivelse: String! # non-PK, non-nullable; REJECTS the carrier
}
The classifier inspects every field on the element type and rejects the carrier if any non-nullable field maps to a non-PK column. Nullable non-PK fields admit and always resolve to null at runtime; this is by design — after a DELETE there is no row left to read those columns from. If your SDL type carries non-nullable non-PK fields, prefer the [ID!] shape above, or define a dedicated DeletedRegelverksamling SDL type whose non-nullable fields are PK-only.
@service-resolved fields are not admitted on the element type, nullable or not. The service would receive a PK-only row at runtime and any non-PK source parameter would silently produce null. Use the [ID!] shape and resolve service-backed data on the deleted entity through a sibling lookup if needed. FK-traversing reference fields (@reference paths to a joined target table) are also rejected on DELETE carriers, since the join cannot run after the row is gone.
Single vs bulk
Both shapes work with single and bulk DELETE; cardinality follows the carrier field’s wrapper (ID/Foo for single, [ID!]/[Foo!] for bulk).
What you can’t return
-
Arbitrary non-PK columns of the deleted row. The row is gone;
RETURNINGis narrowed to primary-key columns. If you need the full pre-delete state, snapshot it in your application code before issuing the mutation. -
A bare class-backed payload type. Use a NodeId echo or a
@tableprojection.
Constraints
-
INSERTandUPDATEderive their write target from the return (a@tablereturn, or a payload’s@table-element data field), falling back to@mutation(table:)for an encoded-ID/ scalar return (see Naming the INSERT and UPDATE write target).DELETEnames its write target with@mutation(table: "…")on the field (it cannot derive the table from its return type, since the row is gone after the statement). A field with no write-target source is rejected at build time, with the message naming the applicable fix.@tableon the input type is a deprecated location, ignored rather than consulted, so it is never a write-target source. -
UPDATEandDELETErequire the input columns to cover a primary key or unique key of the write-target table (inferred from the catalog, no directive needed); otherwise the build fails so a non-unique filter cannot silently broadcast.DELETEcan opt out withmultiRow: truefor a deliberate broadcast;UPDATEhas no broadcast shape (cover a key).@lookupKeyon a mutation input field is no longer supported (it identified rows in the pre-catalog model); remove it. -
UPDATErequires at least one input field outside the matched key (otherwise theSETclause is empty).DELETEhas no such requirement — a key-only DELETE is the canonical single-row delete.UPSERTis exempt on the SET side: an upsert with no SET-clause fields isINSERT … ON CONFLICT DO NOTHING, which is a legitimate shape. -
@mutationand@serviceare mutually exclusive on the same field. Pick one:@mutationfor fully-generated DB operations,@servicefor handing the operation to custom Java. -
Optional input fields participate in the operation only when the client provides them; absent optional fields are omitted from the column list (
INSERT) or theSETclause (UPDATE/UPSERT).
See also
-
Tutorial page 5: A first mutation walks the
INSERT/UPDATEflow against a running database. -
@lookupKeyis a Query-side lookup-key marker onARGUMENT_DEFINITION; it no longer participates in mutation row identification (that is catalog-derived). -
@tablebinds the object types whose returns carry a write target; on input types it is a deprecated location and is ignored. -
@servicefor custom-Java mutations.