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

typeName

MutationType!

(required)

The SQL shape to emit. INSERT writes a row; UPDATE modifies the row identified by the input’s primary-key-or-unique-key coverage; DELETE removes the row(s) identified the same way; UPSERT emits INSERT … ON CONFLICT … DO UPDATE.

multiRow

Boolean

false

On DELETE, opts into a broadcast delete when the input covers no primary key or unique key (the filter may match more than one row). Rejected on INSERT (no WHERE clause), UPDATE (no broadcast shape; cover a key), and UPSERT.

table

String

(none)

On DELETE, names the SQL table to delete from, on the consuming field. The preferred way to give a DELETE its write target: a bare ID / Boolean / count return carries no table, so @mutation(table: "film") names it directly (the field-level analogue of @service(argMapping:)). It supersedes the deprecated @table on the input type; when both are present the field-level table: wins and the input’s @table is outranked. Wired for DELETE only; supplying it on any other verb is rejected.

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).

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; RETURNING is 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 @table projection.

Constraints

  • INSERT/UPDATE/UPSERT take their write target from the input type’s @table. For DELETE, prefer @mutation(table: "…") on the field: it names the table without the deprecated @table on the input type (a DELETE cannot derive the table from its return type, since the row is gone after the statement). A DELETE that carries neither @mutation(table:) nor @table on its input is rejected at build time, with the message naming @mutation(table:) as the fix.

  • UPDATE and DELETE require 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. DELETE can opt out with multiRow: true for a deliberate broadcast; UPDATE has no broadcast shape (cover a key). @lookupKey on a mutation input field is no longer supported (it identified rows in the pre-catalog model); remove it.

  • UPDATE requires at least one input field outside the matched key (otherwise the SET clause is empty). DELETE has no such requirement — a key-only DELETE is the canonical single-row delete. UPSERT is exempt on the SET side: an upsert with no SET-clause fields is INSERT … ON CONFLICT DO NOTHING, which is a legitimate shape.

  • @mutation and @service are mutually exclusive on the same field. Pick one: @mutation for fully-generated DB operations, @service for 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 the SET clause (UPDATE/UPSERT).

See also

  • Tutorial page 5: A first mutation walks the INSERT/UPDATE/UPSERT flow against a running database.

  • @lookupKey is a Query-side lookup-key marker on ARGUMENT_DEFINITION; it no longer participates in mutation row identification (that is catalog-derived).

  • @table establishes the input-type binding @mutation requires.

  • @service for custom-Java mutations.