Marks a mutation field as fully generated, picks the SQL shape (INSERT, UPDATE, DELETE, UPSERT), and threads the response selection set through RETURNING so writes complete in a single round trip.
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) |
On |
Input shape
A @mutation field takes exactly one @table input argument. Its fields bind to columns of that table (@field(name:), or the SDL name by default).
An input field may also be a plain input object with no @table directive: 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 table 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 that is itself @table-backed (introducing a second DML target) is a separate compound-mutation shape and is not admitted here.
Canonical example
The tutorial’s three film mutations cover the three most common shapes:
input FilmCreateInput @table(name: "film") {
title: String! @field(name: "title")
languageId: Int! @field(name: "language_id")
}
input FilmUpdateInput @table(name: "film") {
filmId: Int! @field(name: "film_id")
title: String! @field(name: "title")
}
input FilmUpsertInput @table(name: "film") {
filmId: Int! @field(name: "film_id")
title: String! @field(name: "title")
languageId: Int! @field(name: "language_id")
}
type Mutation {
createFilm(in: FilmCreateInput!): Film @mutation(typeName: INSERT)
updateFilm(in: FilmUpdateInput!): Film @mutation(typeName: UPDATE)
upsertFilm(in: FilmUpsertInput!): Film @mutation(typeName: UPSERT)
}
The INSERT shape produces:
INSERT INTO film (title, language_id)
VALUES (?, ?)
RETURNING film.film_id AS "filmId",
film.title AS "title"
The RETURNING projection narrows to the response selection set: a mutation that asks for { filmId title } retrieves only those columns; asking for { filmId title language { name } } adds a JOIN through @reference and the RETURNING is wrapped in a WITH clause so the round-trip count stays at one.
UPDATE identifies the target row by which input columns cover a primary key or unique key of the @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.
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 DELETE write target
INSERT, UPDATE, and UPSERT read their write target from the @table on the input type. DELETE is different: 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 needs no @table; its fields resolve against the table named on the field. If the input type still carries a legacy @table, it is honored as a deprecated migration bridge but outranked by @mutation(table:) when both are present (and it earns the @table deprecation warning). A DELETE that names its table neither way is rejected at build time, with the message steering you to @mutation(table:).
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 input @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 input @table registers a @node. If the input @table is not @node-backed, the carrier is rejected with the same diagnostic as the bare-ID DELETE path; register the input 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 input. 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
-
INSERT/UPDATE/UPSERTtake their write target from the input type’s@table. ForDELETE, prefer@mutation(table: "…")on the field: it names the table without the deprecated@tableon the input type (aDELETEcannot derive the table from its return type, since the row is gone after the statement). ADELETEthat carries neither@mutation(table:)nor@tableon its input is rejected at build time, with the message naming@mutation(table:)as the fix. -
UPDATEandDELETErequire the input columns to cover a primary key or unique key of the@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/UPDATE/UPSERTflow 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). -
@tableestablishes the input-type binding@mutationrequires. -
@servicefor custom-Java mutations.