Mutations
Vincent Depassier · August 29, 2026
PraxQL — Mutations
Mutations write data through the same /query endpoint as reads. You send a mutation key instead of a query key.
The mutation body
{
"refs": {
"TableAlias": "table-guid"
},
"mutation": {
"type": "insert",
"table": "TableAlias"
}
}type is insert, update or delete. mutation and query are mutually exclusive — send one, not both.
Who can mutate
The credential's table scope needs AccessLevel of write or readwrite. A read-only key gets 403 SCOPE_VIOLATION before anything is parsed. A new table scope is created as `read`, so writing is always something somebody turned on deliberately.
Call mutations from server-side code with a Server Key (sk_live_). A write-scoped key in a browser is a write-scoped key in everyone's hands.
INSERT
{
"refs": {
"Customers": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
},
"mutation": {
"type": "insert",
"table": "Customers",
"values": [
{ "Name": "Acme Corp", "Email": "hello@acme.com", "Region": "EU" },
{ "Name": "Beta Inc", "Email": "info@beta.io", "Region": "US" }
],
"returning": true
}
}Field | Description |
| Array of row objects, each mapping column name to value |
|
|
"returning": ["Name", "Email"]Columns you cannot set
These are injected by the platform, and sending any of them returns 400 INVALID_MUTATION:
Column | Behaviour |
| Generated GUID |
| Current UTC timestamp |
| The authenticated principal |
| Auto-incremented |
| Auto-assigned |
CREATEDBY being un-settable is not just hygiene — it is what makes it usable as a security anchor. See the write filter section below.
Maximum 100 rows per insert. For a bulk import, chunk it.
UPDATE
{
"refs": {
"Customers": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
},
"mutation": {
"type": "update",
"table": "Customers",
"set": {
"Status": "Inactive",
"Region": "EU"
},
"where": [
{ "field": "LastActiveDate", "op": "lt", "value": "2025-01-01" }
]
}
}Field | Description |
| Column names mapped to new values. Maximum 50 columns. |
| Required. At least one condition. |
{ "affectedRows": 12, "durationMs": 8 }DELETE
{
"refs": {
"Customers": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
},
"mutation": {
"type": "delete",
"table": "Customers",
"where": [
{ "field": "Status", "op": "eq", "value": "Archived" },
{ "field": "DeletedAt", "op": "isNull" }
]
}
}{ "affectedRows": 3, "durationMs": 5 }where is mandatory, and that is the point
An update or delete with no where is rejected with 400 INVALID_MUTATION. There is no flag to opt out.
An unscoped mutation is not a rare mistake — it is what a bug produces when a filter value comes back empty and the condition gets dropped. Requiring the clause means that bug fails loudly instead of emptying a table.
Row filters apply to writes too
If the table scope carries a row filter, it is injected into the where of every update and delete, exactly as it is into every query. The caller cannot see it, override it, or write around it: their conditions are ANDed with it, never substituted for it.
The separate write filter
A scope can carry two filters:
Filter | Applies to |
Row filter |
|
Write row filter |
|
Leave the write filter empty and one rule governs everything — a row the caller cannot read is a row they cannot modify. That is the default and it is usually right.
Set it when several people share a container. A chat channel scoped Channel = X with write access lets every member edit and delete every other member's messages, because a single filter only decides which rows are matched. Two filters express the rule you actually wanted:
read filter → Channel eq X ("read the whole room")
write filter → CREATEDBY eq {{claim:sub}} ("change only what is yours")`CREATEDBY` is the anchor to reach for. The gateway stamps it from the caller's verified token on every insert and refuses to let a client set it, so it is the one column a caller cannot forge.
An UPDATE is checked a second time after it executes: if the rows it touched do not satisfy the write filter, the transaction is rolled back rather than committed.
Column permissions
Writing a column needs CanWrite on its column scope, and `CanWrite` is off by default — the opposite of CanRead. So the moment you define column scopes on a table, every column becomes read-only until you say otherwise. A table with no column scopes at all stays fully writable.
A column the credential cannot write is rejected rather than ignored. A mutation naming it returns 400 INVALID_MUTATION and names the column — silently dropping it would let a caller believe a value was stored.
Claim placeholders
For end-user JWT callers, a mutation value can reference a claim from the token with {{claim:claimName}}. It is substituted server-side before executing:
"values": [
{
"OwnerId": "{{claim:sub}}",
"Title": "My Record"
}
]This is how multi-tenant inserts get tagged with the caller's own id without trusting the client to send it. The value comes from the validated token, so a caller cannot claim to be somebody else by editing the request body.
Or let the column do it for you
A column scope can carry a default value template using the same {{claim:…}} syntax. When set, the value is injected on every insert and cannot be overridden by the caller — even if they send that column explicitly.
The difference matters. A placeholder in your request body is your app choosing to stamp the row correctly; a default on the column scope is the platform refusing to let it be stamped any other way. For ownership columns, prefer the second.
Announcing a mutation on the Event Bus
A mutation can announce itself on an Event Bus bus the moment it commits, with no automation in the path:
{
"refs": { "m": "<table_id>" },
"mutation": {
"type": "insert",
"table": "m",
"values": [{ "Channel": "9f2c...", "Body": "hello" }],
"returning": ["Body", "Channel"],
"notify": { "bus": "chat:9f2c8ab1-...", "event": "message.created" }
}
}Field | Meaning |
| Full bus key, |
| Name subscribers switch on. Defaults to |
Three rules decide what this is safe for:
It fires after the commit, never before. Nothing is announced that could still roll back.
The event body is built from what was actually written, never from your request. With
returning, the rows go out mapped to logical column names and masked with the same rules as a read — a column the caller cannot see over REST does not arrive over the bus instead. Withoutreturning, only the row ids go out, which is enough for a subscriber to fetch what it is entitled to.It is best-effort. The row is already durable; an ephemeral fan-out is never allowed to fail a persistent write.
Publishing needs participate on the target bus, and it is checked before the write runs — so a refused notify fails the request instead of committing and then going quiet. The reserved user: topic cannot be a notify target.
Guardrails
Limit | Value |
Rows per insert | 100 |
Columns per | 50 |
Where conditions | 50 |
Command timeout | 30 seconds |