Writing data back¶
Send {"query": <Relation>, "data": <payload>} — a ComplexQuery — instead of a bare Relation
to write. data must be shaped like what query's own select would produce, edited in place :
an array of rows at the root and at any incoming join (reading either back always returns an
array), a single object at an outgoing join (reading it back returns one row, never an array).
rel denormalizes it back down to one row per relation the query touched and applies each
relation's own write_mode:
ComplexQuery also carries five flags — returns, count, stats, query_plan, sql, and
rollback — that shape what the response carries beyond the written-back selection, and work on
a plain read too (just omit data). See Complex queries.
write_mode |
Behavior | Default for |
|---|---|---|
insert |
insert new rows only, never update or delete | the root relation |
upsert |
insert or update, never delete | an outgoing join |
merge |
insert new, update matching, delete rows missing from the payload | an incoming join |
merge-new |
insert new, ignore rows that already exist, delete rows missing from the payload | — |
merge-update |
update matching rows, ignore new rows, delete rows missing from the payload | — |
update |
update matching rows only, never insert or delete | — |
deleteonly |
delete rows missing from the payload, nothing else | — |
readonly |
never write this relation (or anything nested under it) | — |
merge/merge-new/merge-update/deleteonly all delete rows the payload didn't mention, so
they're only legal on an incoming relation (see Joining and embedding
relations) — there's no coherent "rows missing from the payload" for an outgoing
one, since the referenced row isn't exclusively owned by the one pointing at it. insert is the
only mode in this table with no delete component at all — reach for it, not merge-new, when a
nested write must never remove a sibling row it didn't mention. on_conflict
names the constraint (by name, or by its column list) used to detect an existing row on
insert/upsert/merge; it defaults to the relation's primary key. insert_columns/
update_columns narrow which columns a write actually touches, beyond whatever select
already made writable.
Write order¶
A request isn't one big statement — it's every relation's own insert/update/upsert/delete, run
in a specific order, all inside one transaction. Knowing that order explains things that would
otherwise look like magic: why a brand-new parent's generated id is already available to the
child rows pointing at it, and why a row reassigned from one parent to another survives instead
of being deleted and recreated.
Phase 1 — every insert/update/upsert, before any delete. Within one relation:
- Its outgoing joins run first — the relations it points at (e.g.
properties.chain_id → chains.id, see Joining and embedding relations). Their rows are written, and whatever identity value they end up with (freshly generated or already known) is resolved before this relation's own row is written, since this relation may need it. - This relation's own row(s) are written — insert, update, or upsert, per its
write_mode— using whatever values step 1 resolved for any outgoing reference. - Its incoming joins run last — the relations that point at it (e.g.
room_types.property_id → properties.id). They need this relation's own identity value, possibly one that was only just generated in step 2, to write correctly.
So writing properties nested under chains, with room_types nested under properties
(three levels), runs chains first, then properties, then room_types — outermost
dependency to innermost dependent, regardless of how the JSON happens to be nested. This is
also why a brand-new property can arrive in the same request as its brand-new room types,
before the property has an id of its own: properties is written first and its newly
generated id is threaded into each room_types row as it's written next. The same recursion
handles a self-join (a manager field on staff pointing back at another staff row): the
manager row is written first, being an outgoing reference, then the staff row that points at
it.
Phase 2 — every delete, only after phase 1 has finished for the whole request. A
write_mode with a delete component (merge, merge-new, merge-update, deleteonly)
deletes rows that exist under its parent in the database but weren't mentioned in the payload.
These deletes run after every insert/update/upsert across the entire query tree, not just
this relation's own branch — and deepest-first: a relation's delete only runs once every
relation nested under it has already had its chance to delete. This mirrors how Postgres itself
enforces foreign keys: deleting a row before whatever still points at it is gone fails with a
foreign key violation.
Deferring every delete this way is also what makes reassignment safe. Move a room_types row
from one properties parent to another in the same request — present under its new parent's
payload, absent from its old one — and phase 1 has already re-pointed it at the new parent by
the time the old parent's "delete what's missing" delete runs; it survives, rather than being
deleted and needing to be recreated. Two delete-bearing relations reached through separate,
unrelated branches of the same query have no defined order relative to each other — the worst
case if that ever matters is a foreign key violation that rolls back the whole request, never
silent data loss.
Writability rules¶
- A column is writable only if it appears exactly once in
select(see Selecting fields), untransformed except by a coalescing operator (??,||?,coalesce) orset/get-set. A computed field — whether reached by bare name or throughcall— is never a write target: it isn't a real column to begin with. - A relation is writable only if its identity columns (the primary key, or whatever
on_conflictnames) are present and writable, by the rule above, exactly once in its ownselect. - A query is writable only if every relation in it is writable — root and every joined
relation, at every depth. One unwritable relation anywhere in the tree makes the whole
write request fail, not just that relation's own piece of it - unless it is explicitely
write: "readonly".
That last rule is the one worth pausing on, because it's easy to write a query that reads back fine and then silently isn't writable the way you'd expect. Take this nested write — the intent is to rename a property and reprice one of its room types:
{
"query": {
"relation": "properties", "schema": "hotel",
"join": {
"room_types": {
"relation": "room_types", "schema": "hotel",
"on": { "property_id": "id" },
"select": { "name": "name", "base_price": "base_price" }
}
},
"select": { "id": "id", "name": "name", "room_types": "room_types" }
},
"data": [{
"id": 1,
"name": "Marina Bay Grand Hotel",
"room_types": [{ "name": "Deluxe", "base_price": "219.00" }]
}]
}
room_types.select never includes id — its identity column — so room_types isn't writable
under the rule above, even though properties itself is and base_price is an ordinary,
otherwise-writable column. Because room_types is joined (not marked readonly), that
unwritability propagates up: the whole request is rejected before anything is written,
properties.name included, with a 400 naming the actual offending relation:
{
"status": "error",
"code": "WRITE_FORBIDDEN",
"error": "write: relation \"room_types\" is not writable — its identity columns must appear exactly once in the select output, untransformed and writable"
}
The alternative — writing properties while silently skipping room_types because it
"couldn't" — is exactly what this rule avoids: a request that reads back like it worked, having
actually left part of what you sent untouched, with nothing in the response telling you so. rel
would rather reject the whole request up front, atomically, and name the relation that broke
it, than leave you to notice a missing update later.
If a joined relation is only ever meant to be read, not written — an identity-omitting
projection like the one above, a computed-only shape, or a lookup table you never intend to
edit through this query — mark it write_mode: "readonly" explicitly. A readonly relation
(and everything nested under it) is exempt from this propagation: it can be as unwritable as it
likes without blocking the rest of the request.