Writes
Upsert
Insert-or-update by the table’s primary key. Rows are typed RowSet payloads: no SQL text, no quoting.
gRPC
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" }] }
]
}
}
}| Rule | Detail |
|---|---|
match_columns empty | Resolves 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: true | Partial 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 / IMMUTABLE | Enforced at the write boundary — 23502, 23514, 23P90, same as SQL. |
FOREIGN KEY | Enforced23503 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 rows | A 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. |
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 want | Use |
|---|---|
| Typed rows, no SQL, replace or merge by key | Upsert |
| Delete by key, no predicate | Delete |
| A long-running feed of upserts and deletes | WriteStream |
| Several rows to one table, all or nothing, one call | Commit |
A predicate, RETURNING, MERGE, INSERT … SELECT, expressions | SQL via Execute |
Multi-table atomicity, FOR UPDATE, savepoints | BEGIN … COMMIT script via Execute, or the Postgres wire |
VARCHAR(n) / TIMESTAMP(p) enforcement today | SQL via Execute or the Postgres wire |
| Foreign-key checks on a bulk feed | Commit per batch, or SQL — not WriteStream |