UPDATE
UPDATE through Tables.Execute. The planner picks a route from the predicate’s shape against the table’s merge_keys. For the RPC contract, see the Execute hub.
The response is a row count only — ExecuteResponse.rows_affected, plus statement_kind, max_wal_ulid and warnings. There is no strategy, pending_drain or noop field on the wire.
UPDATE requires a WHERE clause
A bare UPDATE t SET … is refused before it plans:
UPDATE without a WHERE clause is not allowed. UseWHERE TRUEto update every row, orTRUNCATE TABLEto clear the table while keeping the schema.
This is the BigQuery-aligned safety guard: a whole-table rewrite is the most expensive thing the plane exposes and the most common cause of accidental corruption. Say WHERE TRUE if you mean it. Source: dodil-tables/crates/htap-planner/src/dml.rs:159-172.
Keyed updates
Every predicate on a table that declares pk_columns routes through the WAL, then a compactor MERGE on the next drain. Safe under concurrent writes.
KeyedUpdate — point update on the PK
UPDATE events SET event_type = 'click_pricing'
WHERE id = 1 AND user_id = 'u-101';Composite PKs are handled either as (a, b) IN ((…), (…)) or as a conjunction of = literal conjuncts — one row-tuple per key.
Non-PK conjuncts in the same WHERE become an extra filter applied after the key lookup and before the upsert:
-- keys on id/user_id; `tier = 'pro'` rides along as extra_filter
UPDATE events SET event_type = 'archived'
WHERE id = 1 AND user_id = 'u-101' AND tier = 'pro';KeyedRange — a range over a single-column PK
UPDATE events SET event_type = 'archived'
WHERE id BETWEEN 1000 AND 2000;BETWEEN and >= a AND <= b are inclusive; >= a AND < b is exclusive at the top. The plane scans the key range, then writes one WAL entry per key. Ranges over a composite PK are refused rather than guessed (htap-planner/src/ir.rs:171-187).
KeyedFromSubquery — an explicit or synthesised key list
UPDATE events SET event_type = 'archived'
WHERE (id, user_id) IN (
SELECT e.id, e.user_id
FROM events e JOIN users u ON e.user_id = u.id
WHERE u.status = 'inactive'
);The plane materialises the key list, then dispatches it as a keyed update.
Non-keyed updates — WAL-bypass
This is narrower than it used to be. A non-PK predicate on a keyed table does not bypass the WAL any more. The planner synthesises
SELECT <pk> FROM <target> WHERE <your predicate>and routes the statement through KeyedFromSubquery, so every write on a keyed table lands in the WAL and cannot be silently clobbered by the compactor’s next drain. The cost is one extra read, proportional to how selective your predicate is — the planner takes that trade deliberately, because correctness beats peak throughput. Source: crates/htap-planner/src/dml.rs:893-925.
So you no longer need “drain first” or “rewrite as a subquery” workarounds. The planner does the second one for you.
NonKeyedUpdate survives for exactly one case: the target declares no pk_columns at all. Such a table has no WAL prefix, so warehouse_update is the only path and there is nothing to race against. The planner attaches a warning naming that reason:
Update routed to warehouse_update — target has no declared pk_columns, so no WAL exists for this table.
It arrives on ExecuteResponse.warnings (htap-planner/src/dml.rs:808-822). Treat it as informational, not as a hazard.
If you want keyed routing, declare a key. Add
PRIMARY KEYatCREATE TABLEtime, or recreate the table via CTAS with an explicitPRIMARY KEY. A keyless CTAS gets a hidden_rowidkey that satisfies the storage layer but will not make your own columns keyed.
Atomic multi-row updates
Keyed DML is the one thing a transaction block accepts, and the block lowers to a single atomic commit:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;On the Postgres wire this is a real buffered session with savepoints. See Execute → One statement per call for what the block will and will not accept.
See also
- Execute — Overview — RPC contract, the one-statement rule, refusals
- SELECT · INSERT · DELETE · MERGE — sibling shapes
- DDL → TRUNCATE — the sanctioned “affect every row” statement
- Data → Writing rows → UPDATE — the write-path overview and the typed write RPCs
- Maintenance → Compact — force the WAL drain