skaidb query syntax
The SQL surface skaidb accepts. It's a subset of SQL — one statement per call,
no subqueries or CTEs (joins, UNION, aggregates, prepared statements, and
embedded-engine transactions are supported; see below). Rows are schema-less
documents keyed by a declared primary key; any field not present reads as
NULL.
Maintenance: this document is the source of truth for the query language. Whenever the parser/grammar changes — a new statement, clause, operator, literal form, function, or type — update this file in the same change.
Statements
A <table> reference may be qualified by a database: <database> . <table>
(e.g. shop.orders). An unqualified table resolves against the connection's
current database (see Databases below).
-- DML upsert
INSERT INTO <t> (...) [OVERRIDING SYSTEM VALUE] VALUES (...)
[ON CONFLICT DO NOTHING -- keep existing row; affected 0
| ON CONFLICT DO UPDATE SET <col> = <expr>, ...] -- merge into existing row
[RETURNING * | <expr> [AS <alias>] [, ...]] -- the rows as they landed
-- DDL
CREATE TABLE [IF NOT EXISTS] <table> (PRIMARY KEY (<col> [, <col> ...])
[, <col> [NOT NULL] [DEFAULT <expr>] [CHECK (<expr>)]] -- column item (no type: see below)
[, <col> {SERIAL | BIGSERIAL | GENERATED {ALWAYS | BY DEFAULT} AS IDENTITY [(<seq-option> ...)]}
[NOT NULL] [CHECK (<expr>)]] -- auto-increment column (owned sequence)
[, <col> GENERATED ALWAYS AS (<expr>) {STORED | VIRTUAL} [NOT NULL] [CHECK (<expr>)]]
-- generated column (NOT NULL: STORED only)
[, [CONSTRAINT <name>] CHECK (<expr>)]
[, [CONSTRAINT <name>] EXCLUDE [USING <method>] (<col> WITH { = | && | <> } [, ...]) [WHERE (<predicate>)]] -- ranges are [lo, hi) arrays
[, [CONSTRAINT <name>] FOREIGN KEY (<col> [, ...]) REFERENCES <parent> (<col> [, ...])
[ON DELETE {RESTRICT | NO ACTION | CASCADE | SET NULL}]
[ON UPDATE {RESTRICT | NO ACTION | CASCADE | SET NULL}] ...])
[WITH (ttl = <duration>, -- rows expire after this age
witness = <bool>, -- mirror to witness nodes (default true)
insert_conflict = 'replace' | 'error', -- plain INSERT of an existing
-- key: replace it (default) or fail
replication = <n>, -- per-table RF override
nodes = ['<alias-or-id>', ...], -- pin the whole table to these members
toast_threshold = <bytes>, -- store big fields out-of-line (value-TOAST)
partition_by = 'range(<col>, <interval>)', -- range partitioning (see below)
cluster_by = (<col> [, ...]), -- physical sort + identity (see below)
distribute_by = (<col> [, ...]))] -- place by this leading PK prefix:
-- rows sharing it share a replica set
-- (fixed at CREATE)
CREATE TIMESERIES TABLE [IF NOT EXISTS] <table>
(SERIES KEY (<label> [, <label> ...])
[, RETENTION <duration>] [, OOO <duration>])
CREATE ROLLUP [IF NOT EXISTS] <name> ON <ts-table> BUCKET <duration>
[RETENTION <duration>]
CREATE ROLLUP [IF NOT EXISTS] <name> ON <event-table> BUCKET <duration>
[BY (<dim> [, <dim> ...])]
AGGREGATE (count(*)|count(<col>)|sum(<col>)|min(<col>)|max(<col>) AS <alias> [, ...])
[REFRESH <duration>] [RETENTION <duration>]
-- event-table form: pre-aggregated bucket table over a
-- partitioned/clustered regular table (see below)
SHOW PARTITIONS <table> -- a range-partitioned table's partitions:
-- start/end, rows, disk bytes, serving
SHOW SERIES FROM <ts-table> [WHERE <filter>] [LIMIT <n>]
-- the series catalog: one row per series,
-- series-key columns + first_ts, last_ts;
-- served from series metadata, never samples
-- (TIMESERIES.md); needs SELECT on the table.
-- <filter> names series-key columns and
-- first_ts / last_ts (`last_ts >= now() - 15m`
-- lists the series still reporting)
DROP TABLE [IF EXISTS] <table>
-- cascades to every derived index on the table (secondary, search, vector)
CREATE [UNIQUE] INDEX [IF NOT EXISTS] <name> ON <table> (<component> [, ...]) [WITH (global = true)] [WHERE <expr>]
-- <component> := <path>[[]] | (<expr>) -- an expression component; WHERE = a partial index
-- WITH (global = true): value-sharded index — equality probes route to the value's
-- replica set instead of scattering; ranges keep the scatter path (GLOBAL_INDEXES.md)
DROP INDEX [IF EXISTS] <name>
CREATE VECTOR INDEX [IF NOT EXISTS] <name> ON <table> (<path>) DIM <n> [USING <metric>] [QUANTIZED [BINARY|INT4]] [MULTI] [SPARSE] [EMBED]
DROP VECTOR INDEX [IF EXISTS] <name>
ALTER VECTOR INDEX <name> SET (ef = <n>) -- live recall/latency tuning
CREATE GEO INDEX [IF NOT EXISTS] <name> ON <table> (<point-path>)
-- Morton/Z-order index: geo_distance / geo_bbox prune to a neighborhood
DROP GEO INDEX [IF EXISTS] <name>
CREATE SEARCH INDEX [IF NOT EXISTS] <name> ON <table> (<path> [, <path> ...])
[WITH (<option> = <literal> [, <option> = <literal> ...])]
-- options are global (analyzer, refresh_ms) or per-column (<path>.<option>)
DROP SEARCH INDEX [IF EXISTS] <name>
REBUILD SEARCH INDEX <name>
REBUILD INDEX <name>
-- every node re-derives its local copy of a secondary / vector /
-- search index from its own rows (paged, `building` meanwhile);
-- a GLOBAL index is refused
CHECK INDEX {<name> | ALL} [ON <table>] [REPAIR] [RESUME '<token>']
-- audit THIS node's indexes (secondary, geo, search, vector, and
-- GLOBAL where it holds every row) against its own rows (paged,
-- short lock holds, one walk of a table for all its indexes,
-- changes nothing); REPAIR puts missing entries back and removes
-- stale ones (secondary, geo, GLOBAL, search documents, vectors;
-- EMBED vector findings are reported only); never scattered
ALTER SEARCH INDEX <name> SET (<option> = <literal> [, ...])
-- query-time options only: synonyms, refresh_ms,
-- <col>.search_analyzer, <col>.boost (applied live, no reindex)
CREATE STREAM [IF NOT EXISTS] <name> ON <table> WHEN (<predicate>)
[WITH (start = 'now' | 'earliest' [, retention = '<duration>'])]
-- a standing filter over the table's writes; see STREAMS.md
DROP STREAM [IF EXISTS] <name>
SHOW STREAMS -- (name, table, predicate, consumers, lag)
CREATE PROCEDURE [IF NOT EXISTS] <name>([IN | OUT | INOUT] <param> <TYPE> [, ...])
BEGIN <step>; [<step>; ...] [EXCEPTION WHEN <cond> THEN <step>; ...] END
-- a step is a SQL statement, a nested CALL, or control flow:
-- DECLARE <v> <TYPE> [DEFAULT <expr>] | DECLARE <c> CURSOR FOR <select>
-- SET <v> = <expr> | SELECT ... INTO <v> [, <v>] FROM ...
-- IF <c> THEN ... [ELSEIF <c> THEN ...] [ELSE ...] END IF
-- WHILE <c> DO ... END WHILE | FOR <v> IN (<select>) DO ... END FOR
-- OPEN <c> | FETCH <c> INTO <v> [, <v>] | CLOSE <c> | LEAVE
-- RETURN [<expr> | {<k>: <expr>, ...}] | EMIT <select> | BEGIN ... END
-- see PROCEDURES.md
DROP PROCEDURE [IF EXISTS] <name>
CALL <name>(<expr> [, <expr> ...]) -- returns the last statement's result set
SHOW PROCEDURES -- (name, params, definer, created)
CREATE JOB [IF NOT EXISTS] <name>
ON SCHEDULE { EVERY '<duration>' | CRON '<expr>' }
CALL <procedure>(<expr> [, ...]) [WITH (<option> = <literal>, ...)]
-- options: timezone (UTC or a fixed +HH:MM offset; CRON only),
-- catchup, timeout, budget, ttl, result_cap; see PROCEDURES.md
DROP JOB [IF EXISTS] <name>
SHOW JOBS -- (name, source, procedure, definer, owner, next_run,
-- last_run, last_status, last_error, failures)
CREATE TRIGGER [IF NOT EXISTS] <name> ON <table> WHEN (<predicate>)
CALL <procedure>(<expr> [, ...]) [WITH (<option> = <literal>, ...)]
-- a job whose source is a change stream; owns a hidden stream
DROP TRIGGER [IF EXISTS] <name> -- removes the trigger AND its stream
SHOW TRIGGERS -- (name, table, predicate, procedure, definer, stream,
-- cursor, last_status, last_error, failures)
SUGGEST '<text>' ON <index> [COLUMN <col>] [LIMIT <n>]
PUBLISH '<topic>' PAYLOAD <expr> [QOS 0|1|2] [RETAIN]
EXPLAIN SCORE <select> FOR <pk-literal>
EXPLAIN <statement>
EXPLAIN ANALYZE <select>
ALTER TABLE <table> RENAME TO <new_table>
ALTER TABLE <table> RENAME COLUMN <from> TO <to>
ALTER TABLE <table> ADD [CONSTRAINT <name>] FOREIGN KEY (<col> [, ...])
REFERENCES <parent> (<col> [, ...]) [ON DELETE <action>] [ON UPDATE <action>]
[NOT VALID] -- skip checking existing rows
ALTER TABLE <table> ADD [CONSTRAINT <name>] CHECK (<expr>) [NOT VALID]
ALTER TABLE <table> ADD [CONSTRAINT <name>] EXCLUDE [USING <method>] (<col> WITH { = | && | <> } [, ...])
[WHERE (<predicate>)] [NOT VALID] -- checks existing rows unless NOT VALID
ALTER TABLE <table> VALIDATE CONSTRAINT <name> -- FOREIGN KEY, CHECK or EXCLUDE
ALTER TABLE <table> DROP CONSTRAINT [IF EXISTS] <name>
ALTER TABLE <table> ALTER [COLUMN] <col> SET NOT NULL -- checks existing rows first
ALTER TABLE <table> ALTER [COLUMN] <col> DROP NOT NULL
ALTER TABLE <table> ALTER [COLUMN] <col> SET DEFAULT <expr>
ALTER TABLE <table> ALTER [COLUMN] <col> DROP DEFAULT
ALTER TABLE <table> ALTER [COLUMN] <col> [SET DATA] TYPE <type> -- bind to an enum type / domain; checks existing rows
ALTER TABLE <table> ALTER [COLUMN] <col> ADD GENERATED {ALWAYS | BY DEFAULT} AS IDENTITY
[(<seq-option> ...)] -- existing rows must already have the column
ALTER TABLE <table> ALTER [COLUMN] <col> DROP IDENTITY [IF EXISTS]
ALTER TABLE <table> ALTER [COLUMN] <col> ADD GENERATED ALWAYS AS (<expr>) STORED -- rewrites every row
ALTER TABLE <table> ALTER [COLUMN] <col> ADD GENERATED ALWAYS AS (<expr>) VIRTUAL -- touches no row
ALTER TABLE <table> ALTER [COLUMN] <col> SET EXPRESSION AS (<expr>) -- keeps the kind; STORED rewrites every row
ALTER TABLE <table> ALTER [COLUMN] <col> DROP EXPRESSION [IF EXISTS] -- STORED values stay as data
-- Sequences (database-scoped counters; <seq-option> is any of
-- INCREMENT [BY] <n> | MINVALUE <n> | NO MINVALUE | MAXVALUE <n> | NO MAXVALUE
-- | START [WITH] <n> | CACHE <n> | CYCLE | NO CYCLE)
CREATE SEQUENCE [IF NOT EXISTS] <name> [<seq-option> ...] [OWNED BY <table>.<col> | OWNED BY NONE]
ALTER SEQUENCE [IF EXISTS] <name> [<seq-option> ...] [OWNED BY ...] [RESTART [WITH <n>]]
DROP SEQUENCE [IF EXISTS] <name> [, <name> ...]
-- Common table expressions (statement-scoped views; SELECT statements only)
WITH <name> [(<col> [, ...])] AS (<select>) [, ...] <select>
-- Enum types and domains (named constraint bundles a column item names)
CREATE TYPE [IF NOT EXISTS] <name> AS ENUM ('<v>' [, ...]) -- the order is the type's order
ALTER TYPE <name> ADD VALUE [IF NOT EXISTS] '<v>' [BEFORE '<w>' | AFTER '<w>']
DROP TYPE [IF EXISTS] <name> [, ...]
CREATE DOMAIN [IF NOT EXISTS] <name> [AS] [NOT NULL] [DEFAULT <expr>] [[CONSTRAINT <n>] CHECK (<expr over VALUE>)] [...]
ALTER DOMAIN <name> SET DEFAULT <expr> | DROP DEFAULT
ALTER DOMAIN <name> ADD [CONSTRAINT <n>] CHECK (<expr over VALUE>) [NOT VALID] -- checks the bound columns' rows
ALTER DOMAIN <name> DROP CONSTRAINT [IF EXISTS] <n>
ALTER DOMAIN <name> RENAME TO <new_name>
DROP DOMAIN [IF EXISTS] <name> [, ...]
-- and in CREATE TABLE a column item may name one: <col> <type> [NOT NULL] [DEFAULT ...] ...
-- Views (a stored SELECT; a materialized view stores the result)
CREATE [OR REPLACE] VIEW [IF NOT EXISTS] <name> [(<col> [, ...])] AS <select>
[WITH [CASCADED | LOCAL] CHECK OPTION] -- writes through the view must stay visible
ALTER VIEW <name> RENAME TO <new-name>
ALTER VIEW <name> [(<col> [, ...])] AS <select> [WITH [CASCADED | LOCAL] CHECK OPTION]
DROP VIEW [IF EXISTS] <name> [, <name> ...]
CREATE MATERIALIZED VIEW [IF NOT EXISTS] <name> [(<col> [, ...])]
[WITH (refresh = '<interval>')] AS <select> [WITH [NO] DATA]
REFRESH MATERIALIZED VIEW [CONCURRENTLY] <name> -- re-runs the body; affected = new row count
CREATE [UNIQUE] INDEX [IF NOT EXISTS] <name> ON <matview> (...) -- maintained by every refresh
DROP MATERIALIZED VIEW [IF EXISTS] <name> [, <name> ...]
-- CREATE TABLE … AS (the full CREATE TABLE head, then a query)
CREATE TABLE [IF NOT EXISTS] <table> (PRIMARY KEY (<col> [, ...]) [, <item> ...])
[WITH (...)] AS <select> [WITH [NO] DATA]
SELECT nextval('<seq>') -- also in a DEFAULT and an INSERT's VALUES
SELECT setval('<seq>', <n> [, <is_called>]) -- FROM-less SELECT only
SELECT currval('<seq>') / SELECT lastval() -- this connection's last nextval; any expression
-- DML
INSERT INTO <table> (<col> [, <col> ...]) VALUES (<expr> | DEFAULT, ...) [, (<expr>, ...) ...]
INSERT INTO <table> [(<col> [, <col> ...])] <select> -- no column list: the query's output names
[ON CONFLICT ...] [RETURNING ...]
UPDATE <table> SET <path> = <expr> [, <path> = <expr> ...] [WHERE <expr>]
[RETURNING * | <expr> [AS <alias>] [, ...]] -- the rows as updated
DELETE FROM <table> [WHERE <expr>]
[RETURNING * | <expr> [AS <alias>] [, ...]] -- the rows as they were
-- Query
SELECT <expr> [[AS] <alias>] [, ...] -- no FROM: constant projection, one row
-- (`SELECT 1` liveness probe; no other
-- clause may follow; `*` needs a table)
SELECT [DISTINCT | DISTINCT ON (<expr> [, ...])] <select-item> [, <select-item> ...]
FROM <table> [[AS] <alias>]
[ <join> ... ]
[NEAREST (<path>, <query-vector>, <k>) ...] -- several legs, fused by RANK BY RRF
[WHERE <expr>]
[RANK BY RRF [(<constant>)]] -- hybrid: fuse NEAREST + WHERE-search by RRF
[RERANK [ON <col>] [WITH '<model>'] [QUERY '<text>'] [TOP <n>] [TIMEOUT <ms>]]
-- second-stage cross-encoder reranking
[GROUP BY <expr> [, <expr> ...] [TOP <k> BY <expr> [ASC|DESC]]]
[HAVING <expr>]
[ { UNION | INTERSECT | EXCEPT } [ALL] <select> ... ] -- applied left to right
[ORDER BY <expr> [ASC|DESC] [NULLS FIRST|NULLS LAST] [, <expr> [ASC|DESC] [NULLS FIRST|NULLS LAST] ...]]
[LIMIT <n>] [OFFSET <n>]
[AFTER (<sort-value>, <pk-value>)] -- deep pagination: keyset cursor (search queries)
[AS OF <instant>] -- point-in-time read (unix ms, timestamp string, or now() - <ms>)
<join> := [INNER | LEFT [OUTER] | RIGHT [OUTER] | FULL [OUTER] | CROSS] JOIN <table> [[AS] <alias>] [ON <expr>]
-- Transactions (see note)
BEGIN [TRANSACTION] -- embedded / standalone driver connections
BEGIN ATOMIC [TRANSACTION] -- cluster: cross-partition txn (two-phase commit)
BEGIN ATOMIC PARTITION (<col> = <literal> [, ...]) -- cluster: single-shard txn
COMMIT [TRANSACTION]
ROLLBACK [TRANSACTION]
-- Databases (see note)
CREATE DATABASE [IF NOT EXISTS] <name>
DROP DATABASE [IF EXISTS] <name>
USE [DATABASE] <name>
-- Users, roles, grants (see Access control note)
CREATE USER [IF NOT EXISTS] <name> PASSWORD '<password>'
CREATE USER [IF NOT EXISTS] <name> GSSAPI -- external Kerberos principal,
-- no local secret; <name> is the
-- principal, e.g. "user@REALM"
-- (double-quote it — it contains
-- '@' and '.')
ALTER USER <name> PASSWORD '<password>'
DROP USER [IF EXISTS] <name>
CREATE ROLE [IF NOT EXISTS] <name>
DROP ROLE [IF EXISTS] <name>
GRANT <privilege> [(<column>, …)] ON { <table> | DATABASE <db> | TOPIC '<filter>' | * } TO <role>
REVOKE <privilege> [(<column>, …)] ON { <table> | DATABASE <db> | TOPIC '<filter>' | * } FROM <role>
GRANT ROLE <role> TO <user>
REVOKE ROLE <role> FROM <user>
SHOW GRANTS [FOR <role>]
-- Introspection (read-only catalog)
SHOW TABLES
SHOW CREATE TABLE <table> -- the table's DDL + its indexes + constraints, replayable
SHOW INDEXES
SHOW CONSTRAINTS [ON <table>] -- NOT NULL / DEFAULT / IDENTITY / GENERATED / CHECK / FOREIGN KEY, one view
SHOW FOREIGN KEYS [ON <table>] -- foreign keys declared on / referencing a table
SHOW SEQUENCES -- every sequence of the current database + its counter
SHOW VIEWS -- views and materialized views of the current database, dependency order
SHOW TYPES -- enum types and domains of the current database (SHOW DOMAINS is the same)
SHOW STATUS
SHOW DATABASES
-- Admin control plane (network server only; reads need MONITOR on *,
-- mutations need ADMIN on *; ADMIN implies MONITOR)
SHOW CLUSTER -- ring, members, epoch, liveness
SHOW CONFIG [LIKE '<pattern>'] -- flattened keys, secrets masked
SET CONFIG <section.field> = <literal> -- live-mutable keys apply instantly (ADMIN)
SHOW SLOW QUERIES [LIMIT <n>] -- masked slow-query sample
SHOW QUERIES -- statements running NOW on this node
-- (id, user, db, via, elapsed_ms,
-- rows_examined, sql)
SHOW ADVICE [FOR DATABASE d] [LIMIT n] -- the advisor's findings the caller may
-- see (docs/ADVISOR.md); no privilege
ALTER ADVICE '<id>' SET STATUS '<status>' [NOTE '…'] -- acknowledged | dismissed | fixed |
-- false_positive | new, on a visible finding
SHOW ADVISOR -- settings, advisor node, last run (MONITOR)
ALTER ADVISOR SET (schedule = '5m'|'off', model_schedule = '30m'|'off', on_observation = false,
docs_grounding = true, sample_databases = 'd1,d2', categories = 'database,os,bug',
node = '<member>', enabled = true, provider = '<name>'|'none',
data_sharing = 'shapes'|'none'|'samples',
token_budget_per_day = 300000, max_runs_per_hour = 4) -- ADMIN
CALL advisor.run() -- the rules now, then the model stage when
-- a provider is set (ADMIN); returns SHOW ADVICE
CALL advisor.run('<db>') -- scoped model run for one database (anyone
-- who may read it); its database findings only
CALL advisor.export_bug_report('<id>') -- ADMIN: a Markdown bug report for one finding,
-- written by the provider, stored on the finding
CALL advisor.digest() -- ADVISOR ON * / ADMIN: the digest as one JSON row
-- (the MCP pull mode)
CALL advisor.report('<findings json>') -- ADVISOR ON * / ADMIN: land an external agent's
-- findings (validated; origin 'external')
CREATE MODEL PROVIDER [IF NOT EXISTS] <name> [TYPE 'anthropic'|'openai'|'openai_compatible'|'ollama'|'embedding'|'rerank']
WITH (endpoint = 'https://…', model = '…', api_key = 'env:NAME'|'file:PATH'|'cmd:PATH',
timeout_secs = 120, max_output_tokens = 8000, fallbacks = true,
tls_verify = 'system'|'insecure',
reasoning_effort = 'none'|'low'|'medium'|'high'|…,
dim = 768, batch_size = 32, active = true) -- ADMIN; the key is a REFERENCE, never a
-- literal; dim/batch_size/active: embedding and
-- rerank types (the active one replaces [inference])
ALTER MODEL PROVIDER <name> SET (…) -- ADMIN
DROP MODEL PROVIDER [IF EXISTS] <name> -- ADMIN
SHOW MODEL PROVIDERS -- MONITOR; the key's reference, never its value
SHOW MAINTENANCE -- background jobs running NOW on this
-- node (id, kind, target, elapsed_ms,
-- progress — "tables 37/415 — verifying:
-- t@peer (since +12 s)", "index 1/3 x:
-- 20480 rows read, 20480 queued"): anti-entropy passes,
-- content-digest builds, index/vector/
-- geo backfills, search catch-ups,
-- hint drains, flush builds,
-- compactions, embed drains
KILL QUERY <id> -- cooperatively terminate a running
-- statement by its SHOW QUERIES id (ADMIN);
-- it fails with "terminated by KILL QUERY"
-- at its next scan tick, or within 0.2 s
-- while it waits on another member's reply
RESET OOM COUNTER [ON '<node>'] -- zero the OOM-kill counter reported in
-- node_stats/host stats, on every member
-- AND registered witness, or on one node
-- by its id / witness id (ADMIN); returns
-- (node, absorbed, status) — witnesses
-- apply on their next pull cycle
-- (absorbed = NULL, status "stamped")
REPAIR CLUSTER -- one anti-entropy pass on every member; answers
-- repaired / complete / incomplete.<member>
-- (complete = every member reconciled every
-- table with every peer; see CLUSTER_REPAIR.md)
RECLAIM -- drop unowned keys/series
ALTER CLUSTER ADD NODE '<host:port>'
ALTER CLUSTER REMOVE NODE '<id>' [FORCE] -- FORCE: drop a node that is down or
-- wedged WITHOUT draining it (then
-- REPAIR CLUSTER re-replicates its keys)
ALTER CLUSTER SET NAME '<name>' -- rename the cluster (ADMIN)
ALTER NODE '<alias|dotted|id>' SET NAME '<n>' -- rename a member/witness alias (ADMIN)
ALTER TABLE <table> SET (ttl = <dur> -- live TTL change (0 clears)
| witness = <bool> -- toggle witness mirroring
| insert_conflict = 'replace' | 'error' -- plain-INSERT conflict rule
| replication = <n> -- online placement transition
| nodes = ['<ref>',..] -- online pin change
| toast_threshold = <bytes> -- value-TOAST on new writes (0 = off)
| placement_finalized = true) -- operator escape hatch
ALTER TABLE <table> SET (cluster_by = (<col> [, ...])) -- adopt a clustering key online (standalone, embedded, or a full-copy table on a cluster)
ALTER TABLE <ts-table> SET (retention = <dur> -- live retention change (0 clears)
| ooo = <dur>) -- live out-of-order window change
-- Session (binary-protocol connections)
SET CONSISTENCY { ONE | QUORUM | ALL } -- per-connection override
SET SCAN BUDGET ROWS <n> [BYTES <n>] -- session scan limits (tightening-only)
SET SCAN BUDGET DEFAULT -- clear the session override
-- Backup & restore (ADMIN)
BACKUP TO '<path>' -- crash-consistent copy of this node's data dir
BACKUP CLUSTER TO '<path>' -- every member backs up + one shared cut instant;
-- '{node}' in the path = each member's own id
-- (192_168_7_174_7100) for a shared mount
RESTORE FROM '<path>' -- embedded / single node only; old data kept aside
RESTORE FROM '<path>' TO TIMESTAMP '<when>' -- point-in-time recovery
CREATE TABLEdeclares the primary key and constraints — there is no typed column list; documents are schema-less. A composite PK lists several columns. A column item (name NOT NULL DEFAULT 'x' CHECK (…)) constrains a field rows may carry, never its type or existence (name TEXT NOT NULLis a parse error) — see Column constraints below.WITH (memory = true)makes the table RAM-only: no write-ahead log fsyncs, never flushed to disk, empty after a restart, and skipped by repair/reshard data motion — for short-lived bounded data (node stats, caches); pair with attl. Indexes on memory tables are not supported.WITH (ttl = <duration>)makes rows expire: a row older than the TTL (measured from its write's HLC timestamp) becomes invisible to every read and is physically dropped at the next compaction. TTL is a read-visibility rule applied uniformly on every replica (the stamped data still replicates, so expiry converges regardless of which node serves the read). Useful for caches, sessions, and rolling event windows. TTL is live-tunable:ALTER TABLE t SET (ttl = 30d)applies to reads immediately — shortening it can expire existing rows at once, and because expiry is lazy, widening or clearing it (ttl = 0) un-expires rows compaction hasn't physically reclaimed yet. Visibility is immediate; space comes back shortly after. Flush is size-triggered, so a low-write expiring table would otherwise hold every expired row in memory until it happened to cross the flush threshold — a background sweep flushes and compacts such tables everystorage.ttl_reclaim_interval_secs(900 by default,0disables). Size an expiring table for its TTL plus about one sweep interval, not for its TTL exactly. A reclaimed row's index entries (secondary, geo, full-text, vector) are removed with it by the background maintenance tick; an expired row not yet reclaimed keeps them (reads skip it).CREATE INDEXwith one path is a single-column index; with several it is a composite index (ordered left-to-right). See indexing notes below. The target may be an event rollup's bucket table (CREATE INDEX i ON r (total)): the same range-capable index, maintained by every refresh — see Event-table rollups under the partitioning notes.WITH (global = true)makes it a global (value-sharded) index: entries live in an internal replicated table placed on the ring by indexed value, so a full-tuple equality/INprobe routes to one replica set instead of scattering to every member — the RF < members win; local stays the default. Ranges and partial prefixes keep the scatter path. The DDL acks at schema-apply and backfills in the background (SHOW INDEXESsaysglobal (building); probes fall back to scatter until ready). Guide: INDEXING.md.- A
[]suffix on one path (CREATE INDEX i ON t (account, labels[])) marks a multikey component: the value there is an array and each element gets its own index entry, solabels = 'x'(element containment) becomes an index probe — including exact index-only counts. At most one[]per index. The planner uses a multikey index only when every column through the[]component is equality-constrained; other shapes (ranges or sorts on the array column) fall back to a scan. CREATE VECTOR INDEXbuilds an HNSW index for nearest-neighbor search over the float array at<path>.DIM <n>(the vector dimension) is required;USING <metric>iscosine(default),l2, ordot.QUANTIZEDstoresSPARSEindexes a column of(dimension, weight)pairs — a learned-sparse model's output — invertedly, and scores it EXACTLY by dot product;DIMis not required and the query isNEAREST (col, sparse([dims], [weights]), k).MULTIindexes an ARRAY OF VECTORS per row (late interaction): every sub-vector becomes a graph node, hits collapse back to rows, and a query — one vector, or an array of vectors — scores each row by MaxSim (the sum of per-query-vector best distances).SELECT DISTINCT ON (g) … NEARESTis grouped vector search: the best row per group, retrieving enough candidates to cover the groups.QUANTIZEDstores int8 scalar-quantized vectors in the in-RAM graph (4× less vector RAM),QUANTIZED BINARYone sign bit per dimension (32× less, Hamming-steered traversal),QUANTIZED INT4two components per byte (8× less, real distances); either way queries over-fetch and rescore the top-k against the exact row vectors, so returned distances stay exact (build-time choice — rebuild to change; not combinable withEMBED). It broadcasts across the cluster so every node indexes its shard. Query it with theNEARESTclause (see Vector search below and VECTOR.md).CREATE GEO INDEXbuilds a Morton (Z-order) spatial index over the{lat, lon}point column at<point-path>, sogeo_distance/geo_bboxpredicates in aWHEREscan a small set of code ranges instead of the whole table — no query change, the index is used transparently when present. It broadcasts across the cluster (each node indexes its shard), maintains itself on write, backfills existing rows in the background, and persists on disk (no rebuild on restart). There is nothing to configure; a row whose column is not a readable point is simply not indexed. See GEO.md.CREATE STREAMdeclares a standing filter over a table's writes: every committed change to<table>is matched against theWHENpredicate, which is an ordinary boolean expression evaluated like aWHEREclause.retentionaccepts a duration ('24h','90m', or a bare30d);startaccepts only'now'— backfilling from an earlier point is not implemented, and astartthat cannot be honoured is refused rather than silently ignored. The predicate is stored as text and re-parsed on open, which is also whatSHOW STREAMSdisplays.- Events land in a table you can read today. Each matching write
appends to
_stream_<name>, an ordinary table in the same database: columnsid(position),op,k(the row's primary-key value),ts,doc(the row). It replicates like any table,retentionis its TTL, and a replay is an ordinary ordered read:SELECT * FROM _stream_orders WHERE id > '<last>' ORDER BY id LIMIT 500. Dropping the stream drops the log. Push delivery to consumers does not exist yet — nothing is published to subscribers, soconsumersandlagread 0 — and only inserts/updates emit: deletes do not yet. A stream that matches most writes roughly doubles the table's write volume, since every matching change writes a second replicated row. CREATE PROCEDUREstores a statement list in the catalog thatCALLruns server-side, so a fixed sequence of statements costs one round trip instead of N. A body takesSELECT,INSERT,UPDATE,DELETEand nestedCALL— no DDL, noBEGIN/COMMIT, no dynamic SQL — and each statement ends with;, including the last. Nesting is capped at 16 deep, which is what ends a recursive cycle. Parameters carry a declared type (TEXT,INT,FLOAT,BOOL,TIMESTAMP,JSON,ARRAY,ANY) checked against the argument at the call; the store stays schema-less, so the type is a contract on the call and nothing else. ACALLreturns the last statement's result set, inherits the caller's scan budget and deadline, and runs with invoker rights:EXECUTEon the procedure plus every privilege the body itself needs.- Control flow is a PSM subset, not an embedded VM: every leaf is a
statement the ordinary executor already runs. A
WHILEhas no iteration cap — it is bounded by the caller's scan budget and deadline, which is the only termination guarantee — and anEXCEPTIONhandler can catch data errors (NOT_FOUND,CONSTRAINT,UNIQUE_VIOLATION,FOREIGN_KEY_VIOLATION,NOT_NULL_VIOLATION,CHECK_VIOLATION,TYPE_ERROR,UNSUPPORTED,OTHERS) but never a resource limit or a storage/cluster error. A cursor is a resumable keyset scan over the primary key, not a snapshot: it sees writes made while it is open. See PROCEDURES.md. CREATE JOBruns a stored procedure on a schedule —EVERY '<duration>'(a duration since the last tick) orCRON '<expr>'(calendar instants, five fields, no seconds). Exactly one node runs each job: the ring picks a preferred owner and a linearizable CAS lease makes that correct. A job runs as the role that created it, always with an explicit timeout and row budget (an unattended run has no caller to inherit one from), and its state and history live in the feature-owned_job_state/_job_runstables, which clients may read and nothing else. Delivery is at-least-once, so a body should key its writes onSCHEDULED_AT()— the logical tick, stable across attempts. Timezones areUTCor a fixed+HH:MMoffset; named zones are refused, since skaidb embeds no timezone database. See PROCEDURES.md.CREATE TRIGGERis the same job with a change stream as its source instead of a schedule: it owns a hidden stream carrying pre-images, andDROP TRIGGERremoves both. The body seesNEW,OLD(NULLon an insert),EVENT()andOP, all from the log rather than from a re-read. It fires from the log, never inline on the write path. A cascade cycle between triggers is refused at declaration — a cascade without one is a DAG and terminates by construction.CREATE SEARCH INDEXbuilds a full-text (BM25) index over the listed document paths. Global options:analyzer('standard'default,'folding','whitespace','keyword','ngram(min,max)','edge_ngram(min,max)', or a language like'english'— full list in SEARCH.md) andrefresh_ms(integer, default1000— how quickly writes become searchable). Per-column options use the path as prefix:<path>.type('text'default,'keyword','long','double','bool','date'),<path>.analyzer,<path>.search_analyzer,<path>.boost(number),<path>.keyword(boolean — adds a<path>.keywordexact-match twin), and<path>.copy_to(composite target field). Query it withMATCH()/SEARCH()predicates andscore()(see Full-text search below and SEARCH.md);REBUILD SEARCH INDEXre-indexes the table from scratch (recovery escape hatch). On a server it returns at once and the re-index runs in the background in short pages; searches on the index error "rebuilding … retry shortly" until it completes, and writes made meanwhile are kept.CHECK INDEX <name> | ALL [ON <table>] [REPAIR] [RESUME '<token>']audits the local copy of indexes on the node it is sent to (never scattered — run it on each member) against what that node's rows produce: a secondary index's entries (one per value tuple), a geo index's (one per readable point), a search index's documents (one per row with a value an indexed column takes), a vector index's graph nodes (one per vector of the index's dimension;MULTIone per vector of the row,SPARSEone per non-empty sparse vector,EMBEDone per non-empty text once embedded), and a GLOBAL index's entries — on a node that holds every row of its table and every entry (a standalone server, a full-copy cluster table); elsewhere it is listedskipped: sharded. Every row's entries are probed in the index (an absent one is missing), then the index is matched against what the rows produced — a hash ledger, so only entries in buckets that do not match have their row read back (an entry no stored row produces is stale; on a UNIQUE index two current entries with one value are a duplicate). A search index's committed documents are read in key order alongside the rows instead (a document for a row that is gone or has nothing indexable is stale, and a row indexed twice has one stale document); a vector index is matched in node-id order. A clean table costs one sequential pass over the table — shared by all its indexes — and two over each index, no random read.ALLcovers every index of the current database, or of<table>; a named index on a range-partitioned table covers its per-partition copies. It walks in pages, each under its own short read hold of the engine lock (about 8 ms, cut at 25 ms), pausing between pages so writers go first, with memory bounded to a page and the ledgers (at most 12 MB for one table's indexes) — never one hold for the table. One row per index, table by table and in name order within a table:index,table,status(ok,drift,repaired,partial,skipped: building,skipped: sharded,stopped: index dropped|rebuilding|redefined),rows,entries(documents, nodes),missing,stale,duplicates,in_flight(rows and entries whose index maintenance was still on its way — queued, a search write not yet committed, a text waiting to be embedded, or a GLOBAL entry and its row written less than 30 s ago on a cluster member; judged once more at the end, and only counted when still on its way then; never drift),repaired,samples(up to five primary keys per kind, e.g.missing [42] -> ["s3"],stale [7] -> (52.500000, 13.400000),missing [5] -> document,stale [9] -> vector),resume,elapsed_ms. The statement timeout stops it cleanly at 80 % ofstorage.statement_timeout_secs: the row sayspartialandresumeholds a token (the indexes of one table cut in their shared walk carry the same one);CHECK INDEX … RESUME '<token>'continues from it (counts are per statement; the ledger lives for one statement, so a resumed check that has passed its row walk reads every remaining entry's row back — correct, and slower).CHECK INDEX ALLstarts no table the rest of the statement is unlikely to finish, and one cut after another table ran first restarts from its first row onRESUME(itspartialrows report no rows), so only a table too large for one statement is finished entry by entry.KILL QUERYstops it with the kill error;SHOW QUERIESshows its progress. A row is judged by its last-writer-wins version across every storage source (what a scan returns), never by a point read's first hit.REPAIRre-judges each finding of a secondary, geo or GLOBAL index under a short write hold (a key whose maintenance is on its way again is left to it), puts a missing entry back or removes a stale one, syncs the WAL after the hold, and logs a line per index; it is idempotent and the index keeps serving reads throughout. A GLOBAL repair writes this node's copy of the entry table, and anti-entropy carries it to the other replicas. A search index's findings are repaired the same way, key by key: a row missing its document is indexed again from its stored version, a document no stored version stands behind (row gone, or no indexable value) is removed, both stamped with the row's HLC; the index is committed after each hold, so a repair survives a crash. No whole-index rebuild, no long lock hold — use it instead ofREBUILD SEARCH INDEXfor drift. A vector index's findings are repaired row by row too: the row's vectors are derived again from its stored version (a lost one inserted, one whose row is gone or holds no vector removed); the graph is in memory and the repair is durable at the next vector checkpoint (every 10 minutes, and at a clean stop) — a crash before it leaves the finding for the next check. A managed (EMBED) index's findings are reported only (REBUILD INDEXre-derives it); duplicates are reported, never repaired. Privilege: SELECT on the table (on the database forALLwithoutON);REPAIRneeds CREATE, likeREBUILD INDEX.<select-item>is*(all fields seen in the result rows) or<expr> [[AS] <alias>].ALTER TABLE … RENAME TOrenames a table (moving its on-disk data and repointing its indexes);RENAME COLUMN from TO torewrites that field in every row (recomputing the primary key if it is a key column) and rebuilds any index that referenced it. The store is schema-less, so there is noADD/DROP COLUMN— a field simply exists in the rows that set it.CREATE UNIQUE INDEX <i> ON <t> (<path>[, ...])constrains a value (or value tuple) to at most one row — the second unique axis a primary key cannot express (userskeyed byidbutemailunique too), and composite uniqueness beside a surrogate key. A violating write fails with a distinctunique violationerror (catchable separately from other constraint errors) and writes nothing. Rows where any indexed path is absent or NULL are unconstrained, matching SQL. Uniqueness is enforced cluster-wide, not per shard: the constraint is a reservation keyed by the value itself, claimed through the same per-key consensus round asUPDATE, so concurrent writers racing one value yield exactly one winner.UPDATEenforces and maintains the constraint likeINSERT: updating INTO a taken value is rejected (the row untouched), updating a row off a value frees it, the moved-to value is claimed, and a multi-rowUPDATEwhose own rows would collide on a unique value is rejected whole before anything is written (a collision with an EXISTING value still errors at the colliding row, so earlier rows of a multi-row statement stay applied — the same mid-statement semantics as any other row error). AnUPDATEthat assigns a primary-key column MOVES the row: a unique value it keeps across the move stays its own; a new key already held by a row the statement does not move is a duplicate-key error (<table>_pkey) on every table, UNIQUE or not — never a silent overwrite; two rows given one new key are refused; rows exchanging keys (a swap, any cycle) move as one unit (a cycle larger than the 64 MiB staging bound is refused). Not supported: a swap/cycle on a table with a FOREIGN KEY (error), and on a cluster a swap/cycle or a PK change on a UNIQUE table; inside a transaction a move to a LOWER key on a UNIQUE table is refused at COMMIT. Decimal unique values compare exactly (1.0and1.00are one value;1.0and1.000000001are two). Deleting a row frees its values.CREATE UNIQUE INDEXover data that already contains duplicates fails rather than silently tolerating them. Cannot be combined withWITH (global = true), and a component cannot be an array element (tags[]) — uniqueness per element is not enforced, so the DDL is refused. On a cluster,UPDATE/upserts on unique-indexed tables take the plain read-compute-put path rather than the per-row linearizable round (same documented fallback as toast/gidx tables); the VALUE claim itself is still consensus-serialized, so uniqueness stays exactly-one-winner.FOREIGN KEY (<cols>) REFERENCES <parent> (<cols>)— declared inline inCREATE TABLEafter the primary key, or added later withALTER TABLE … ADD [CONSTRAINT <name>] FOREIGN KEY …— makes the child's column tuple point at an existing parent row. The referenced columns must be the parent's primary key (its full key, including anycluster_bycolumns) or exactly the columns of one of itsUNIQUEindexes; arity and order must match. A child row whose tuple has no parent is refused with aforeign key violationerror (procedures catch it asFOREIGN_KEY_VIOLATION) and the whole statement writes nothing. A tuple with any NULL or absent column is unconstrained (MATCH SIMPLE). The parent side acts per clause:RESTRICT/NO ACTION(the default) refuses deleting a referenced parent row or changing its referenced tuple;CASCADEdeletes the children with the parent, or rewrites their tuple to the parent's new one;SET NULLnulls the children's columns. Actions chain through several levels, a violation anywhere down the chain fails the whole statement, and a chain deeper than 16 levels is refused. Self-references are allowed — a tree inserted in one multi-rowINSERTsatisfies itself whatever the row order. Both tables must be regular row tables in the same database: the parent cannot be a time-series, memory, TTL or range-partitioned table, the child cannot be memory or partitioned, and constraint columns are top-level fields. The default constraint name isfk_<table>_<col>[_<col>…]. A supporting index on the child columns is required: an existing local index whose leading paths are the columns is reused, otherwise<constraint>_idxis created with the constraint (and dropped with it); an index a constraint depends on — on either side — cannot be dropped, nor can a parent table while another table references it.ADD CONSTRAINTchecks every existing row unlessNOT VALIDis given, in which case only new writes are enforced untilVALIDATE CONSTRAINTfinds the data clean and marks it valid.RENAME TO/RENAME COLUMNrewrite the definitions on both sides. Inside aBEGIN … COMMITtransaction the checks see the transaction's own writes. On a cluster the constraint is enforced cluster-wide: the coordinator checks the parent (or the referencing children) atQUORUMwherever the rows live, then writes, andADD CONSTRAINT/VALIDATE CONSTRAINTscan every member's rows before the DDL broadcasts — a stray child anywhere fails the statement and nothing is applied. A member that missed the DDL picks the constraint up through schema sync. A violating statement writes nothing on a cluster either (every row is checked before the first is written), but the rows of a passing statement land one at a time, so a mid-statement failure of another kind can leave the earlier rows written. Tables on either side of a foreign key are refused insideBEGIN ATOMIC PARTITION. Concurrent statements on opposite sides of a constraint (a child insert against a parent delete) are serialized per referenced value through a hidden guard table, so the outcome matches a single writer's; a coordinator that dies mid-statement leaves guard state that ages out after 10 s (seedocs/CLUSTER_PLACEMENT.md).- Column constraints —
NOT NULL,DEFAULT <expr>andCHECK (<expr>)— are declared per column inCREATE TABLE(qty NOT NULL DEFAULT 1 CHECK (qty >= 0), the three in any order), as table-level[CONSTRAINT <name>] CHECK (<expr>)items, or later withALTER TABLE … ADD [CONSTRAINT <name>] CHECK (…) [NOT VALID]andALTER TABLE … ALTER [COLUMN] <col> SET | DROP NOT NULL | DEFAULT. A column is a top-level field or a dotted path (meta.lang); the store stays schema-less, so an item says nothing about the field's type or existence. NOT NULL requires the column to be present and non-NULL in every row that lands — a schema-less row that never mentions the field counts as NULL — soINSERT INTO t (id) …on a table withname NOT NULLis refused.SET NOT NULLscans the existing rows first and refuses when any violates; there is noNOT VALIDform (useCHECK (c IS NOT NULL) NOT VALID). DEFAULT is applied onINSERTonly, to a column the statement's column list does not name (an explicitNULLis named and gets no default), and before the key is computed; never onUPDATE. The expression is evaluated per row against an empty row —now()anduuid()are per-row — so it may not reference columns, aggregates, windows or parameters (refused at DDL time), and an unknown function is refused at DDL time too. CHECK is evaluated against the final row:falsefails the write,trueorNULL(an unknown, e.g. an absent column) passes — SQL semantics; a non-boolean result is a type error. Like DEFAULTs, CHECKs may not use aggregates, windows or parameters.NOT VALIDleaves existing rows unchecked untilVALIDATE CONSTRAINTfinds them clean; new writes are always enforced.UPDATEis gated on each computed row, and anINSERT … ON CONFLICT DO UPDATEchecks the proposed row before conflict resolution and then the merged row (Postgres semantics: an upsert that omits a NOT NULL column fails even when the row already exists). A violating statement writes nothing (every row is checked before the first is written). Errors:not null violation: column "c" of table "t"(conditionNOT_NULL_VIOLATION) andcheck violation: constraint "n" of table "t": <col> = <value>, …(conditionCHECK_VIOLATION), both in theConstrainterror class. Names: a column-level CHECK ischk_<table>_<col>, an unnamed table-level onechk_<table>thenchk_<table>_<n>; CHECKs and FOREIGN KEYs share one namespace per table, andVALIDATE/DROP CONSTRAINTfind either.RENAME COLUMNrewrites column items; a CHECK whose expression references the column refuses the rename (drop and re-add it).SHOW CONSTRAINTS [ON <table>]lists every constraint of every kind in one view (constraint, table, type, columns, valid, definition; the name and columns cells are NULL where they do not apply),SHOW CREATE TABLEprints the column items and valid CHECKs inline and NOT VALID CHECKs as trailingALTER TABLE … ADD CONSTRAINT … NOT VALIDrows. On a cluster enforcement is identical on every coordinator;ADD CHECK,VALIDATE CONSTRAINTandSET NOT NULLscan every member's rows atQUORUMbefore the DDL broadcasts, and the constraints travel with schema sync. Partition children enforce the parent's constraints. - Sequences and auto-increment columns. A sequence is a
database-scoped counter:
CREATE SEQUENCE s [INCREMENT BY n] [MINVALUE n | NO MINVALUE] [MAXVALUE n | NO MAXVALUE] [START WITH n] [CACHE n] [CYCLE | NO CYCLE] [OWNED BY t.c](defaults as Postgres: increment 1, min 1 / max i64 max for an ascending sequence, min i64 min / max −1 for a descending one, start = min (max when descending),CACHE 1,NO CYCLE).nextval('s')hands out the next value —sis resolved in the current database, or writedb.s— and is allowed in a columnDEFAULT, in anINSERT'sVALUESand in a FROM-lessSELECT; anywhere else (a projection over rows,WHERE,CHECK, a procedure expression) is refused withnextval() is allowed in a DEFAULT, an INSERT's VALUES and a FROM-less SELECT.setval('s', n [, is_called])(FROM-lessSELECTonly) sets the counter: withis_calledtrue (the default) the next call returnsn + increment, with false it returnsn;ALTER SEQUENCE s RESTART [WITH n]issetval(n, false)as DDL — the next call returnsn(default: the START value). ReachingMAXVALUE(orMINVALUEwhen descending) on aNO CYCLEsequence fails the statement withnextval: reached maximum value of sequence "s" (n);CYCLEwraps around to the other bound.CACHE nleasesnvalues per counter write and hands them out from memory: on a cluster every coordinator holds its own block, so values stay unique and dense per coordinator but interleave across coordinators, and asetval/RESTARTonly invalidates the blocks of the node that ran it — the others hand theirs out to the end (Postgres sessions withCACHE > 1behave the same). With the defaultCACHE 1every call is one counter round (a local write on standalone, a linearizable CAS on a cluster), values are dense across the whole cluster, and a failed statement still consumes its value — Postgres semantics: a sequence is never rolled back.SERIAL/BIGSERIALon a column item (id SERIAL, the two are synonyms — values are i64) creates the sequence<table>_<col>_seqowned by the column and makes the columnGENERATED BY DEFAULT AS IDENTITY:NOT NULLwith anextvaldefault, soINSERT INTO t (name) VALUES ('x')keys the row by the next value (aDEFAULTon a primary-key column is applied before the key is computed).GENERATED {ALWAYS | BY DEFAULT} AS IDENTITY [(seq-options)]is the same with explicit options (id GENERATED ALWAYS AS IDENTITY (START WITH 1000 INCREMENT BY 10)).BY DEFAULTlets anINSERTsupply its own value (the counter does not move);ALWAYSrefuses a supplied value onINSERT(column "id" of "t" can only be updated to DEFAULT: it is an identity column defined as GENERATED ALWAYS) — except underINSERT INTO t (…) OVERRIDING SYSTEM VALUE VALUES (…)(bulk loads, imports) — and refuses the column inUPDATE … SETandON CONFLICT DO UPDATE SEToutright. An identity column may not also carry aDEFAULT. Writing the wordDEFAULTas aVALUESelement (INSERT INTO t (id, name) VALUES (DEFAULT, 'x')) un-names the column for that row so its default applies.ALTER TABLE t ALTER COLUMN c ADD GENERATED … AS IDENTITY [(…)]adopts an existing column (every row must already have it, as forSET NOT NULL; refused on a column that has aDEFAULTor is already an identity column);… DROP IDENTITY [IF EXISTS]makes it an ordinary column again and drops the owned sequence. The owned sequence is dropped with its table, keeps its name acrossRENAMEand shows inSHOW SEQUENCESasowned_by "t.c";DROP SEQUENCE sis refused while a columnDEFAULTcalls it (sequence 's' is used by the DEFAULT of t.c — drop the default first), andDROP DATABASEdrops the database's sequences.currval('s')is the last valuenextval('s')handed out on THIS connection andlastval()the last value anynextvaldid, whichever sequence — including the identity values anINSERTgenerated (currval('users_id_seq')afterINSERT INTO users (name) …) and asetval(which defines them at the value set). Before the connection's firstnextvalof that sequence either is refused withcurrval of sequence "s" is not yet defined in this session/lastval is not yet defined in this session— as in Postgres. The state is per connection (a driver session,skaidbsh, the MCP server's connection), never shared, and a stateless request (REST, the Elasticsearch surface, the UI) starts empty: therecurrvalis defined only within the request that callednextval. On a cluster the value is the one the coordinator you are connected to handed out. Both calls are allowed in any expression (a projection over rows,WHERE,SET,VALUES,RETURNING);currval('db.s')addresses another database's sequence likenextvaldoes, and within one FROM-lessSELECTacurrvalafter anextvalreads the value just handed out.INSERT … RETURNING * | <expr> [AS alias], …returns the rows as they landed — defaults (so the generated id) applied, the merged row forON CONFLICT DO UPDATE, and nothing for a rowDO NOTHINGskipped — as a result set instead of an affected count;*is every field of the rows in name order.UPDATE … RETURNINGreturns the updated rows as they landed (assignments, generated columns and a moved key applied — on a cluster the document each per-key consensus round actually committed) andDELETE … RETURNINGthe deleted rows as they were, in key order, with the same items and row shape aSELECTof them would have; a filter that matches nothing returns the columns and no rows (*then has no columns to show). Column-level grants treat aRETURNINGas a read of the table, like an assignment.SHOW SEQUENCESlistssequence, increment, min_value, max_value, start, cache, cycle, owned_by, last_value—last_valueis the highest value leased so far (NULL before the firstnextval; withCACHE > 1it is the block's end, as Postgres), read atQUORUMon a cluster.SHOW CONSTRAINTSreports an identity column as oneIDENTITYrow whose definition is the column item;SHOW CREATE TABLEprints the item inline (the owned sequence is implied); a free-standing sequence aDEFAULTcalls must exist before the table is replayed. Sequence definitions travel with schema sync; the counter is one row of the hidden per-database table__seq__, replicated at the database's factor, so it survives any node.skaidbsh exportwrites free-standing sequences intoschema.sql, identity columns andsetval(…)for every counter intoconstraints.sql(replayed after the data), andimportloadsGENERATED ALWAYStables withOVERRIDING SYSTEM VALUE. Not offered: typed sequences (AS smallint),SMALLSERIAL. - Enum types and domains — the store has no column types, so
CREATE TYPE mood AS ENUM ('happy', 'sad')andCREATE DOMAIN positive AS NOT NULL DEFAULT 1 CHECK (VALUE > 0)are named constraint bundles: a column item may name one (m mood,qty positive, alongside its ownNOT NULL/DEFAULT/CHECK), andCREATE TABLEexpands it into ordinary constraints — an enum into a columnCHECK (m IN ('happy', 'sad'))named<col>_<type>, a domain into its NOT NULL / DEFAULT / CHECKs withVALUEreplaced by the column, each CHECK named<col>_<check>(the CHECK written inCREATE DOMAINwithout aCONSTRAINT <n>is named after the domain, soqty positiveyieldsqty_positive) — whichSHOW CONSTRAINTSlists like any other. The column stays bound to the type:ALTER TABLE … ALTER [COLUMN] <col> [SET DATA] TYPE <type>binds an existing column the same way (the derived constraints are added and existing rows checked, cluster-wide on a cluster), the binding replays throughSHOW CREATE TABLEand schema sync as that statement, andDROP TYPE/DROP DOMAINclear it. Enums order by their declaration: on a bound column<,<=,>,>=,BETWEENandORDER BYcompare the labels' positions in theCREATE TYPElist rather than their text (a label outside the enum compares as unknown;=andINcompare the text;min/maxand an index range over the column still see text).ALTER TYPE mood ADD VALUE 'angry' [BEFORE | AFTER 'sad']grows the enum at that position (last by default) and rewrites every column CHECK derived from it (found by name and exact expression);DROP TYPEis refused while such a CHECK exists (drop the constraint first).ALTER DOMAINreaches every bound column:SET DEFAULT/DROP DEFAULTreplace the DEFAULT a column took from the domain (a DEFAULT written on the column itself wins, as atCREATE TABLE);ADD [CONSTRAINT <n>] CHECK (…)adds<col>_<n>to each bound column, checking their existing rows first unlessNOT VALID(thenALTER TABLE … VALIDATE CONSTRAINT <col>_<n>per table);DROP CONSTRAINT <n>takes those derived CHECKs off;RENAME TOre-points the bindings.DROP DOMAINleaves the constraints it produced in place. Types are database-scoped (db.moodqualifies like a table), travel with schema sync (typ:keys, before tables) and list inSHOW TYPESwith theirCREATEstatement. Not offered:ALTER TYPE … RENAME VALUE,ALTER DOMAIN … SET | DROP NOT NULL, composite types, collations. EXCLUDEconstraints — generalized uniqueness over ranges:CONSTRAINT no_double_booking EXCLUDE (room WITH =, during WITH &&)refuses a row whose=elements equal another row's, whose&&elements all overlap it and whose<>elements all differ from it (exclusion violation: constraint "…" of table "…" — … conflicts with row …). There are no range types: a range is a two-element array[lo, hi)([10, 20],['2026-01-01', '2026-02-01'],[NULL, 5]), and&&is an ordinary operator usable anywhere. At least oneWITH =element and oneWITH &&orWITH <>element are required (plain equality is aUNIQUEindex);<>says the rows sharing the=tuple must agree on that column (EXCLUDE (project WITH =, owner WITH <>)= one owner per project). A row with a NULL, absent or non-range element is exempt, like NULLs under UNIQUE, and so is a row outside the optionalWHERE (<predicate>)(… WHERE (cancelled = false): a cancelled booking neither conflicts nor is conflicted with; the predicate follows the CHECK rules andNULLcounts as outside).USING gist/USING <method>is accepted and ignored — the access method Postgres tools emit. Rows of oneINSERTare checked against each other too. Enforced on the FOREIGN KEY write path: the rows sharing the=tuple are probed through a supporting index over the=columns (an auto<constraint>_idx, or an existing index — dropped with the constraint like a FOREIGN KEY's), and on a cluster two coordinators racing the same=tuple serialize through the constraint's guard table (__fkref__<table>__<name>, the FOREIGN KEY fence), so the second writer probes after the first row landed; the probe itself reads at the session consistency.ALTER TABLE … ADD … EXCLUDEvalidates existing rows (cluster-wide, before the broadcast) unlessNOT VALID, which enforces new writes only untilALTER TABLE … VALIDATE CONSTRAINTscans them (under the scan budget, cluster-wide) and marks it valid — thevalidcolumn ofSHOW CONSTRAINTS;DROP CONSTRAINTremoves it,SHOW CONSTRAINTSlists it asEXCLUDE, and it replays throughSHOW CREATE TABLE, schema sync and the dump asALTER TABLE … ADD CONSTRAINT … EXCLUDE (…) [WHERE (…)] [NOT VALID]. A witness mirrors itNOT VALIDlike a FOREIGN KEY or CHECK. Not offered: operators beyond=,&&and<>,DEFERRABLE.- Partial and expression indexes —
CREATE [UNIQUE] INDEX i ON t ((lower(email)))indexes an expression's value per row (any deterministic expression over the row's columns — the CHECK rules: no aggregates, windows, subqueries, parameters,now(),nextval); the planner uses it for a filter whose left side is the same expression (WHERE lower(email) = 'x', ranges,IN,ORDER BY lower(email)), matched by canonical text.… WHERE <predicate>makes an index partial: only rows satisfying the predicate have entries, and the planner uses it only when the query'sWHEREimplies the predicate — every AND-conjunct of the predicate appears literally among the query's AND-conjuncts (WHERE active = true AND score > 5uses an index… WHERE active = true;WHERE score > 5alone does not). A partialUNIQUEindex constrains only the rows it covers. Both compose with global and multikey ([]on a plain path only) and round-trip throughSHOW INDEXES, schema sync andskaidbsh export. - Views —
CREATE [OR REPLACE] VIEW [IF NOT EXISTS] v [(c1, c2, …)] AS <select>stores a named SELECT (any SELECT the engine accepts: joins, GROUP BY, set ops, ORDER BY / LIMIT, FTS, NEAREST — but no bind parameters and nonextval/setval). A view lives in the table namespace of its database (db.vqualifies like a table; a view and a table cannot share a name), sees the tables as they are now (the store is schema-less: a column added later shows throughSELECT *immediately), and exposes exactly its projection — with a column list or a non-*item list, any other column iscolumn "x" does not exist in view "v", and a computed item has no sub-fields. Querying it: a simple body (no DISTINCT / GROUP BY / HAVING / set ops / LIMIT / OFFSET / aggregates / windows / NEAREST) is inlined — the outer query is rewritten onto the base table, so point lookups, index scans,ORDER BY … LIMITpushdown, FTS and NEAREST all keep working through it; any other body (or a view on the right of a JOIN) runs first and the outer query reads its rows as a derived table, with outerWHEREconjuncts over a grouped body's plain grouping columns pushed into it. Views over views expand recursively (a cycle is refused at CREATE; depth capped at 16).DROP TABLE/RENAMEof a table a view names is refused (view "v" depends on "t"— drop the view first),DROP DATABASEdrops its views. RBAC: a view runs with the caller's rights, not its author's.SELECTon the view's name is checked, and so isSELECTon every base table its body reads, following nested views — re-checked at each run, so a laterREVOKEtakes effect immediately. Creating (or redefining) one is checked the same way: you cannot wrap a table you may not read. A view therefore never widens access; to expose a narrowed slice of a table, grant on the columns (GRANT SELECT (a, b) ON t).CREATE VIEW/ALTER VIEWneedCREATEon the database,DROP VIEWDROP. A materialized view's backing store is engine-internal and cannot be selected directly. Definitions travel with schema sync asCREATE OR REPLACE VIEW(vw:<name>keys, tombstoned on DROP), in dependency order. - Writes through a view —
INSERT/UPDATE/DELETE(andINSERT … SELECT,ON CONFLICT,RETURNING) on a plain view whose body is a single-table SELECT without joins,DISTINCT,GROUP BY/HAVING, aggregates or window functions, set operations,LIMIT/OFFSET,NEAREST/ ranking orWITHare rewritten onto the base table: the named columns map through the view's projection (a computed item is refused —column "c" of view "v" is computed; a column the view does not expose isdoes not exist in view "v"), and an UPDATE's or DELETE's WHERE is ANDed with the view's own WHERE, so only rows the view shows are reached.RETURNING *returns the view's columns. A view over a view unwinds to the bottom table. Any other body is refused (view "v" is not updatable: it groups rows), as is a materialized view. An INSERT may land a row the view does not show (a column the view filters on left unset) unless the view carriesWITH CHECK OPTION: then a row the view's WHERE would hide — evaluated on the row as it lands, after DEFAULTs, generated columns and the UPDATE's assignments — is refused (new row violates check option for view "v"). A plain INSERT's rows are all checked before any is written; an UPDATE writes row by row, so rows before the offending one stay updated (as with any mid-statement error).WITH [CASCADED] CHECK OPTIONchecks this view's WHERE and every underlying view's;WITH LOCAL CHECK OPTIONchecks its own WHERE and those of underlying views that declare an option themselves. RBAC: a write through a view needs the statement's privilege on the view's name and on every base table the view names (INSERTthroughvneedsINSERTwherev's rows live), re-checked each run. ALTER VIEW—ALTER VIEW v RENAME TO wmoves the definition (refused while another view depends onv, and across databases);ALTER VIEW v [(cols)] AS <select> [WITH … CHECK OPTION]replaces the definition in place — grants onvare untouched, and the new body is validated and authorized asCREATE VIEW's. Plain views only: a materialized view is dropped and created again. Both replicate as DDL and travel with schema sync (a rename as the old name's tombstone plus the new name's definition).- Common table expressions —
WITH a [(cols)] AS (SELECT …), b AS (SELECT … FROM a …) SELECT … FROM b JOIN a …names one or more selects for the statement that follows: aSELECT, or anUPDATE,DELETEorINSERT(WITH gone AS (SELECT id FROM t WHERE …) DELETE FROM t WHERE id IN (SELECT id FROM gone)), where the CTEs are in scope for the write's subqueries; the write's own target is always a table, never a CTE. Each CTE behaves exactly like a view scoped to the statement: the same inline-or-overlay expansion, the same projection rule (column "x" does not exist in view "a"), visible to the statement's subqueries and to a view body that usesWITH; a later CTE may name an earlier one; a CTE shadows a table or view of the same name. A CTE is not a materialization fence — a CTE named twice runs twice (a recursive one excepted, below). A CTE body is itself a SELECT:WITH … AS (INSERT …)(data-modifying CTEs) is not offered. - Recursive CTEs —
WITH RECURSIVE name [(cols)] AS (<base> UNION [ALL] <recursive>) ….RECURSIVEbelongs to the wholeWITHlist; a CTE of the list whose body names itself is recursive, the others are ordinary. The body is one or more non-recursive legs followed by one or more legs that name the CTE (in aFROMor aJOIN), joined byUNIONorUNION ALL(all recursive legs the same). Evaluation: the base legs produce the first working table; each iteration runs the recursive legs with the CTE's name bound to the previous iteration's rows; it stops when an iteration produces no rows.UNION ALLkeeps every row;UNIONdrops rows already produced, which is what ends a walk over a cycle. The accumulated rows are then an in-memory table to the statement (joins, aggregates, subqueries, a write'sWHERE), materialized once. Columns are the column list's, else the base leg's output names. Bounds: at most 10,000 iterations (beyond that the statement fails withrecursive CTE "r" exceeded 10000 iterations), every row the working table retains is charged tostorage.scan_byte_budget, and each iteration's reads run under the statement's scan row budget and timeout. The body may not carryORDER BY,LIMIT/OFFSETorDISTINCT(apply them in the statement that reads the CTE); the first leg must not name the CTE. Walking a tree:WITH RECURSIVE sub (id, depth) AS (SELECT id, 0 FROM tree WHERE id = 2 UNION ALL SELECT t.id, s.depth + 1 FROM tree t JOIN sub s ON t.parent = s.id) SELECT * FROM sub. - Materialized views —
CREATE MATERIALIZED VIEW [IF NOT EXISTS] m [(cols)] [WITH (refresh = '<interval>')] AS <select> [WITH [NO] DATA]runs the body and stores its result in a hidden backing table (__mv__m,PRIMARY KEY (__row): a content hash of the output row plus an occurrence ordinal, so an unchanged result lands on unchanged keys and duplicate rows survive), placed like the body's FROM table. Readingmis an ordinary table read of that snapshot — point lookups,ORDER BY … LIMIT, streaming all apply; the backing key never shows andSELECT *on a never-refreshed view (orWITH NO DATA) returns no rows.REFRESH MATERIALIZED VIEW mre-runs the body at the session consistency, upserts every output row and deletes the keys the new result did not produce — readers during a refresh see the union of old and new rows, never an empty table; it reports the new row count as the affected count.REFRESH MATERIALIZED VIEW CONCURRENTLY mwrites the new result beside the old one as the view's next generation (keysg<n>:<hash>) and then switches readers to it with one write of the view's metadata row: a reader sees the whole old result until that write and the whole new one after it, never a mix; the old generation's rows are deleted afterwards. It costs the new result's size in extra storage while it runs and rewrites every row (a plain refresh leaves unchanged rows in place). Indexes on a materialized view:CREATE [UNIQUE] INDEX i ON m (col)indexes the backing table; reads throughmplan it like any table index, both refresh kinds maintain it (they write through the ordinary write path),SHOW INDEXES/ schema sync /skaidbsh exportlist it againstm(after the view, so a replay can create it), andDROP MATERIALIZED VIEWtakes it along. AUNIQUEindex there is checked per generation's row, soCONCURRENTLYover a UNIQUE index collides with the old generation — use a plain refresh or a non-unique index.WITH (refresh = '5m')(ms/s/m/h/d/w) refreshes on the maintenance tick (every 60 s) once the interval has elapsed — on a cluster from the ring owner of the view's name.SHOW VIEWSlistsview, kind (view | matview), refresh, last_refresh, rows, definitionin dependency order (rowsis the backing table's count);SHOW TABLESlists views withkind = view | matviewand no key;SHOW CREATE TABLE mprints theCREATEstatement.DROP MATERIALIZED VIEW mdrops the backing table too (DROP VIEWon a materialized view, and vice versa, is refused).skaidbsh exportwrites views intoschema.sqlafter the tables (a materialized view as… WITH NO DATA) and aREFRESH MATERIALIZED VIEWper materialized view intoconstraints.sql, so an import ends populated. CREATE TABLE … ASandINSERT … SELECT—CREATE TABLE t (PRIMARY KEY (id) [, items]) [WITH (…)] AS <select> [WITH [NO] DATA]creates the table exactly as the plainCREATE TABLEwould (the key is required — there are no column types to infer one from) and fills it withINSERT INTO t SELECT ….INSERT INTO t [(cols)] <select>runs the query in full first — views, CTEs, subqueries, joins, under the session's scan budgets like any read — then inserts its rows in chunks of 500 through the ordinary INSERT path (DEFAULTs, generated columns, constraints,ON CONFLICT,RETURNING, a view target all apply); with no column list the query's output names are the columns, with one the counts must match. Because the query finishes before the first row is written,INSERT INTO t SELECT … FROM tsees only the rows that existed. A population that fails (a scan budget, a constraint) drops the new table again;CREATE TABLE IF NOT EXISTS … ASon an existing table leaves its rows alone;WITH NO DATAcreates it empty. On a cluster the table DDL broadcasts and the coordinator runs the fill (the query gathers there, the rows route to their replicas at the session's write consistency). RBAC:CREATE(orINSERT) on the target andSELECTon every table the query reads.- Generated columns. A column item
c GENERATED ALWAYS AS (<expr>) STORED(total GENERATED ALWAYS AS (qty * price) STORED) is computed from the row that is about to land — afterDEFAULTs, after anUPDATE's assignments, after theON CONFLICT DO UPDATEmerge — and overwrites whatever the column held, on every write path (INSERT, UPDATE, upsert, the cluster's per-key rounds);RETURNINGsees the value, indexes and filters treat it as an ordinary stored field. A NULL result is stored as NULL (aNOT NULLon the column then refuses the write) and an evaluation error fails the write.c GENERATED ALWAYS AS (<expr>) VIRTUALstores nothing: the expression is evaluated wherever the column is read — the item list (bare, aliased, inside an expression, and through*, which lists the virtual columns after the stored fields),WHERE,GROUP BY,HAVING,ORDER BY,DISTINCT, aggregates and window functions, either side of a join (a.total, or bare — the first source in FROM order that declares it), views and subqueries over it,RETURNING, and anUPDATE's orDELETE'sWHEREand assignment expressions. Every reference becomes the expression before the statement is planned, so a filter on the column plans like a filter on the expression, an index on the column is an expression index over its expression (CREATE INDEX i ON t (total)is… ON t ((qty * price))inSHOW INDEXESand schema sync, and servesWHERE total = 5; an expression component or partial-index predicate reading the column expands the same way; a multikey[]on it is refused), aCHECKreading it judges the expression, and every write strips any field of that name from the landing row. A computed column has no sub-fields (total.xis refused), cannot beNOT NULL(nothing is stored to check) and cannot be aNEARESTcolumn. Explicit writes to either kind are refused:INSERTnaming the column (cannot insert a non-DEFAULT value into column "c" of "t": it is a generated column; theDEFAULTelement is allowed andOVERRIDING SYSTEM VALUEdoes not apply),UPDATE … SET candON CONFLICT DO UPDATE SET c(column "c" of "t" can only be updated to DEFAULT: it is a generated column). The expression may read the row's other columns (dotted paths too) — other generated columns included, STORED or VIRTUAL: STORED columns are computed in dependency order whatever their declaration order, a VIRTUAL column expands through the VIRTUAL columns it reads, and a cycle (areadsbreadsa, through any length) is refused atCREATE TABLE,ADD GENERATEDandSET EXPRESSION, naming it (generated column expressions form a cycle: a -> b -> a) — and nothing else: no aggregates, window functions or bind parameters (asCHECK), nonextval/setval, nonow(), noexcluded., not itself. A generated column cannot be aPRIMARY KEY,distribute_by,cluster_byor partition column, and carries noDEFAULTor identity;NOT NULL(STORED only) andCHECKcombine freely.ALTER TABLE t ALTER COLUMN c ADD GENERATED ALWAYS AS (<expr>) STOREDmakes an existing column generated and rewrites every row (through the ordinary UPDATE path — cluster-wide from the coordinator that ran the DDL; a row the recomputed value fails a constraint on fails the ALTER after the schema change, so the next write judges it again);… ADD GENERATED ALWAYS AS (<expr>) VIRTUALtouches no row and is refused while any row carries the column (column "c" of "t" holds a value in an existing row; checked cluster-wide before the broadcast, by a read) or the column isNOT NULL;… SET EXPRESSION AS (<expr>)replaces the expression, the column keeping its kind (refused on a column that is not generated; a STORED column's rows are rewritten, a VIRTUAL column's are not);… DROP EXPRESSION [IF EXISTS]makes the column ordinary — a STORED column's values stay in place, a VIRTUAL column then reads NULL, since nothing was stored. A generated column may read a column that later loses its own expression: it keeps reading the values.RENAME COLUMNis refused while a generated expression references the column; renaming the generated column itself keeps its expression.SHOW CONSTRAINTSreports oneGENERATEDrow per generated column whose definition is the column item,… STOREDor… VIRTUAL(aNOT NULLon it is its own row);SHOW CREATE TABLEprints the item inline;DESCRIBElists every generated column withgenerated stored/generated virtualin itskeycell (an index over a VIRTUAL column's expression is attributed to the column). The expression and kind travel with schema sync. A witness mirror replays VIRTUAL columns (no row is touched, so the mirror computes the same values from the primary's rows) and never STORED ones — see CLUSTER_WITNESSES.md.skaidbsh exportwrites a STORED column's values as data andDROP EXPRESSION IF EXISTS+ADD GENERATED …(with the kind) intoconstraints.sql;importdrops the column from the rows and theADDrecomputes a STORED one. Not offered: generated key columns. - Array functions operate on list-valued fields. Mutators —
array_append(a, x),array_append_distinct(a, x)(no-op when an equal element exists; never reorders),array_remove(a, x)(drops all equal elements),array_set(a, i, x)(positional write; out-of-bounds is an error) — are evaluated server-side insideUPDATE/upserts, so the whole element mutation inherits per-row linearizability on a cluster:UPDATE t SET tags = array_append(tags, ?) WHERE id = ?is one atomic operation, and a NULL/absent base acts as the empty array (array_append(NULL, x)=[x]). Element equality is the SQL=comparison, so mixed numeric encodings match numerically. Readers:array_length(a)(NULL for non-arrays) andarray_contains(a, x)(false for non-arrays), both usable inWHERE. - A plain
INSERTof an existing primary key REPLACES the row by default. A table created (or altered)WITH (insert_conflict = 'error')gives it the Postgres semantics instead: the statement fails with a unique violation on<table>_pkey(unique violation: index "t_pkey" already has (id)=(1);UNIQUE_VIOLATIONin a procedure handler), and so does a key repeated within one statement. A multi-row INSERT is all or nothing: every key is checked before any row is written, and each row is re-checked as it lands (a consensus round per row on a cluster, likeON CONFLICT), so a key created concurrently still fails rather than being replaced. The rule covers every INSERT into the table — SQL, driver batches, INSERT … SELECT, and the REST, Elasticsearch-gateway and MQTT-sink writes that insert rows.ON CONFLICTclauses keep their own meaning on such a table. INSERT ... ON CONFLICTmakes inserts upserts: with no clause an existing primary key is REPLACED wholesale (unless the table saysinsert_conflict = 'error', above);DO NOTHINGleaves the existing row untouched and reports 0 affected for that row;DO UPDATE SET ...merges only the named columns into the existing row — other columns survive. Merge expressions see the EXISTING row's columns directly and the incoming row's values asexcluded.<col>(the nameexcludedis reserved inside this clause), soSET hits = hits + 1andSET name = excluded.namecompose. The existing row can also be named explicitly as<table>.<col>, so the Postgres spellingSET hits = t.hits + 1works too — with one precedence rule the schemaless model forces: if the row really has a column named after its own table, that column wins andt.xstays a path into it. On a cluster the whole check-and-write runs inside the same per-row consensus round as UPDATE, so concurrent create-or-update races serialize: exactly one insert wins and every merge counts. A multi-row upsert runs those rounds batched, as a multi-row UPDATE does (rows sharing a replica set share each consensus step); a statement repeating a key runs its rows one after another, so the later row sees the earlier one's result. When a row fails (a CHECK on the merged row, say), the statement returns the error without starting any further batch, but it is not atomic: other rows of the failing row's own batch (up to 128 rows, not only those before it in the statement) may still commit, as with a multi-row UPDATE. Retry only idempotent merges blindly. Value-TOAST, UNIQUE-indexed and globally-indexed tables use a best-effort read-merge-write instead (same caveat class as their UPDATEs).- Clustered
UPDATEis linearizable per row: every row a clustered UPDATE touches is read-modified-written through a per-key consensus round (single-decree Paxos among the row's replicas), so concurrent updates to one row serialize —SET n = n + 1from many clients counts every increment exactly. Caveats: assignments that change a PRIMARY KEY column take the plain last-writer-wins path (a consensus commit cannot move a key); plain INSERT/DELETE stay last-writer-wins, so mixing overwrites of the same key with concurrent UPDATEs forfeits the guarantee for those writes; a single hot row serializes every writer behind one consensus round at a time (CLUSTER_CONSISTENCY.md "Hot keys" has the counter patterns: shard over N rows, batch client-side, or append and roll up); heavy single-key contention can error withCAS contention … (not applied; safe to retry …)after bounded retries — the statement provably had no effect and will never take one, so clients retry safely (an update whose effect landed is recognized and returns success, never an error). When that cannot be proven — a replica that may hold one of the statement's proposals did not answer, e.g. a member is down, the statement hit its deadline orKILLwhile resolving, or more than 1,024 rival commits landed on the key while the statement was stalled (the row's bounded commit history then "no longer covers this statement's proposals") — the error isCAS outcome unknown: … (may have been applied; do not retry blindly): the update may yet commit, so re-running a non-idempotent statement (n = n + 1) can apply it twice. A multi-row UPDATE or upsert whose rows partly committed before another row gave up errors withCAS partial outcome: N of M rows … (partially applied; do not retry blindly); "not applied" always means no row of the statement changed. Embedded (single-node) UPDATEs were always serialized by the engine lock. On a standalone server an INSERT / UPDATE / DELETE statement (foreign-key cascades included) that fails part-way — a constraint, a full disk, an I/O error — leaves no row of it applied: its rows and index entries are written together after the WAL has reserved room for all of them. Values a failed statement took from a sequence (nextval, SERIAL) stay consumed. This is error atomicity, not crash atomicity: a crash or power loss in the middle of the final writes can leave part of a statement (use a transaction —BEGIN … COMMIT, whose commit is journaled — when that matters). A statement writing more than 64 MiB of WAL is applied in 64 MiB chunks, each committed on its own: a failure leaves the chunks before it applied. Monitoring:skaidb_cluster_cas_*_totalmetrics. - Clustering —
WITH (cluster_by = (<col> [, ...]))stores rows sorted by the clustering values, and folds them into row identity: a row is identified by(cluster_by…, PRIMARY KEY), exactly like Cassandra's clustering columns. What that buys and costs: - Range predicates on a clustering prefix seek instead of scanning
(
WHERE ts >= a AND ts < boncluster_by = (ts)reads only the matching key range — the scan budget then counts only those rows), andORDER BYa clustering prefix walks in stored order. GROUP BYa clustering prefix streams: when theGROUP BYcolumns are (any order of) a leading prefix ofcluster_by, and every aggregate folds (COUNT/SUM/AVG/MIN/MAX, plain args), the groups are merged straight off the presorted key walk — a k-way merge over the partition children, replicas and shards that holds ONE open group instead of a group map, so the result's cardinality never touches the byte budget.ORDER BYa leading run of those columns (ascending) is the walk's own order and stops atOFFSET + LIMIT; anORDER BYon an aggregate re-sorts the streamed groups (a bounded top-k under aLIMIT). The generic fold answers instead when the filter is served by a secondary index, a point read or a geo index (the merge needs a key-order walk), inside an open transaction, during acluster_byadoption, and on a cluster gather over partition children whose partition column is not the clustering head (see CLUSTER_CONSISTENCY.md).EXPLAINreports the decision in itsgroupaspect.- Same-tuple re-ingest is idempotent: replaying a row with the same
clustering values and key overwrites in place — the at-least-once
pipeline case. A row with the same
PRIMARY KEYbut a different clustering value is a new row by definition. - An
UPDATEthat changes a clustering value moves the row (put-new-then-delete-old — a failure between the two leaves a recoverable duplicate, never a lost row). Clustering columns must be present and non-null on every write, cannot be renamed, and cannot overlap thePRIMARY KEY. - A bare-PK lookup (no clustering bound) is a scan by design —
declare a secondary index on the key column where needle lookups
matter.
cluster_bycannot combine withdistribute_byortoast_threshold. Composes withpartition_by(put the partition column at the clustering head). Declared at CREATE, or adopted later withALTER TABLE t SET (cluster_by = (c1 [, c2 …]))— an ONLINE re-key on a standalone server, an embedded engine, or a cluster when the table is a full copy on every member (replication factor at least the member count, no pins; a sharded table refuses — the new key would move rows between members): the table's rows move under a legacy child while a fresh engine takes the new layout, reads union both, writes land in the new layout and retire the old copy, and a background drain re-keys the rest (inline for an embedded engine). On a cluster, reads serve from the legacy child until the cutover, every write lands in both layouts, the member owning the table's name copies the merged legacy view into the new layout with each row's original stamp, and a replicated drop of the legacy child completes the swap everywhere; rows missing a clustering column stay in legacy, served but never re-keyed. Refused withpartition_by,distribute_by,toast_threshold,memory, any index, streams / triggers or a FOREIGN KEY on either side — recreate those after the drain finishes (SHOW PARTITIONS/ the log report it). - Range partitioning —
WITH (partition_by = 'range(<col>, <interval>)')splits a regular table into non-overlapping ranges of<col>(integer or timestamp values, typically epoch-ms;<interval>is a duration literal:15min,1h,1d,1w). Rows land in a hidden per-interval partition, created lazily on first write; every row must carry the partition column (a row without it is refused). What it buys: - Partition elimination: a
WHEREbound on the partition column scans only the overlapping partitions —scan_row_budgetthen counts rows after pruning, so ranged reads on large event tables stay under the guardrail instead of tripping it. - O(1) retention: with
ttl, expired partitions drop whole (a metadata + directory delete — no per-row tombstones, no compaction debt). Retention is partition-granular: a partition serves reads until its entire range is older than the ttl, so rows can outlive a pure row-ttl by up to one interval; a fully-expired partition never serves reads even before the physical drop. - No out-of-order horizon: a late row lands in the partition its column value names, however late it arrives.
- Per-child parallel folds: an aggregate / streamed projection over
several surviving partitions folds them on parallel workers (children
dealt into contiguous groups, merged in child order — the result is
exactly the serial walk's), and a filtered
count(*)a secondary index covers is the sum of the children's index counts (the parent's index fans to one physical index per child), decoding no rows. Duplicates on the partition column are first-class (append semantics); identity stays with thePRIMARY KEY. The partition column is part of row placement: keep it immutable per key — rewriting a key with a different partition value files the new version in the new partition and leaves the old one behind (like a re-key). On a cluster, the parent's DDL replicates and every member creates the same per-interval children lazily as writes apply (deterministic names — no extra coordination); retention runs as replicatedDROP TABLEDDL on a background cadence, and reads hide fully-expired partitions regardless. Indexes work:CREATE INDEX ON <parent>keeps a template on the parent and fans a physical index to every partition (new partitions inherit automatically; the needle path —trace_id-style equality lookups — prunes through them);DROP INDEXfans the removal, and a partition's drop cleans its indexes. Search, vector and geo indexes fan the same way (CREATE SEARCH INDEX/CREATE VECTOR INDEX … DIM n/CREATE GEO INDEXon the parent): aMATCH()query ranks across the partitions' indexes with merged BM25 statistics (one corpus-wide IDF, not one per partition),NEARESTmerges the per-partition neighbours by distance, a geo predicate prunes through each partition's index,ALTER … INDEX SETandDROPfan, andSHOW INDEXESlists the parent's index once. Search aggregation and fast-field-sorted pushdowns decline on a partitioned parent (the row path answers the same query exactly). Introspection: `SHOW PARTITIONS