Track a table and you get insert, update and delete
alongside the query fields. This lesson covers how they work — and then the harder question
they raise, which is which writes belong in Hasura at all.
The generated mutations
Each table gets a bulk form and a _one form:
mutation {
insertBookingsOne(object: {
propertyId: "89c69134-...",
checkIn: "2026-09-14",
checkOut: "2026-09-18",
guests: 2
}) {
publicId
total
status
}
}returning is the part people miss coming from REST. The mutation gives you back the
row it just wrote, including database-generated columns — ids, defaults, computed totals
— so you rarely need a follow-up read:
mutation {
updateProperties(
where: {publicId: {_eq: $id}},
_set: {status: "PUBLISHED"}
) {
affectedRows
returning { publicId title status }
}
}Note _set. There are also _inc for numbers and, on JSON columns,
_append and _deleteKey.
Upserts
mutation {
insertPropertyAmenities(
objects: [{propertyId: $p, amenityId: $a}],
onConflict: {constraint: property_amenities_pkey, updateColumns: []}
) { affectedRows }
}updateColumns: [] is the idiom for “insert if missing, otherwise do
nothing”. The constraint name is the real Postgres constraint, so it has to exist — a
unique index is what makes an upsert possible, not the syntax.
Transactions
Several mutations in one request run in a single transaction and roll back together. Several separate requests do not, regardless of how quickly you send them.
mutation BookAndPay {
booking: insertBookingsOne(object: {...}) { publicId }
payment: insertPaymentsOne(object: {...}) { publicId }
}That is a genuine guarantee and it is also the limit of what Hasura will do for you: it is one database transaction, not a distributed one. If the second step is charging a card, no amount of GraphQL makes that atomic with the insert.
Write permissions
Insert permissions have two features that select permissions do not, and they matter more:
{
"role": "customer",
"permission": {
"columns": ["property_id", "check_in", "check_out", "guests"],
"check": {"property": {"status": {"_eq": "PUBLISHED"}}},
"set": {"guest_id": "x-hasura-user-id"}
}
}The check validates what is being written. The preset
(set) forces a column from a session variable, and the column must not be in the
allowed list — that is the whole point. If the client can supply guest_id, the
client can book as somebody else. Preset from the token and it cannot, because the value never comes
from the request.
This is also why the columns list on a write permission should be as short as you can make it. Every column a client may set is a column they may set to something you did not anticipate.
Which writes belong here?
This is the real content of the lesson, and the demo app answers it with a deliberate no.
StayHub reads everything through Hasura and writes almost nothing through it. Creating a booking goes to its own API instead, because creating a booking is not an insert. It is: check the dates are still free, recompute the price server-side, take a payment, write the row, index it for search, and email a confirmation — with the whole thing failing cleanly if the card is declined.
You can push some of that into Postgres with constraints and triggers. You should not push all of it, and the moment you try, your business rules live in three places and your database is doing network I/O.
A workable rule: if the write is a fact, Hasura is fine. If the write is a decision, put it behind your own endpoint. Marking a listing as a favourite is a fact. Confirming a booking is a decision.
When you take the second path you have two good options, and lesson 10 covers the first: an Action puts your endpoint in the same GraphQL schema, so clients never learn there were two systems. The other is simply a REST call, which is what StayHub does.
Mutations in v3 (DDN)
On the v3 sections. Everything marked v3 (DDN) is taken from the official Hasura DDN documentation as read on 2026-08-21 and was not run locally — it shows configuration, never claimed output. The v2 material was executed against a running engine.
The biggest structural difference: in v3 mutations are marked connector-dependent. The engine does not guarantee them — the connector has to implement them. A read-only connector gives you a read-only model, which is a real answer rather than a gap.
Alongside that, v3 adds native mutations, which v2 had no equivalent of (v2 had
native queries only). And custom write logic becomes a Command:
kind: Command
version: v1
definition:
name: createBooking
outputType: Booking!
arguments:
- name: propertyId
type: Uuid!
source:
dataConnectorName: business_logic
dataConnectorCommand:
procedure: createBooking
graphql:
rootFieldName: createBooking
rootFieldKind: MutationWhich is the same architectural advice this lesson gives for v2, made explicit by the product: generated writes for facts, a Command for decisions.
Next
Lesson 7 keeps a query open and lets the server push.