Skip to Content
We are live but in Staging 🎉

Writes

Upsert

Insert-or-update by the table’s primary key. Rows are typed RowSet payloads: no SQL text, no quoting.

rpc Upsert(UpsertRequest) returns (WriteAck); message UpsertRequest { string db_id = 1; string table = 2; RowSet rows = 3; // typed or Arrow repeated string match_columns = 4; // EMPTY = the declared PRIMARY KEY bool merge = 5; // true = partial-column merge optional string min_ulid = 6; }
{ "db_id": "kb-prod", "table": "users", "rows": { "typed": { "columns": [ { "name": "id", "type": { "code": "TYPE_CODE_INT64" } }, { "name": "email", "type": { "code": "TYPE_CODE_STRING" } }, { "name": "tier", "type": { "code": "TYPE_CODE_STRING" } } ], "rows": [ { "values": [{ "int64_value": "101" }, { "string_value": "[email protected]" }, { "string_value": "pro" }] } ] } } }
RuleDetail
match_columns emptyResolves to the declared PRIMARY KEY. A table without one is FAILED_PRECONDITION.
merge: false (default)Whole-row replace. A column absent from the row becomes NULL (or its default).
merge: truePartial merge: absent columns keep their current value. A merge on a key that does not exist yet must still supply every NOT NULL column, or the row is refused 23502.
NOT NULL / CHECK / IMMUTABLEEnforced at the write boundary — 23502, 23514, 23P90, same as SQL.
FOREIGN KEYEnforced23503 on Upsert and Commit: a row naming a parent key that does not exist is refused. Accepted · no effect on WriteStream, which does not probe parents.
VARCHAR(n) / CHAR(n) / TIMESTAMP(p)Partial — declared widths and precision are not applied on any typed lane yet.
Rejected rowsA row that fails validation fails the call. Rows that pass validation but cannot be applied later are dead-lettered and counted in GetDatabaseStats.rejected_rows, never silently dropped.
Still openThe Upsert, WriteStream and Commit lanes bypass the SQL funnel where VARCHAR(n) / CHAR(n) widths and TIMESTAMP(p) rounding are applied (since 0.1.43), so an overlong string arrives whole and a timestamp keeps microseconds. WriteStream also skips the foreign-key parent probe that Upsert, Commit and SQL INSERT run. Enforce those in the caller on these lanes, or write through SQL. Tracked for 0.1.44 — see Limits & differences.

WriteAck

message WriteAck { string key = 1; bool wal_written = 2; // durable; always true on OK — failures are gRPC status string wal_ulid = 3; // the watermark for your next read }

There is no row count, version or pending flag. You sent N rows; N rows are durable. Carry wal_ulid as the next read’s min_ulid when a different process must see this write.

Delete

The key-only sibling of Upsert. keys carries just the primary-key columns; there is no predicate.

rpc Delete(DeleteRequest) returns (WriteAck); message DeleteRequest { string db_id = 1; string table = 2; RowSet keys = 3; // the PK columns, one row per key repeated string match_columns = 4; // EMPTY = the declared PRIMARY KEY }

For DELETE … WHERE <predicate> use SQL through Execute.

WriteStream

The bulk / CDC channel. The client streams op-tagged batches — upserts and deletes interleaved, order preserved — and receives one ack per batch.

rpc WriteStream(stream WriteStreamRequest) returns (stream WriteStreamAck); message WriteStreamRequest { oneof msg { WriteStreamHeader header = 1; // FIRST message, exactly once UpsertBatch upsert = 2; DeleteBatch delete = 3; } } message WriteStreamHeader { string db_id = 1; string table = 2; repeated string match_columns = 3; } message UpsertBatch { RowSet rows = 1; bool merge = 2; } message DeleteBatch { RowSet keys = 1; } message WriteStreamAck { uint64 batch_seq = 1; // 1-based; the header is not acked uint32 rows_written = 2; string max_ulid = 3; // watermark }

Each batch is atomic (all or nothing) and acknowledged individually, in order. A CDC feed needs tombstones in sequence with the upserts they follow, which is why this is one stream and not two.

Commit

An atomic multi-row write to one table, in one call. No session, no BEGIN.

rpc Commit(CommitRequest) returns (CommitResponse); message CommitRequest { string db_id = 1; string table = 2; // the single target table repeated Mutation mutations = 3; // upserts + deletes, order preserved optional string min_ulid = 4; } message Mutation { oneof mutation { UpsertMutation upsert = 1; DeleteMutation delete = 2; } } message CommitResponse { string commit_ulid = 1; // the commit's identity uint64 rows_written = 2; string max_wal_ulid = 3; // watermark }

All or nothing, structurally: the whole commit is one durable write, so a validation failure in any mutation fails the call with nothing written. Naming a second table is INVALID_ARGUMENT.

For a transaction that spans tables, or that needs reads between writes, use a BEGIN … COMMIT script over Execute or the Postgres wire — both commit multiple tables atomically and carry the optimistic guard described on Transactions & concurrency.

Which lane to use

You wantUse
Typed rows, no SQL, replace or merge by keyUpsert
Delete by key, no predicateDelete
A long-running feed of upserts and deletesWriteStream
Several rows to one table, all or nothing, one callCommit
A predicate, RETURNING, MERGE, INSERT … SELECT, expressionsSQL via Execute
Multi-table atomicity, FOR UPDATE, savepointsBEGIN … COMMIT script via Execute, or the Postgres wire
VARCHAR(n) / TIMESTAMP(p) enforcement todaySQL via Execute or the Postgres wire
Foreign-key checks on a bulk feedCommit per batch, or SQL — not WriteStream