skaidb query syntax
The SQL surface skaidb accepts. It's a subset of SQL — one statement per call
(joins, UNNEST, subqueries, CTEs, UNION, aggregates, window functions,
prepared statements and transactions are supported; the
command index lists every statement, and
Not part of the SQL surface what is not).
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. Each statement has one section — its synopsis, its notes, then See also links — and one entry in the command index; a new statement gets both.
Statements
Every statement skaidb accepts, grouped by what it works on. Each section opens with the statement's synopsis, then its notes, then See also links to the statements it works with. Expressions, the lexical rules and types follow the statements.
In a synopsis, [ … ] is optional, { a | b } picks one, ... (or …)
repeats what precedes it, <name> is a placeholder, and -- starts a
comment.
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 CREATE DATABASE).
- Tables
CREATE TABLE·CREATE TABLE … AS·ALTER TABLE·DROP TABLE· column constraints · generated columns ·FOREIGN KEY·EXCLUDE· clustering · range partitioning · value TOAST · witness mirroring · placement - Sequences and types
CREATE/ALTER/DROP SEQUENCE·SERIALand identity columns ·CREATE TYPE … AS ENUM,CREATE DOMAIN - Indexes
CREATE/DROP INDEX·CREATE UNIQUE INDEX·CREATE VECTOR INDEX·CREATE GEO INDEX·CREATE SEARCH INDEX·REBUILD INDEX·CHECK INDEX - Views
CREATE/ALTER/DROP VIEW·CREATE/REFRESH/DROP MATERIALIZED VIEW - Writing data
INSERT·ON CONFLICT·UPDATE·DELETE·RETURNING - Querying data
SELECT·JOIN·UNNEST·UNION/INTERSECT/EXCEPT·WITH·WITH RECURSIVE·EXPLAIN - Deep pagination (
AFTER)search pages · keyset pages · streamed queries ·AS OF - Search
MATCH/SEARCH·SUGGEST·NEAREST·RANK BY RRF·RERANK - Time-series tables
CREATE TIMESERIES TABLE· querying ·SHOW SERIES·CREATE ROLLUP - Streams, procedures, jobs and triggers
CREATE STREAM·CREATE PROCEDURE,CALL·CREATE JOB·CREATE TRIGGER·PUBLISH - Transactions
BEGIN/COMMIT/ROLLBACK·BEGIN ATOMIC·BEGIN ATOMIC PARTITION - Databases and sessions
CREATE/DROP DATABASE,USE,SHOW DATABASES·SET CONSISTENCY,SET SCAN BUDGET - Access control
CREATE/ALTER/DROP USER,CREATE/DROP ROLE·GRANT/REVOKE,SHOW GRANTS - Introspection
SHOW TABLES,SHOW INDEXES·SHOW CREATE TABLE·DESCRIBE·SHOW STATUS·SHOW CONSTRAINTS/FOREIGN KEYS/SEQUENCES/VIEWS/TYPES/ENDPOINTS - Administration
SHOW CLUSTER,SHOW CONFIG,SET CONFIG,SHOW SLOW QUERIES·SHOW QUERIES,KILL QUERY·SHOW MAINTENANCE·REPAIR CLUSTER,RECLAIM,ALTER CLUSTER· advisor · model providers ·BACKUP·RESTORE - Expressionsliterals · operators ·
CASE·IN·BETWEEN·LIKE· subqueries · aggregates · window functions · scalar functions · array functions · geospatial · bind parameters - Languageidentifiers & literals · types · indexing notes · not supported
Tables
CREATE TABLE
CREATE TABLE [IF NOT EXISTS] <table> (PRIMARY KEY (<col> [, <col> ...])
[, <col> [NOT NULL] [DEFAULT <expr>] [CHECK (<expr>)]] -- column item (no type: see Column constraints)
[, <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
memory = <bool>, -- RAM-only: no WAL, empty after a restart
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 Range partitioning)
cluster_by = (<col> [, ...]), -- physical sort + identity (see Clustering)
distribute_by = (<col> [, ...]))] -- place by this leading PK prefix:
-- rows sharing it share a replica set
-- (fixed at CREATE)
CREATE TABLE declares 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. A type
after the column name is accepted only when it names an enum or domain
declared with CREATE TYPE … AS ENUM or CREATE DOMAIN (name TEXT NOT
NULL is refused: type 'TEXT' does not exist) — see Column constraints.
The WITH (…) options each have their own notes: ttl
(Row expiry), memory (Memory tables),
witness (Witness mirroring), insert_conflict
(Existing primary keys), replication and nodes
(Per-table placement), toast_threshold
(Value TOAST), partition_by
(Range partitioning), cluster_by
(Clustering) and distribute_by
(CLUSTER_PLACEMENT.md).
So do the column items — constraints,
generated columns,
SERIAL and identity columns,
enum types and domains — and the table-level
FOREIGN KEY and EXCLUDE items.
Memory tables
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 a ttl. Indexes on memory tables are not supported.
Row expiry (TTL)
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 every
storage.ttl_reclaim_interval_secs (900 by default, 0 disables). 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).
See also ALTER TABLE · DROP TABLE · CREATE TABLE … AS · SHOW CREATE TABLE · DESCRIBE · CREATE TIMESERIES TABLE
Column constraints
NOT NULL, DEFAULT <expr> and CHECK (<expr>) are declared per column in CREATE TABLE
(qty NOT NULL DEFAULT 1 CHECK (qty >= 0), the three in any order),
as table-level [CONSTRAINT <name>] CHECK (<expr>) items, or later
with ALTER TABLE … ADD [CONSTRAINT <name>] CHECK (…) [NOT VALID] and
ALTER 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 — so INSERT INTO t (id) … on a
table with name NOT NULL is refused. SET NOT NULL scans the
existing rows first and refuses when any violates; there is no NOT
VALID form (use CHECK (c IS NOT NULL) NOT VALID).
DEFAULT is
applied on INSERT only, to a column the statement's column list
does not name (an explicit NULL is named and gets no default), and
before the key is computed; never on UPDATE. The expression is
evaluated per row against an empty row — now() and nextval(…) 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: false
fails the write, true or NULL (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 VALID leaves existing rows unchecked until VALIDATE CONSTRAINT
finds them clean; new writes are always enforced. UPDATE is gated on
each computed row, and an INSERT … ON CONFLICT DO UPDATE checks 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" (condition
NOT_NULL_VIOLATION) and check violation: constraint "n" of table
"t": <col> = <value>, … (condition CHECK_VIOLATION), both in the
Constraint error class.
Names: a column-level CHECK is chk_<table>_<col>, an unnamed table-level
one chk_<table> then chk_<table>_<n>; CHECKs and FOREIGN KEYs share one
namespace per table, and VALIDATE / DROP CONSTRAINT find either. RENAME
COLUMN rewrites 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 TABLE prints
the column items and valid CHECKs inline and NOT VALID CHECKs as trailing
ALTER TABLE … ADD CONSTRAINT … NOT VALID rows.
On a cluster enforcement is identical on every coordinator; ADD CHECK,
VALIDATE CONSTRAINT and SET NOT NULL scan every member's rows at
QUORUM before the DDL broadcasts, and the constraints travel with schema
sync. Partition children enforce the parent's constraints.
See also CREATE TABLE · ALTER TABLE · Enum types and domains · Generated columns · SHOW CONSTRAINTS
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 — after DEFAULTs, after
an UPDATE's assignments, after the ON CONFLICT DO UPDATE merge —
and overwrites whatever the column held, on every write path (INSERT,
UPDATE, upsert, the cluster's per-key rounds); RETURNING sees the
value, indexes and filters treat it as an ordinary stored field. A
NULL result is stored as NULL (a NOT NULL on the column then refuses
the write) and an evaluation error fails the write.
c GENERATED
ALWAYS AS (<expr>) VIRTUAL stores 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 an UPDATE's or DELETE's WHERE and 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))
in SHOW INDEXES and schema sync, and serves WHERE total = 5; an
expression component or partial-index predicate reading the column
expands the same way; a multikey [] on it is refused), a CHECK
reading 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.x is refused), cannot be NOT NULL (nothing is stored to
check) and cannot be a NEAREST column.
Explicit writes to either kind are refused: INSERT naming the column
(cannot insert a non-DEFAULT value into column "c" of "t": it is a
generated column; the DEFAULT element is allowed and OVERRIDING SYSTEM
VALUE does not apply), UPDATE … SET c and ON 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 (a reads b reads a, through any
length) is refused at CREATE TABLE, ADD GENERATED and SET
EXPRESSION, naming it (generated column expressions form a cycle: a
-> b -> a) — and nothing else: no aggregates, window functions or
bind parameters (as CHECK), no nextval / setval, no now(), no
excluded., not itself. A generated column cannot be a PRIMARY
KEY, distribute_by, cluster_by or partition column, and carries
no DEFAULT or identity; NOT NULL (STORED only) and CHECK combine
freely.
ALTER TABLE t ALTER COLUMN c ADD GENERATED ALWAYS AS (<expr>)
STORED makes 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>) VIRTUAL touches 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 is NOT 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 COLUMN is refused while a generated expression references the
column; renaming the generated column itself keeps its expression. SHOW
CONSTRAINTS reports one GENERATED row per generated column whose
definition is the column item, … STORED or … VIRTUAL (a NOT NULL on it
is its own row); SHOW CREATE TABLE prints the item inline; DESCRIBE
lists every generated column with generated stored / generated virtual
in its key cell (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 export writes a STORED column's values as data and DROP
EXPRESSION IF EXISTS + ADD GENERATED … (with the kind) into
constraints.sql; import drops the column from the rows and the ADD
recomputes a STORED one. Not offered: generated key columns.
See also CREATE TABLE · ALTER TABLE · Partial and expression indexes · Column constraints · DESCRIBE
FOREIGN KEY
FOREIGN KEY (<cols>) REFERENCES <parent> (<cols>) — declared
inline in CREATE TABLE after the primary key, or added later with
ALTER 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 any
cluster_by columns) or exactly the columns of one of its UNIQUE
indexes; arity and order must match.
A child row whose tuple has no parent is refused with a foreign key
violation error (procedures catch it as FOREIGN_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; CASCADE deletes the children with the
parent, or rewrites their tuple to the parent's new one; SET NULL
nulls 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-row INSERT satisfies 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 is fk_<table>_<col>[_<col>…].
The child's probes need a path to its rows by the constraint's columns: the
child's own primary key when the columns are that key (SHOW FOREIGN KEYS
then reports the support as (primary key) and no index is created), else
an existing local index whose leading paths are the columns is reused,
otherwise <constraint>_idx is 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 CONSTRAINT checks every existing row unless NOT VALID is given, in
which case only new writes are enforced until VALIDATE CONSTRAINT finds
the data clean and marks it valid. RENAME TO / RENAME COLUMN rewrite the
definitions on both sides. Inside a BEGIN … COMMIT transaction 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) at QUORUM wherever the rows live, then
writes, and ADD CONSTRAINT / VALIDATE CONSTRAINT scan 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).
The rows of a passing statement land in batches on a table that is a child
only (no table references it, no EXCLUDE), its parent probes batched ahead
of them — a few multi-key reads per statement rather than one round trip per
row, and the same for ADD CONSTRAINT / VALIDATE CONSTRAINT; an UPDATE
of such a table that assigns none of its constraint columns writes like any
table's. On a table that is also a parent (or carries an EXCLUDE) the rows
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
inside BEGIN 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 (see
docs/CLUSTER_PLACEMENT.md).
See also CREATE TABLE · ALTER TABLE · UNIQUE indexes · EXCLUDE constraints · SHOW FOREIGN KEYS · CLUSTER_PLACEMENT.md
EXCLUDE constraints
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 one WITH
= element and one WITH && or WITH <> element are required (plain
equality is a UNIQUE index); <> 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 optional WHERE (<predicate>) (… WHERE
(cancelled = false): a cancelled booking neither conflicts nor is
conflicted with; the predicate follows the CHECK rules and NULL counts as
outside).
USING gist / USING <method> is accepted and ignored — the access
method Postgres tools emit. Rows of one INSERT are 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 … EXCLUDE validates existing rows (cluster-wide, before
the broadcast) unless NOT VALID, which enforces new writes only until
ALTER TABLE … VALIDATE CONSTRAINT scans them (under the scan budget,
cluster-wide) and marks it valid — the valid column of SHOW CONSTRAINTS;
DROP CONSTRAINT removes it, SHOW CONSTRAINTS lists it as EXCLUDE, and
it replays through SHOW CREATE TABLE, schema sync and the dump as ALTER
TABLE … ADD CONSTRAINT … EXCLUDE (…) [WHERE (…)] [NOT VALID].
A witness mirrors it NOT VALID like a FOREIGN KEY or CHECK. Not
offered: operators beyond =, && and <>, DEFERRABLE.
See also CREATE TABLE · ALTER TABLE · FOREIGN KEY · UNIQUE indexes · Operators
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).
See also CREATE TABLE · ALTER TABLE · Range partitioning · Concurrency and atomicity · CREATE ROLLUP
Range partitioning
SHOW PARTITIONS <table> -- a range-partitioned table's partitions:
-- start/end, rows, disk bytes, serving
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 the PRIMARY 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 replicated DROP TABLE DDL 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 INDEX fans 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 INDEX on the parent): a MATCH() query ranks across the
partitions' indexes with merged BM25 statistics (one corpus-wide IDF,
not one per partition), NEAREST merges the per-partition neighbours
by distance, a geo predicate prunes through each partition's index,
ALTER … INDEX SET and DROP fan, and SHOW INDEXES lists 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 <table>
lists each partition's range, live rows, disk bytes and
whether reads still serve it; EXPLAIN SELECT … reports
partitions_total / partitions_scanned / partitions_pruned.
Current restrictions (refused loudly, not half-applied): no UNIQUE or
global indexes (uniqueness would only hold per partition), no streams
or triggers on a partitioned table; no memory, toast_threshold or distribute_by combinations;
not usable inside BEGIN ATOMIC PARTITION; AFTER keyset paging
pages with a WHERE range instead.
Pick the interval so the retention window holds tens of partitions
(e.g. 1d over a 90-day ttl).
Adopting on an existing table:
ALTER TABLE <t> SET (partition_by = 'range(<col>, <interval>)') moves
a table to partitioning without re-ingest — the current data
becomes a hidden legacy partition (a directory rename, no copy) and a
background drain relocates rows into their range partitions with their
original versions; reads stay complete throughout (children + legacy),
writes during the drain land in their range partition and retire the
pre-adoption copy, and the legacy partition drops once empty. On
clusters the adoption is replicated and fully online: reads serve from
the legacy partition until the cutover, every write lands in both the
legacy partition and its range partition, the member owning the
table's name copies the merged legacy view into the range partitions
with each row's original stamp, and a replicated drop of the legacy
partition cuts over; a standalone server is
online too — the ALTER returns once the layout is switched and a
drainer thread pages the legacy partition away (brief write locks per
page); only an embedded engine completes the move within the ALTER
statement. Rows missing the
partition column cannot be placed: they stay in the legacy partition
(still served, listed by SHOW PARTITIONS with NULL bounds) until an
UPDATE gives them the column. The table must first shed its indexes,
streams and triggers (recreate indexes after — they then fan per
partition).
See also CREATE TABLE · ALTER TABLE · Clustering · CREATE ROLLUP · Row expiry (TTL)
Value TOAST
ALTER TABLE <t> SET (toast_threshold = <bytes>) enables value-TOAST:
on every subsequent write, a top-level field whose encoded value is at
least the threshold is stored out-of-line in a hidden replicated
companion table and the row keeps a small marker. Scans that do not
project the field skip the marker instead of reading, decrypting and
decompressing the payload — the win for wide rows (multi-KB JSON blobs)
under narrow dashboards/aggregations. Reads that DO want the field
resolve it transparently (one extra point read per row) — a WHERE,
ORDER BY … LIMIT or DISTINCT on a toasted field included, and a search
hit comes back with its toasted fields; filtering on a toasted field costs
that point read for every row examined, so keep frequent filters on inline
or indexed fields. Applies to NEW
writes only; existing rows stay inline until rewritten. Fields covered by
any index (secondary, global, search, vector, geo) and primary-key
columns never toast — and creating a new index on a possibly-toasted
field is refused (set toast_threshold = 0 and rewrite rows first).
0 disables future splits; already-toasted rows remain readable.
See also CREATE TABLE · ALTER TABLE · CREATE INDEX
Witness mirroring
ALTER TABLE <t> SET (witness = <bool>) toggles witness mirroring for an
existing table. WITH (witness = false) at CREATE (or the ALTER form)
excludes the table from witness pulls: witnesses skip it entirely (rows
already mirrored are kept but stop updating), and the table stops holding
back tombstone GC for lagging witnesses. Toggling back to true re-includes it on the next full sweep.
System/registry tables (witnesses, drivers, node_aliases, ...) refuse
placement/witness options — every node consults them locally.
See also CREATE TABLE · ALTER TABLE · CLUSTER_WITNESSES.md
Per-table placement
replication = <n> overrides the cluster replication factor for one table
(n above the member count = full copy, the same semantics cluster-wide RF
has); nodes = ['<alias-or-id>', ...] pins the WHOLE table to an explicit
member set (entries accept node aliases as sugar, resolved to stable ids at
DDL time; a reference that is not a current member is refused, and witnesses
can never be pinned) — every pin holds every row, quorum math counts the
pins, and a non-pin coordinator routes reads and writes to them. The two are
mutually exclusive. Pins are a deliberate durability trade: a pinned node
down means quorum errors for that table until it returns, and ALTER CLUSTER
REMOVE NODE refuses to remove a pinned member (re-pin first with ALTER
TABLE t SET (nodes = [...])). GLOBAL-index entry tables follow their base
table's placement.
Placement transitions
ALTER TABLE t SET (replication = n) / SET (nodes = [...]) change
placement ONLINE. The old placement is kept alongside the new one and every
read/write addresses the UNION of both (the per-table twin of the membership
change's dual-ring window), so quorum reads stay correct while new owners
are still empty. A background driver (the sorted union's first member) runs
repair until every member has completed a full anti-entropy pass that
started after the change, then finalizes automatically; SHOW TABLES shows
transition = true while the window is open. One transition per table at a
time. If the driver is down the window just stays open (safe, wider than
needed) — the operator escape is REPAIR CLUSTER followed by ALTER TABLE t
SET (placement_finalized = true). After finalize, RECLAIM trims copies
the new placement no longer owns. Shrinking a pin set needs no window
(remaining pins already hold full copies) and applies immediately.
See also CREATE TABLE · ALTER TABLE · SHOW TABLES · Cluster membership and repair · CLUSTER_PLACEMENT.md
ALTER TABLE
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
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 <table> SET (partition_by = 'range(<col>, <interval>)') -- adopt range partitioning online
ALTER TABLE <ts-table> SET (retention = <dur> -- live retention change (0 clears)
| ooo = <dur>) -- live out-of-order window change
ALTER TABLE … RENAME TO renames a table (moving its on-disk data and
repointing its indexes); RENAME COLUMN from TO to rewrites 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 no
ADD/DROP COLUMN — a field simply exists in the rows that set it.
The constraint forms are described under Column constraints,
FOREIGN KEY and EXCLUDE;
ALTER COLUMN … TYPE binds an enum type or domain;
the identity and generated forms are under
SERIAL and identity columns and
Generated columns. The SET (…) options: ttl
(Row expiry), witness (Witness mirroring),
insert_conflict (Existing primary keys),
replication, nodes and placement_finalized
(Per-table placement), toast_threshold
(Value TOAST), cluster_by (Clustering),
partition_by (Range partitioning), and a time-series
table's retention and ooo (CREATE TIMESERIES TABLE).
See also CREATE TABLE · DROP TABLE · SHOW CREATE TABLE
DROP TABLE
DROP TABLE [IF EXISTS] <table>
-- cascades to every derived index on the table (secondary, search, vector)
A table a view reads, or a parent another table's foreign key references,
cannot be dropped — see CREATE VIEW and
FOREIGN KEY. Dropping a range-partitioned table drops its
partitions; dropping a rollup's source drops the rollup
(CREATE ROLLUP).
See also CREATE TABLE · DROP DATABASE
CREATE TABLE AS
CREATE TABLE [IF NOT EXISTS] <table> (PRIMARY KEY (<col> [, ...]) [, <item> ...])
[WITH (...)] AS <select> [WITH [NO] DATA]
CREATE TABLE t (PRIMARY KEY (id) [, items]) [WITH (…)] AS <select> [WITH
[NO] DATA] creates the table exactly as the plain CREATE TABLE would (the
key is required — there are no column types to infer one from) and fills it
with INSERT 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 t sees only the rows that existed. A population that fails (a
scan budget, a constraint) drops the new table again; CREATE TABLE IF NOT
EXISTS … AS on an existing table leaves its rows alone; WITH NO DATA
creates 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 (or INSERT) on the target
and SELECT on every table the query reads.
See also CREATE TABLE · INSERT · CREATE MATERIALIZED VIEW
Sequences and types
CREATE SEQUENCE
-- 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> ...]
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
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 — s is resolved in the current
database, or write db.s — and is allowed in a column DEFAULT, in an
INSERT's VALUES and in a FROM-less SELECT; anywhere else (a projection
over rows, WHERE, CHECK, a procedure expression) is refused with
nextval() is allowed in a DEFAULT, an INSERT's VALUES and a FROM-less
SELECT.
setval('s', n [, is_called]) (FROM-less SELECT only) sets the
counter: with is_called true (the default) the next call returns
n + increment, with false it returns n; ALTER SEQUENCE s RESTART
[WITH n] is setval(n, false) as DDL — the next call returns n
(default: the START value).
Reaching MAXVALUE (or MINVALUE when descending) on a NO CYCLE sequence
fails the statement with nextval: reached maximum value of sequence "s"
(n); CYCLE wraps around to the other bound.
CACHE n leases n values 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 a
setval / RESTART only invalidates the blocks of the node that ran it —
the others hand theirs out to the end (Postgres sessions with CACHE > 1
behave the same). With the default CACHE 1 every 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 and identity columns
SERIAL / BIGSERIAL on a column
item (id SERIAL, the two are synonyms — values are i64) creates
the sequence <table>_<col>_seq owned by the column and makes the
column GENERATED BY DEFAULT AS IDENTITY: NOT NULL with a
nextval default, so INSERT INTO t (name) VALUES ('x') keys the
row by the next value (a DEFAULT on 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 DEFAULT lets an INSERT supply its own value (the counter does
not move); ALWAYS refuses a supplied value on INSERT (column "id"
of "t" can only be updated to DEFAULT: it is an identity column
defined as GENERATED ALWAYS) — except under INSERT INTO t (…)
OVERRIDING SYSTEM VALUE VALUES (…) (bulk loads, imports) — and
refuses the column in UPDATE … SET and ON CONFLICT DO UPDATE SET
outright.
An identity column may not also carry a DEFAULT. Writing
the word DEFAULT as a VALUES element (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 for SET NOT NULL; refused on a column that has a
DEFAULT or 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 across RENAME and shows in SHOW SEQUENCES as owned_by
"t.c"; DROP SEQUENCE s is refused while a column DEFAULT calls
it (sequence 's' is used by the DEFAULT of t.c — drop the default
first), and DROP DATABASE drops the database's sequences.
currval and lastval
currval('s') is the last value nextval('s') handed out on
THIS connection and lastval() the last value any nextval
did, whichever sequence — including the identity values an INSERT
generated (currval('users_id_seq') after INSERT INTO users (name)
…) and a setval (which defines them at the value set). Before the
connection's first nextval of that sequence either is refused with
currval 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: there currval is defined only within the
request that called nextval. 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 like nextval does,
and within one FROM-less SELECT a currval after a nextval reads the
value just handed out.
Listing and exporting sequences
SHOW SEQUENCES lists sequence, increment, min_value, max_value, start,
cache, cycle, owned_by, last_value — last_value is the highest value
leased so far (NULL before the first nextval; with CACHE > 1 it is the
block's end, as Postgres), read at QUORUM on a cluster. SHOW CONSTRAINTS
reports an identity column as one IDENTITY row whose definition is the
column item; SHOW CREATE TABLE prints the item inline (the owned sequence
is implied); a free-standing sequence a DEFAULT calls 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 export writes free-standing sequences into schema.sql,
identity columns and setval(…) for every counter into
constraints.sql (replayed after the data), and import loads
GENERATED ALWAYS tables with OVERRIDING SYSTEM VALUE. Not
offered: typed sequences (AS smallint), SMALLSERIAL.
See also CREATE TABLE · ALTER TABLE · Scalar functions · RETURNING · SHOW SEQUENCES
Enum types and domains
-- 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 ...] ...
The store has no column types, so
CREATE TYPE mood AS ENUM ('happy', 'sad') and CREATE 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 own NOT NULL / DEFAULT / CHECK), and
CREATE TABLE expands it into ordinary constraints — an enum into a
column CHECK (m IN ('happy', 'sad')) named <col>_<type>, a domain
into its NOT NULL / DEFAULT / CHECKs with VALUE replaced by the
column, each CHECK named <col>_<check> (the CHECK written in CREATE
DOMAIN without a CONSTRAINT <n> is named after the domain, so
qty positive yields qty_positive) — which SHOW CONSTRAINTS lists
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 through SHOW
CREATE TABLE and schema sync as that statement, and DROP TYPE /
DROP DOMAIN clear it.
Enums order by their declaration: on a bound
column <, <=, >, >=, BETWEEN and ORDER BY compare the
labels' positions in the CREATE TYPE list rather than their text (a
label outside the enum compares as unknown; = and IN compare the
text; min/max and 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
TYPE is refused while such a CHECK exists (drop the constraint first).
ALTER DOMAIN reaches every bound column: SET DEFAULT / DROP
DEFAULT replace the DEFAULT a column took from the domain (a DEFAULT
written on the column itself wins, as at CREATE TABLE); ADD
[CONSTRAINT <n>] CHECK (…) adds <col>_<n> to each bound column,
checking their existing rows first unless NOT VALID (then ALTER
TABLE … VALIDATE CONSTRAINT <col>_<n> per table); DROP CONSTRAINT
<n> takes those derived CHECKs off; RENAME TO re-points the
bindings. DROP DOMAIN leaves the constraints it produced in place.
Types are database-scoped (db.mood qualifies like a table), travel
with schema sync (typ: keys, before tables) and list in SHOW TYPES
with their CREATE statement. Not offered: ALTER TYPE … RENAME
VALUE, ALTER DOMAIN … SET | DROP NOT NULL, composite types,
collations.
See also CREATE TABLE · ALTER TABLE · Column constraints · SHOW TYPES
Indexes
CREATE INDEX
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 INDEX with one path is a single-column index; with several it is a
composite index (ordered left-to-right). See Indexing notes. 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 CREATE ROLLUP.
Global indexes
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/IN probe 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 INDEXES says
global (building); probes fall back to scatter until ready). Guide:
INDEXING.md.
Multikey components
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, so labels = '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.
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. A deterministic call or arithmetic over
literals and bound parameters in WHERE / ON is evaluated before
planning, so WHERE lower(email) = lower(?), ts > 1792800000000 -
3600000 and id = to_timestamp(?) plan like the values they evaluate
to (select items keep their text, and with it their column names).
… WHERE <predicate> makes an index partial: only rows satisfying the
predicate have entries, and the planner uses it only when the query's
WHERE implies the predicate — every AND-conjunct of the predicate appears
literally among the query's AND-conjuncts (WHERE active = true AND score >
5 uses an index … WHERE active = true; WHERE score > 5 alone does not).
A count(*) whose WHERE is exactly the predicate is the index's entry
count (no row read), and the same plus a string/boolean equality or range on
the index's own columns is a range count. A partial UNIQUE index
constrains only the rows it covers. Both compose with global and multikey
([] on a plain path only) and round-trip through SHOW INDEXES, schema
sync and skaidbsh export.
UNIQUE indexes
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 (users keyed by id but email unique too), and
composite uniqueness beside a surrogate key. A violating write fails
with a distinct unique violation error (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 as UPDATE, so concurrent writers racing one value yield
exactly one winner.
UPDATE enforces and maintains the constraint like INSERT: 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-row UPDATE whose 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).
An UPDATE that 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.0 and 1.00 are one value; 1.0
and 1.000000001 are two). Deleting a row frees its values.
On a cluster, a multi-row INSERT (a driver's executemany batch
included) into a unique-indexed table claims its values in parallel —
each still a single-key consensus round — and writes its rows as one
replicated batch, 256 rows at a time; when a claim is refused (a value
held elsewhere, the same value twice, a row handing its value to a
later row) the batch releases what it claimed and that slice goes row
by row, so the outcome is exactly the row-at-a-time one described
above. A table with value-TOAST, a global index, an EXCLUDE
constraint, or a foreign key that other tables reference keeps the
row-at-a-time path.
CREATE UNIQUE INDEX over data that already contains duplicates
fails rather than silently tolerating them. A partial unique index
(… WHERE active = true) constrains only the rows its predicate holds
for — the rows already in the table when it is built exactly as every
write after: duplicates among the rows the predicate leaves out do not
fail the DDL, and a row moving into the predicate is checked (one
moving out frees its value). Cannot be combined with
WITH (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.
See also REBUILD INDEX · CHECK INDEX · SHOW INDEXES · DESCRIBE · Indexing notes · INDEXING.md
CREATE VECTOR INDEX
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 VECTOR INDEX builds an HNSW index for nearest-neighbor search over the
float array at <path>. DIM <n> (the vector dimension) is required;
USING <metric> is cosine (default), l2, or dot.
SPARSE indexes a column of (dimension, weight) pairs — a
learned-sparse model's output — invertedly, and scores it EXACTLY by dot
product; DIM is not required and the query is
NEAREST (col, sparse([dims], [weights]), k).
MULTI indexes 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) … NEAREST
is grouped vector search: the best row per group, retrieving enough
candidates to cover the groups.
QUANTIZED stores int8 scalar-quantized vectors in the in-RAM graph (4×
less vector RAM), QUANTIZED BINARY one sign bit per dimension (32× less,
Hamming-steered traversal), QUANTIZED INT4 two 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 with EMBED).
It broadcasts across the cluster so every node
indexes its shard. Query it with the NEAREST clause (see Vector search and VECTOR.md).
See also Vector search · Hybrid search · CHECK INDEX · REBUILD INDEX · Model providers · VECTOR.md
CREATE GEO INDEX
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 GEO INDEX builds a Morton (Z-order) spatial index over the
{lat, lon} point column at <point-path>, so geo_distance / geo_bbox
predicates in a WHERE scan 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.
See also Geospatial functions · CHECK INDEX · GEO.md
CREATE SEARCH INDEX
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>
ALTER SEARCH INDEX <name> SET (<option> = <literal> [, ...])
-- query-time options only: synonyms, refresh_ms,
-- <col>.search_analyzer, <col>.boost (applied live, no reindex)
CREATE SEARCH INDEX builds 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) and refresh_ms (integer, default 1000 — 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>.keyword exact-match twin), and <path>.copy_to (composite
target field).
Query it with MATCH()/SEARCH() predicates and score() (see Full-text
search and SEARCH.md).
REBUILD SEARCH INDEX re-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.
See also Full-text search · SUGGEST · CHECK INDEX · REBUILD INDEX · SEARCH.md
REBUILD INDEX
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
REBUILD SEARCH INDEX is described with CREATE SEARCH
INDEX. For drift between an index and its rows,
CHECK INDEX … REPAIR fixes the entries in place without a
rebuild.
See also CREATE INDEX · CHECK INDEX · Index DDL privileges
CHECK INDEX
CHECK INDEX {<name> | ALL} [ON <table>] [REPAIR] [RESUME '<token>'] [TIMEOUT <duration> | NONE]
-- 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
CHECK INDEX 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; MULTI one per vector of the
row, SPARSE one per non-empty sparse vector, EMBED one 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 listed skipped: sharded.
How an index is judged
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.
ALL covers 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.
Output and progress
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.
Time limits and RESUME
The statement timeout stops it cleanly at 80 % of
storage.statement_timeout_secs: the row says partial and resume holds
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).
TIMEOUT
<duration> gives the statement its own time limit in place of the
node's (TIMEOUT NONE lifts it), so a table too large for
storage.statement_timeout_secs is checked with its ledger in one
statement; the client's own timeout still applies.
CHECK INDEX ALL starts no table the rest of the statement is unlikely to
finish, and one cut after another table ran first restarts from its first
row on RESUME (its partial rows report no rows), so only a table too
large for one statement is finished entry by entry.
KILL QUERY stops it with
the kill error; SHOW QUERIES shows 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.
REPAIR
REPAIR re-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 of REBUILD SEARCH INDEX for 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 INDEX
re-derives it); duplicates are reported, never repaired.
Privilege: SELECT on the table (on the database for ALL without ON);
REPAIR needs CREATE, like REBUILD INDEX.
See also REBUILD INDEX · SHOW QUERIES · SHOW INDEXES · CLUSTER_REPAIR.md
Views
CREATE VIEW
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 [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 no nextval/setval).
A view lives in the table
namespace of its database (db.v qualifies 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 through SELECT *
immediately), and exposes exactly its projection — with a column list
or a non-* item list, any other column is column "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 … LIMIT pushdown, 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 outer WHERE conjuncts over a
grouped body's plain grouping columns pushed into it.
Inlined, the body's own table takes the name the outer query reads the
view by (FROM v names it v), so a subquery in the body that names one
of ITS tables like that while it reads the body's table — CREATE VIEW v AS
SELECT id, EXISTS (SELECT 1 FROM c v WHERE v.pid = p.id) … FROM p — would
read its own table where the body meant the view's row. Such a body is
refused at CREATE VIEW, ALTER VIEW … AS and ALTER VIEW … RENAME TO
with an error that names the view and asks for another alias for that
table, and a read under a clashing alias (FROM w AS v) is refused the same
way — never misread. A view over a view inlines the inner body under the
OUTER view's name, so the check follows that chain: creating ov AS SELECT
… FROM iv is refused when iv's body would clash under the name ov, and
so is redefining iv into a clash with a view that reads it.
Views over views expand recursively (a cycle is refused at CREATE;
depth capped at 16). DROP TABLE / RENAME of a table a view names is
refused (view "v" depends on "t" — drop the view first), DROP
DATABASE drops its views.
RBAC: a view runs with the caller's rights, not its author's. SELECT
on the view's name is checked, and so is SELECT on every base table its
body reads, following nested views — re-checked at each run, so a later
REVOKE takes 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 VIEW need CREATE on the database, DROP VIEW
DROP. A materialized view's backing store is engine-internal and cannot be
selected directly.
Definitions travel with schema sync as CREATE OR REPLACE VIEW
(vw:<name> keys, tombstoned on DROP), in dependency order.
Writes through a view
INSERT / UPDATE / DELETE (and INSERT … 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 or WITH are
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 is does 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 carries
WITH 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 OPTION checks this view's WHERE and every
underlying view's; WITH LOCAL CHECK OPTION checks 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 (INSERT through v needs
INSERT where v's rows live), re-checked each run.
ALTER VIEW
ALTER VIEW v RENAME TO w moves the definition
(refused while another view depends on v, and across databases);
ALTER VIEW v [(cols)] AS <select> [WITH … CHECK OPTION] replaces
the definition in place — grants on v are untouched, and the new body
is validated and authorized as CREATE 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).
See also CREATE MATERIALIZED VIEW · WITH (common table expressions) · SHOW VIEWS · GRANT
CREATE MATERIALIZED VIEW
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 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.
Reading m is an ordinary table read of that snapshot — point
lookups, ORDER BY … LIMIT, streaming all apply; the backing key never
shows and SELECT * on a never-refreshed view (or WITH NO DATA)
returns no rows.
REFRESH MATERIALIZED VIEW m re-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 m
writes the new result beside the old one as the view's next
generation (keys g<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 through m plan it like any table index, both
refresh kinds maintain it (they write through the ordinary write
path), SHOW INDEXES / schema sync / skaidbsh export list it
against m (after the view, so a replay can create it), and DROP
MATERIALIZED VIEW takes it along. A UNIQUE index there is checked
per generation's row, so CONCURRENTLY over 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 VIEWS lists view, kind (view | matview), refresh, last_refresh,
rows, definition in dependency order (rows is the backing table's count);
SHOW TABLES lists views with kind = view | matview and no key; SHOW
CREATE TABLE m prints the CREATE statement. DROP MATERIALIZED VIEW m
drops the backing table too (DROP VIEW on a materialized view, and vice
versa, is refused).
skaidbsh export writes views into schema.sql after the tables (a
materialized view as … WITH NO DATA) and a REFRESH MATERIALIZED VIEW per
materialized view into constraints.sql, so an import ends populated.
See also CREATE VIEW · CREATE INDEX · SHOW VIEWS · CREATE TABLE … AS
Writing data
INSERT
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
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 ...]
INSERT INTO t [(cols)] <select> is described with
CREATE TABLE … AS; OVERRIDING SYSTEM VALUE and the
DEFAULT element with SERIAL and identity columns;
an INSERT into a view under Writes through a view.
Existing primary keys
A plain INSERT of 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_VIOLATION in 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, like
ON 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 CONFLICT clauses keep their own
meaning on such a table.
ON CONFLICT
INSERT ... ON CONFLICT makes inserts upserts: with no clause an
existing primary key is REPLACED wholesale (unless the table says
insert_conflict = 'error', see Existing primary keys);
DO NOTHING leaves 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 as excluded.<col> (the name excluded is reserved inside
this clause), so SET hits = hits + 1 and SET name = excluded.name
compose. The existing row can also be named explicitly as <table>.<col>,
so the Postgres spelling SET hits = t.hits + 1 works too — with one
precedence rule the schemaless model forces: if the row really has a column
named after its own table, that column wins and t.x stays 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).
See also RETURNING · UPDATE · Column constraints · UNIQUE indexes · Generated columns
UPDATE
UPDATE <table> SET <path> = <expr> [, <path> = <expr> ...] [WHERE <expr>]
[RETURNING * | <expr> [AS <alias>] [, ...]] -- the rows as updated
Assignments may use the array functions
(SET tags = array_append(tags, ?)) and a
table qualifier (SET, WHERE and RETURNING accept
<table>.<col>). An UPDATE that changes a clustering value
moves the row; one that assigns a primary-key column on a table with
UNIQUE indexes follows the rules there.
Concurrency and atomicity
Clustered UPDATE is 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 + 1 from 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 with
CAS 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 or KILL while 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 is CAS
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 with CAS 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_*_total metrics.
See also RETURNING · INSERT · ON CONFLICT · Writes through a view · CLUSTER_CONSISTENCY.md
DELETE
DELETE FROM <table> [WHERE <expr>]
[RETURNING * | <expr> [AS <alias>] [, ...]] -- the rows as they were
Foreign-key ON DELETE actions run with the deleted rows
(FOREIGN KEY), and a standalone server applies all of a
statement's rows or none of them
(Concurrency and atomicity). Time-series tables
are append-only (CREATE TIMESERIES TABLE).
See also RETURNING · Writes through a view · Row expiry (TTL)
RETURNING
INSERT … RETURNING * | <expr> [AS alias],
… returns the rows as they landed — defaults (so the generated id)
applied, the merged row for ON CONFLICT DO UPDATE, and nothing for
a row DO NOTHING skipped — as a result set instead of an affected
count; * is every field of the rows in name order. UPDATE …
RETURNING returns 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) and
DELETE … RETURNING the deleted rows as they were, in key order,
with the same items and row shape a SELECT of them would have; a
filter that matches nothing returns the columns and no rows (* then
has no columns to show). Column-level grants treat a RETURNING as a
read of the table, like an assignment.
See also INSERT · UPDATE · DELETE · Column-level grants
Querying data
SELECT
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>] | <unnest> }
[ <join> | , [LATERAL] <unnest> ... ]
[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>]
| [INNER | LEFT [OUTER] | CROSS] JOIN [LATERAL] <unnest> [ON <expr>]
<unnest> := UNNEST(<array-expr> [, ...]) [WITH ORDINALITY] [[AS] <alias>[(<column> [, ...])]]
-- one row per element (see UNNEST)
<select-item> is * (all fields seen in the result rows) or
<expr> [[AS] <alias>].
Clauses with their own sections: JOIN, UNNEST,
NEAREST, RANK BY RRF,
RERANK,
UNION / INTERSECT / EXCEPT,
AFTER and AS OF;
a WITH list before the SELECT is under
WITH (common table expressions).
Table qualifiers
A table qualifier names the table, with or without a join. In a
single-table SELECT, <alias>.<col> — or <table>.<col> when the
FROM gives no alias — is the column <col> in every clause (SELECT
d.pid FROM dept d WHERE d.region = 'us' ORDER BY d.pid), and a nested
path continues after it (e.addr.city). UPDATE (SET, WHERE,
RETURNING) and DELETE accept <table>.<col> the same way. An
unaliased qualified column keeps its written label in the result
(d.pid), as in a join. An alias replaces the table name, so under
FROM address a the path address.city is the nested field city of
the field address (also spelled a.address.city). Any other leading
segment is a nested-document path.
Precedence when the qualifier is also a field. A table whose rows
hold a top-level document field named like the qualifier keeps the
nested-path reading: over rows {id, address: {city, zip}}, SELECT
address.city FROM address WHERE address.city = 'Oslo' reads the nested
city, in every clause, and an index on address.city serves it. The
reading is chosen once per statement — one reading for the projection,
WHERE, GROUP BY, HAVING, ORDER BY, SET and RETURNING, so an
index can serve the predicate — from what the table shows on the node
running the statement: an index defined on a path under the field
(address.city), or a document in that field in one of the table's
first 16 rows in key order. A scalar field of that name does not count (FROM docs d
over rows with a date field d still reads d.id as the column id).
To pin a reading whatever the rows hold, alias the table: under FROM
address a, address.city is always the nested path and a.city the
column.
DISTINCT and DISTINCT ON
DISTINCT ON (<expr>, …) keeps the first row of each group of
equal expressions, in the query's ORDER BY — whose leading keys must
be exactly the DISTINCT ON expressions (SELECT DISTINCT ON (k) k, v
FROM ev ORDER BY k, ts DESC = the latest row per k); without an
ORDER BY the row picked per group is arbitrary. LIMIT / OFFSET
apply after the pick. Lowered onto ROW_NUMBER() OVER (PARTITION BY …
ORDER BY …) = 1, so it materializes the filtered set like a window
function and cannot combine with GROUP BY / aggregates (use GROUP
BY … TOP 1 BY … for that) or appear in a set-operation branch.
DISTINCT removes duplicate output rows. SELECT DISTINCT <one
plain column> (no ORDER BY/GROUP BY/JOIN) streams the value set without
materializing rows — safe on tables of any size; an array-valued column
dedupes whole arrays (its elements: SELECT DISTINCT tag FROM t,
unnest(t.tags) AS tag). NULL and a missing field are one distinct value:
the result has a NULL row when any matching row has no value, with one
column selected or several (count(DISTINCT col) counts values only).
GROUP BY, HAVING and TOP
HAVING filters groups after
aggregation (it may reference aggregates and the GROUP BY columns).
GROUP BY ... TOP <k> BY <expr> [ASC|DESC] — per-group top-k rows:
instead of one aggregated row per group, each group contributes its k
best rows ranked by the expression (DESC — best-first — is the
default; NULLs rank last either way). The select items are then ordinary
per-row expressions (* works; aggregates cannot mix with TOP), and
HAVING (which may aggregate) still filters whole groups first.
ORDER BY/LIMIT/OFFSET apply to the flattened output; without
ORDER BY, groups keep first-seen order with rows best-first inside
each. Under a search predicate TOP k BY score() ranks by BM25 — the
SQL spelling of ES top_hits (per-group best documents), and
HIGHLIGHT() works in the projection.
ORDER BY
Numbers sort by value whatever their type — a
column holding ints, floats, decimals and timestamps interleaves them
(0, 0.5, 1, 1.5); values of other types follow by type (bools sort
before numbers; then strings, bytes, uuids, arrays, documents). This
holds on every access path: a secondary index that serves the ORDER
BY merges its numeric types by value.
Where NULL sorts: NULLS FIRST | NULLS LAST. Each sort key takes
an optional placement for NULL and missing values after its
direction (ORDER BY d DESC NULLS LAST, id); with it, the placement is
the same on every access path and at every consistency level. Without
it, the placement of the leading key follows how the statement
reads its rows:
- a statement that reads its rows in order from a secondary index
on the leading key places them where the index keys do —
NULLand missing values first ascending, last descending.SELECT … FROM messages WHERE account = ? ORDER BY date DESC LIMIT 50through an index on(account, date)lists the newest dated rows; rows without adatecome after every dated one, and a keyset cursor (date < ?) taken from the page continues it; - a statement that sorts its rows places them last ascending
and first descending — no index on the leading key, a leading key
that is an expression, a
GROUP BY/ window /UNIONresult, and every key after the first.
A statement reads in index order when the leading key is a plain column
that is the next column of a secondary index after the columns the
WHERE pins by equality, the planner picks that index, and the read is
bounded: at consistency ONE on a table the node holds in full (and on
a single node) with or without a LIMIT; at QUORUM / ALL (and at
ONE on a sharded table) when LIMIT + OFFSET is at most 10,000 (a
larger or absent limit sorts). A window of more than 4,096 rows at
QUORUM / ALL in primary-key order (ascending, on a filter no index
narrows) reads the first OFFSET + LIMIT rows of the merged key-order
walk, whatever the table's size; another order, on a table every member
holds and the coordinator can level — up to a million stored keys — is
read from the coordinator's copy brought level with the read quorum, as
ONE reads it. Past that, an order a secondary index or the primary key
walks is read from every member's walk of the window and a margin of an
eighth more, re-read at the read consistency once the union provably
holds the window — while those walks rank the rows as the statement
does: past 10,000 rows not when the walked range holds NULL entries,
unless the statement asks for the index's own placement (ASC NULLS
FIRST, DESC NULLS LAST). Otherwise a single-key order gathers the
matching rows keeping only the best OFFSET + LIMIT, and a multi-key
order gathers them all. Rows tied on a single sort key come in no defined
order, so consecutive OFFSET pages can repeat or skip a row of a tie run
that crosses a page edge (as with any single-key order); adding the primary
key to the ORDER BY makes the pages stable. A
primary-key lookup, a geo predicate, a WHERE that pins a primary-key
prefix without a LIMIT, a partitioned parent table, a statement
inside a transaction and a read while the index is still building all
sort.
The placement is decided once per statement and every step ranks by it — the
index walk, a coordinator's merge of its members' windows, the final sort of
a multi-key order — so a page is always presented in the order it was picked
in. Write NULLS FIRST / NULLS LAST when the placement must not depend on
the plan. An index serves either placement on a single node and at ONE; at
QUORUM a placement opposite to the index's own (ASC NULLS LAST, DESC
NULLS FIRST) over a range that holds NULL entries is answered by the
exact gather.
Output aliases and positions
Output aliases resolve in ORDER BY, GROUP BY, and HAVING
(SELECT count(*) AS c … HAVING c > 1 ORDER BY c DESC, SELECT
time_bucket(1m, ts) AS t … GROUP BY t). ORDER BY/GROUP BY resolve
bare names (ORDER BY c, not ORDER BY c + 1); HAVING resolves
references anywhere in its predicate. When an alias shadows a source
column of the same name (SELECT price >= 10 AS price), ORDER BY
prefers the output column while GROUP BY/HAVING keep the source
column — SQL's usual preference rules.
Output positions: a positive integer in GROUP BY, ORDER BY or
DISTINCT ON (…) names the select-list item at that position, as in
PostgreSQL and Trino — SELECT region, count(*) FROM o GROUP BY 1 ORDER
BY 2 DESC, SELECT DISTINCT ON (1) k, v FROM ev ORDER BY 1, ts DESC.
Over a UNION/INTERSECT/EXCEPT, ORDER BY 2 is the combined result's
second column. A position past the select list, GROUP BY of an
aggregate or a window function, and a position over SELECT * (no fixed
positions) are errors; any other expression (ORDER BY 1 + 0) is an
ordinary key. A bound parameter is a value, never a position: ORDER BY
? sorts by a constant in every query shape, bound to an integer or not.
See also EXPLAIN · Aggregate functions · Window functions · Streamed queries · Subqueries
JOIN
JOIN combines tables — equi-joins (ON a = b) run as a hash join,
other predicates and RIGHT joins fall back to a nested loop.
A single [LEFT] JOIN t whose ON equates t's WHOLE primary key with
columns of the FROM table (ON t.account = m.account AND t.id = m.id,
other ON conjuncts allowed) is an index nested loop instead: the
FROM table is read under its own m.-qualified WHERE conjuncts (index,
point and key-slice plans apply) and t is point-read in batches of keys,
never gathered. Under a single ORDER BY m.<col> LIMIT n (no grouping,
DISTINCT or windows) the FROM walk runs in that order in pages of growing
size and stops once OFFSET + LIMIT joined rows pass the WHERE. Keys
match as = compares them (the probes above); the ON is re-checked on
every probed row. EXPLAIN names it (join: index nested loop via
primary-key probes of <t> (<key>)).
A single INNER JOIN is read from its selective side, whichever side
of the JOIN it is written on, when that side's own WHERE and ON
conjuncts (b.cat = ?) select few rows and the other side's join column
leads an index or its primary key (ON a.k = b.id with an index on
a.k): the selective side is gathered first (at most 16,384 rows), then
the other side is read by k IN (…) of the gathered values in batches,
through that index, under its own conjuncts — never walked. It is chosen
when the other side's own conjuncts select at least eight times the
selective side's rows plus the rows the probe reads (their share of the
other table), by bounded peeks at the index ranges, primary-key slices and
table sizes on the coordinator's copy (no row read). Values match as =
compares them, a NULL joins nothing, and the ON and WHERE are
re-checked on every pair. EXPLAIN names it (join: index nested loop
driven from <t>: … then <o> by
…). LEFT, RIGHT and
FULL joins keep their sides' roles.
A join runs on the coordinator. Each side is gathered like any table,
under the WHERE conjuncts that read that side alone (o.region = ?
narrows o's gather, index plans included; the full WHERE still
decides every joined row) — except a side an outer join may NULL-extend,
which is gathered whole so an IS NULL anti-join sees its NULL-extended
rows; charged to storage.scan_byte_budget at its stored size, decoding
only the columns the statement reads. A joined row is a set of references
into the sides, and only the rows that pass the ON and the WHERE
become output rows, holding the columns read after the WHERE
(SELECT a.id, b.name keeps one field per side, SELECT count(*) none,
SELECT * every field once).
A join's output is not bounded by its
inputs, so what the join builds — hash buckets, joined-row references
and output rows, at their heap size (about 0.9 KB per output row, plus
0.9 KB per alias the statement names, plus the values) — has a limit of
its own of four times storage.scan_byte_budget: past it the
statement fails with resource limit: join result exceeded N bytes
materialized (4 x storage.scan_byte_budget) instead of growing the
node's memory. A finished join stage hands back its buckets and input
references, and the candidate loops honor the statement timeout and
KILL QUERY. DISTINCT and UNION keep a hash and a row index per
distinct row (rows are compared exactly) and charge nothing beyond the
rows themselves.
INNER/LEFT/RIGHT/FULL/CROSS are supported (JOIN alone means INNER;
CROSS JOIN takes no ON). Reference columns qualified by table alias
(u.id, o.amt); an unqualified field resolves against whichever joined
table defines it (first table wins on a name clash). SELECT * over a join
expands to the underlying fields.
See also SELECT · UNNEST · Correlated subqueries · Not part of the SQL surface
UNNEST
UNNEST(<array>) turns an array into rows: a join side whose rows are
the elements of an array computed from each row joined before it (an
implicitly LATERAL source). It is how you group, filter or join on the
elements of an array column — GROUP BY tags groups whole arrays, so
['a','b'] and ['b'] are two groups; per tag:
SELECT u.tag, count(*) FROM t CROSS JOIN UNNEST(t.tags) AS u(tag) GROUP BY u.tag -- Trino / Athena
SELECT tag, count(*) FROM t, unnest(t.tags) AS tag GROUP BY tag -- PostgreSQL
SELECT unnest(tags) AS tag, count(*) FROM t GROUP BY tag -- select-list form
All three are the same statement. The alias names the side; with a
column list (AS u(tag)) the element is u.tag (or bare tag), and
without one the alias IS the column (unnest(tags) AS tag → tag; no
alias → unnest). A document element exposes its fields (line.sku
over unnest(o.lines) AS line). Each row repeats once per element; an
array that is NULL, missing or empty contributes no row, and any
other value one row (itself) — MongoDB's $unwind. LEFT JOIN UNNEST(…)
AS u(x) ON TRUE keeps the rows that yield nothing (the side NULL), an
ON filters the elements, and only CROSS, INNER and LEFT joins
take an UNNEST (a RIGHT/FULL one would null the rows it reads).
Several arrays zip side by side, the shorter padded with NULL, and
WITH ORDINALITY adds the 1-based position as a last column — both need
the column list: UNNEST(t.names, t.scores) WITH ORDINALITY AS p(name,
score, pos). FROM UNNEST(?) AS k(id) JOIN t ON t.id = k.id joins a
bound array of keys to a table, and SELECT unnest([3, 1, 2]) needs no
table.
In the select list, unnest(<array>) (one argument) returns rows
as in PostgreSQL: every call becomes one generated UNNEST side — several
calls zip, a repeated call is one column, an unaliased call is named
unnest — and, beyond PostgreSQL, grouping by it (GROUP BY tag or
GROUP BY unnest(tags)) groups the elements. Anywhere else (WHERE,
ON, HAVING, an aggregate's or a window's argument) unnest(…) is
refused: unnest in FROM and filter its column. A scalar alias wins its
own name: unnest(p.tags) AS tag makes bare tag the element even when
p has a tag field (read that one as p.tag). A subquery inside the
arguments is refused (join its table and unnest the column), and so is
nextval() over the rows of an UNNEST — a FROM-less SELECT nextval(…)
is one row.
UNNEST runs where the join runs (on the coordinator in a cluster,
after the FROM table's gather and its own WHERE conjuncts): it builds
only the columns the statement reads, its rows count toward the join's
limit (JOIN), and EXPLAIN names it (unnest: …). LATERAL is accepted
before UNNEST only. Joined to UNNEST sides alone, the FROM table takes
every WHERE conjunct that reads only its columns — qualified or bare,
a bare name an UNNEST side defines staying that side's — so WHERE
is_archived = false narrows the table's read (by an index or not)
before any row is unnested. A MATCH()/SEARCH() predicate on the table
combines with its UNNEST sides — facet counts over search hits (see
SEARCH.md); one on an UNNEST column is refused.
Rows per element, from a multikey index
With a multikey index whose
[] component is the unnested column, how many rows hold each element
is counted from the index's entries — no row read, however many rows
the WHERE selects:
CREATE INDEX i_tags ON message_tags (account, tags[]);
SELECT tag, count(DISTINCT id) AS n
FROM message_tags CROSS JOIN UNNEST(tags) AS u(tag)
WHERE account = ? GROUP BY tag ORDER BY n DESC;
It applies when the WHERE pins exactly the index's columns before the
[] one, each by = to a string, boolean, UUID or bytes literal
(nothing else in the WHERE), the join is an inner or cross join on one
UNNEST of that column without WITH ORDINALITY, the statement groups by
the element (or is SELECT DISTINCT <element>), and its aggregates are
count(DISTINCT <col>) / approx_count_distinct(<col>) where <col>
completes the row's primary key with the columns the WHERE pins, or
count(*) —
HAVING, ORDER BY, LIMIT and expressions over them work as usual.
The index holds each distinct element once per row, while count(*)
counts 'a' twice in ['a', 'a']. So count(*) is served from the index
only while each replica asked holds a proof that no row repeats an element.
A build of the whole table that meets no repeat writes the proof; storing a
row that repeats an element withdraws it, and only REBUILD INDEX brings it
back. Without the proof count(*) joins the rows, as it does on an index
built before the proof existed until that index is rebuilt. A restored
backup comes back without the proof.
The NULL group's rows are read to tell a
NULL element from a row without an array (more than 256 → the rows are
joined). ONE counts this replica's entries; QUORUM/ALL serve them
when the replicas the level needs count the same, and join the rows
otherwise: when too few answer within seconds, or their counts keep
differing. Joining the rows can exceed the statement's scan budget on a
large table; skaidb_cluster_element_count_fallbacks_total counts these
and the log names the table and reason. On a member that is no pin of a
pinned table, the pins' indexes count. EXPLAIN says rows per element counted from multikey index
'…'.
See also JOIN · SELECT · Multikey components · Array functions
UNION, INTERSECT, EXCEPT
UNION / INTERSECT / EXCEPT [ALL] combine the rows of
several SELECTs that share a column count: UNION concatenates,
INTERSECT keeps the rows present in both, EXCEPT removes the rows
the right side has. Without ALL the result is deduplicated; with
ALL it is a multiset (UNION ALL keeps every row, INTERSECT ALL
keeps each row as many times as both sides have it, EXCEPT ALL
removes one occurrence per right-side row). Operations apply left to
right (no INTERSECT-binds-tighter rule: parenthesise by writing the
legs in the order intended). A trailing ORDER BY/LIMIT/OFFSET
after the last branch applies to the whole combined result and
references the output column names.
See also SELECT · Output aliases and positions
WITH (common table expressions)
WITH <name> [(<col> [, ...])] AS (<select>) [, ...] <select>
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: a
SELECT, or an UPDATE, DELETE or INSERT (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 uses WITH; 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, see Recursive CTEs). 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>) …. RECURSIVE belongs to the whole WITH list;
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 a FROM or a JOIN), joined
by UNION or UNION 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 ALL
keeps every row; UNION drops 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's WHERE), 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 with
recursive CTE "r" exceeded 10000 iterations), every row the working table
retains is charged to storage.scan_byte_budget, and each iteration's reads
run under the statement's scan row budget and timeout. The body may not
carry ORDER BY, LIMIT / OFFSET or DISTINCT (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.
See also CREATE VIEW · SELECT · Subqueries
EXPLAIN
EXPLAIN <statement>
EXPLAIN ANALYZE <select>
EXPLAIN SCORE <select> FOR <pk-literal>
EXPLAIN <statement> returns the plan the executor would choose for any
SELECT/DML statement, as (aspect, decision) rows. The access row
names the path: point read / point-read set (primary-key =/IN, N
keys) / index scan via '<name>' (<bounds>) / index-ordered walk via
'<name>' (… early-stop at LIMIT | the whole range in order) /
global-index probe via '<name>'
(routed to the value's replica set) / primary-key ordered walk on
'<col>' (ASC | DESC tail-walk, early-stop at LIMIT) — an ORDER BY on
the LEADING primary-key column needs no sort and no separate index
read, because the table is its primary index / primary-key slice
walk on '<col>' (the PK columns before it equality-pinned, ASC | DESC
tail-walk, early-stop at LIMIT) — the same for an ORDER BY on the
primary-key column right after the ones the filter pins, as in WHERE
account = ? ORDER BY id LIMIT 10 on PRIMARY KEY (account, id); both
walks return keys of different types in key order (type tag, then
value), so a key column holding both ints and floats lists them by type
/
primary-key range slice / BM25 top-k, index-ordered or
unranked search pushdown / search-aggregation pushdown vs. row-gather
fallback / search count: hit set of '<search index>' ∩ index keys of
'<index>' (a search count answered from key sets alone, no row
gather) / search via '<search index>': hit-set walk of the sorted
index '<name>' or hit-set scan of the bounded index range on
'<name>' (an ordered search page that reads only hits), plus a
search_key_set row (N keys (primary key pinned by …)) when the WHERE
pins every primary-key column to =/IN literals — the search then
answers for those keys only / HNSW
vector search / full table scan (streaming k-way
merge).
An order row notes ORDER BY strategy, and names the
clustered top-k cliff: a window (OFFSET + LIMIT) of at most 10,000
rows is a bounded per-member scatter (a multi-key sort under a
completeness proof, gathering every matching row when the proof fails);
past it the row says how the deeper window is read — the members' walks
of the window where an index or the primary key walks the order, else
no bounded scatter: every matching row is gathered; residual-filter and join-strategy rows follow (join: index nested loop via primary-key probes of <t> … for a join on a whole primary key, join: index nested loop driven from <t> … for an inner join read from its selective side), a subquery row per correlated subquery answered by primary-key probes (anti-join / semi-join / anti-join (NOT IN) / semi-join (IN) / scalar lookup via primary-key probes of <t> (<key>), and whether the outer walk drives the probes with an early stop at OFFSET + LIMIT or every distinct key is probed), and — on a
cluster — cluster.* rows (the coordinating member that planned and
serves the statement, members — the ring's, witnesses not counted —
replication factor, fan-out:
point-routed / global-probe-routed / served locally on full copy /
scatter-gather; a search SELECT reports its search scatter even when its
key is pinned).
Advisory: it mirrors the planner's decision logic
without executing anything, so EXPLAIN DELETE ... is safe. Bindable
(EXPLAIN SELECT … WHERE id = ? prepares like the statement it wraps).
Gated by the wrapped statement's own privilege. EXPLAIN EXPLAIN is
rejected. Worked example with output:
INDEXING.md.
EXPLAIN ANALYZE
EXPLAIN ANALYZE <select> returns the same plan, plus what the statement
actually cost: elapsed, rows returned, rows examined, and a
selectivity line reading those two against each other. That ratio is
the diagnosis — "the planner intends a full scan" and "the scan read
3.7M rows to return nine" are different facts, and only the second says
something is wrong. Past 100× the message names the likely fix.
It runs the statement, so it takes a SELECT only: executing
somebody's DELETE because they wanted to see its plan is not a trade
worth making. Counting does not require storage.scan_row_budget to be
set.
On a cluster the statement runs through the path the plan
describes and the rows report what the coordinator paid: elapsed,
rows returned, rows examined (this node) — its own engine's rows,
a peer's work being the peer's — and peer requests, the requests the
statement sent other members (its own, and those of the workers it ran
in parallel; another statement's and the node's background traffic are
not counted).
Where a probe walk (a correlated [NOT] EXISTS / [NOT] IN, an
anti-join, walked in keyset pages) or a QUORUM agreed window spent its
time is reported as well, for the parts the statement entered: time
reading probe candidates (the walk's pages), time probing (the
subquery's lookups), and on a cluster time in agreed window walks (this
node) and time waiting on agreed windows (peers) — the wait for the
consulted peers' windows once this node's own walk was done. Each sums
over the statement's threads, so parts that run side by side (the next
page read while one is probed; a page's peer wait is inside its read)
add up past elapsed.
EXPLAIN SCORE
EXPLAIN SCORE <select> FOR <pk literal> is a standalone statement
returning the BM25 breakdown (explanation column, tantivy's JSON —
per-term K1 / idf(n, N) / tf-normalization) of how the row with that
primary-key value scored against the SELECT's search predicates. One
row when the key matches, zero rows when it does not; an error without
a search predicate. On a cluster the explain routes to a replica of
the key, so it works at any replication factor.
See also SELECT · Indexing notes · INDEXING.md · ADVISOR.md
Deep pagination (AFTER)
Stable keyset pagination for search queries — the SQL analogue of
Elasticsearch's search_after. Instead of OFFSET (which re-fetches and
discards every earlier row, and shifts under concurrent writes), pass the
previous page's last sort value and last primary-key value:
-- page 1
SELECT id, title, score() FROM docs
WHERE MATCH(body, 'compaction') ORDER BY score() DESC LIMIT 10;
-- page 2: strictly after (last score, last id) of page 1
SELECT id, title, score() FROM docs
WHERE MATCH(body, 'compaction') ORDER BY score() DESC LIMIT 10
AFTER (0.7311, 'doc-141');
Works with ORDER BY score() DESC (cursor = last score) or ORDER BY <col>
[ASC|DESC] (cursor = last column value). The primary key is the implicit
tie-break, always ascending — every sorted search page is ordered
(sort value, pk) deterministically, so ties never straddle a page boundary
inconsistently. Rules:
- Requires a search predicate in
WHERE, exactly oneORDER BYkey, andLIMIT; a single-column primary key; mutually exclusive withOFFSET,GROUP BY/DISTINCT,RANK BY RRF, andRERANK. Filter-only (non-search) queries can keyset-paginate withWHERE <col> > <last>, or — ordered by the primary key — with the plain-selectAFTERform below. - The cursor is resolved by re-ranking: the fetch depth doubles until the page
fills or the match set is exhausted (capped at 65,536 ranked hits), so a
page costs about the same as the equivalent
OFFSETfetch — the win is stability (concurrent writes never shift or duplicate pages; a row inserted before the cursor simply never appears) plus no offset ceiling. - Score-sorted deep paging inherits the usual distributed-search caveat
(per-shard BM25 statistics; scores move if the index changes between
pages) — the same caveat ES documents for
search_afterwithout a PIT.
ES clients use the _search search_after parameter, echoing each hit's
sort array (SEARCH.md).
Plain-select keyset pages (ORDER BY <pk> … AFTER)
A non-search SELECT of bare columns can page directly on the primary key:
SELECT id, status, v FROM events
WHERE status = 'open'
ORDER BY id LIMIT 1000 AFTER ('evt-01H8...');
Requires bare-column items, ORDER BY the table's single-column primary
key ASC, a LIMIT, and a single non-NULL cursor literal; no
GROUP BY/DISTINCT/joins/set ops/OFFSET. The cursor resumes the
primary-key walk by stored key position, not by an SQL comparison —
so on a schema-less table whose keys mix types (integer and string ids),
pages cover every row exactly once, where WHERE id > <last> would
silently skip rows whose key type differs from the cursor's (cross-type
comparisons are NULL). Rows arrive in the key encoding's order (type tag,
then value — the same order ORDER BY <pk> walks).
On a cluster this form pages at any consistency and placement: a node
holding a full copy at consistency ONE walks its own shard, and every
other route — QUORUM (the default), sharded tables, pinned tables —
serves each page as a bounded, LWW-merged distributed read seeded
strictly after the cursor, with the page's read quorum enforced. Each
page is its own read: a row written between pages appears or not per
ordinary keyset-pagination semantics.
Streamed queries: what pages server-side
A streamed SELECT — a driver's streaming call (OP_QUERY_STREAM; with
bound parameters, OP_QUERY_STREAM_PARAMS) or a REST /query POST —
always reaches the client in chunks. Whether the node also avoids
holding the whole result depends on the statement:
- Pages, in storage-key order. A plain SELECT of bare columns (or a
lone
*) from one table, with any filter that is a function of the row — columns, literals, operators and the pure scalar functions (lower,upper,trim,length,substr,concat,coalesce,to_timestamp,to_int,abs,round,array_contains, …) — and either noORDER BYorORDER BYa leading run of the table's storage key(cluster_by…, primary key)ascending. Each page is the statement itself, resumed strictly after the storage key the previous page reached: it returns at most 1,024 rows, examines at most 8,192 rows or 32 MB of stored rows (whichever comes first — a table of 100 KB rows pages a few hundred rows at a time) and holds at most 4 MB, then reports where it stopped. A page decodes only the columns the statement reads — its items, its filter and its order; other fields are never decoded, and the TOASTed values of fields it does not read are never fetched. (On a cluster the replicas' page rows still travel whole and are pruned on the coordinator.) So every key shape pages — a composite primary key, acluster_bytable (a range on the leading cluster column reads only its slice) — and no scan budget applies to a page: a sparse filter costs pages that hold no rows, neverscan budget exceededpart-way through. The byte budget,statement_timeout_secsandKILL QUERYapply to each page; the stream as a whole runs as long as the client consumes it. A stream is one heavy scan foriwm.max_concurrent_scans: once its pages have examinediwm.heavy_scan_rowsrows, each further page runs holding one admission slot and gives it back before its rows are written to the client, so a slow reader holds no slot while it reads — counted, like a statement's, toward its database's share underiwm.max_concurrent_scans_per_database. With every slot (or its database's share) taken the stream waits before its next page — holding no lock, and sending nothing, so the client sees a pause — for up to 30 seconds (orstatement_timeout_secs, when that is shorter), and only then fails with the retryabletoo many concurrent scans(skaidb_iwm_stream_slot_waits_totalcounts the streams that waited). Literal-only calls and arithmetic are evaluated once, before the first page, andnow()is read once for the whole stream — soWHERE ts >= now() - 1dand a boundto_timestamp(?)page like the literals they evaluate to. Rows arrive in the key encoding's order (type tag, then value; aNULLor missing cluster value first). - Runs whole first. When a secondary index narrows the filter to at
most 16,384 rows (a quarter of
scan_row_budget, when that is lower), the planner's index read is far cheaper than walking the table, so the stream runs the statement whole — holding at most 16 MB — and pages it instead if that read exceeds a scan budget, is refused a heavy-scan slot (the paged stream then takes its turn for one between pages) or runs paststatement_timeout_secs(the first page then arrives after that long).skaidb_query_stream_whole_gave_way_totalcounts those. - Time slices. A raw read of a time-series table pages on
ts >= <cursor> ORDER BY ts(TIMESERIES.md). Each slice is an ordinary statement: one that examinesiwm.heavy_scan_rowsrows takes a heavy-scan slot like any statement, and is refused, not held back, when none is free. - Materialized, then delivered in chunks. Everything else: joins,
GROUP BYand aggregates,DISTINCT, set operations, anORDER BYother than the storage key ascending,LIMIT/OFFSET(a bounded read keeps the plan its LIMIT picks), searches, a filter calling a search predicate, a sequence or a subquery, a selected VIRTUAL generated column (or a*over a table that has one — it is computed per statement; one in the filter pages), a view, a range-partitioned table, a statement inside a transaction. The scan budgets apply to these exactly as to a one-shot statement, and a budget error then says why the statement could not page. A one-shotSELECTthat fails a budget but would page if streamed says so in its error.
EXPLAIN SELECT … ends with a stream row saying which of these the
streamed form takes. skaidb_query_stream_total{path} counts streams by
what they did: keyset (paged in storage-key order), ts (time slices),
buffered (ran whole), unpaged (planned to page, but another path served
it whole — a shape the lane should have refused).
What a paged stream is, and is not:
- Not a snapshot. Each page is its own read. A row written behind the
position is not seen; one written ahead of it is. A row whose storage key
changes while the stream runs (an UPDATE of a
cluster_bycolumn moves it) can be seen twice or not at all.AS OF <instant>pins every page to one instant (a row changed after it drops out of the stream). - Bounded per page, not per stream.
statement_timeout_secsandKILL QUERYact on the page running when they fire:SHOW QUERIESlists each page as its own statement, and aKILLthat lands between two pages misses (close the stream from the client). A stream over a table whose keys grow ahead of the position faster than the client drains it does not end — bound it with a filter (ts < <the start>).
A lone SELECT * streams too: its column set is resolved up front by one
retain-free discovery scan over the matching rows (the sorted field union —
the same columns a one-shot SELECT * reports), then streamed as those
explicit columns with absent fields as NULL. The discovery reads field
NAMES: values are decoded only for the filter's columns, and no toasted
value is fetched (a toasted field's name comes from the row's manifest). A field that first appears
in a row written after the stream started is not among that stream's
columns. On a cluster the discovery reads the local shard when the node
holds the whole table (at any consistency — the pages themselves run at
the requested level) and otherwise unions every member's shard; only when
a member cannot answer (down, or older than the request) does SELECT *
fall back to the materializing path.
On a cluster every read route pages: a node holding a full copy at
consistency ONE walks its own shard, and every other route — QUORUM
(the default), sharded tables, pinned tables — serves each page as a
bounded, LWW-merged distributed read (each replica's agreed chunks, or the
merge of every source) resumed strictly after the page's position, with the
page's read quorum enforced. Each page is its own read: a row written
between pages appears or not per ordinary keyset-pagination semantics
(AS OF pins every page to one instant).
Point-in-time reads (AS OF)
SELECT … AS OF <instant> reads the table as it stood at that instant:
a row is visible only when the version currently stored for it was
written at or before the instant. Rows inserted, updated or deleted
afterwards drop out of the result — the earlier version of an updated
row is not resurrected (compaction folds old versions away), so the
rule is deliberately simple: newer wins, and newer-than-the-instant is
hidden. The operand is a constant expression: unix milliseconds, a
timestamp string in any to_timestamp form, or now() - <ms>; an
operand that does not convert is ignored (the select runs unbounded).
-- every page of a long export reads the same instant, whatever gets
-- written meanwhile
SELECT id, body FROM events ORDER BY id LIMIT 1000 AFTER ('evt-01H8...')
AS OF 1757066400000;
SELECT count(*) FROM events WHERE status = 'open' AS OF '2026-09-05T10:00:00Z';
The bound applies to everything the statement reads — the scan, point
reads, index lookups, search hits, CTEs, views and subqueries — on
standalone and cluster deployments alike (a coordinator tests each
replica's row stamp at its merge). Two things it cannot do: index-side
facets (GROUP BY pushed into a search index) and key-count statistics
carry no per-row stamps, so an AS OF statement takes the row-reading
path for those (exact, slower); and BM25 scores are computed from the
index's current statistics, so relevance order under AS OF may
differ from the order at the instant. The ES gateway's point-in-time and
scroll ids are built on this clause (SEARCH.md).
Search
Full-text, vector, hybrid and reranked search are clauses of an ordinary
SELECT. The indexes they read are created with
CREATE SEARCH INDEX and
CREATE VECTOR INDEX.
Full-text search (MATCH / SEARCH)
Full-text predicates run against a search index on the table
(BM25-ranked, Tantivy-backed). They are ordinary WHERE conditions: search
predicates compose with AND/OR/NOT among themselves and join
ordinary conditions at the top level with AND (those filter the matches
afterward). Mixing a search predicate with an ordinary condition under
OR/NOT is rejected — the index cannot serve it.
CREATE SEARCH INDEX articles_fts ON articles (title, body)
WITH (analyzer = 'english', refresh_ms = 1000);
-- Ranked retrieval: top-k pushed into the index, best first. The BM25
-- score is exposed as score() (and a _score field on the row);
-- HIGHLIGHT() projects a snippet with the matches marked.
SELECT id, title, score(), HIGHLIGHT(body, 120) AS snippet FROM articles
WHERE MATCH(body, 'quick brown fox') AND published = true
ORDER BY score() DESC LIMIT 10;
-- Query-string mini-language over all indexed columns:
SELECT id, score() FROM articles
WHERE SEARCH('title:"rust database" +body:performance -draft')
ORDER BY score() DESC LIMIT 20;
-- Bool composition of search predicates (must / should / must_not):
SELECT id FROM articles
WHERE (MATCH(body, 'rust') OR MATCH(title, 'rust'))
AND NOT MATCH_PHRASE(body, 'rust belt') AND year >= 2024;
- Analyzed predicates (query text goes through the field's query-time
analyzer —
search_analyzerif set, else the index-time one):MATCH(<col>, '<text>')— true if the column matches any term (OR).MATCH_PHRASE(<col>, '<text>' [, <slop>])— terms in order, within<slop>transpositions (default 0).FUZZY(<col>, '<text>' [, <distance>])— Levenshtein-fuzzy terms, distance ≤ 2 (default 1).SEARCH('<query-string>')— mini-language, bare terms over the text columns:term,"phrase",col:term,+must,-must_not,AND/OR, and ranges over typed columns (year:[2020 TO 2024],price:[30 TO *],published:true).MATCH_CROSS(<col>, <col> [, ...], '<text>')— term-centric multi-field match (ESmulti_matchcross_fields): the listed fields behave like one big field — each term scores by its best field, terms OR together. At least two columns.MATCH_BEST(<col>, <col> [, ...], '<text>')— field-centric twin (ESbest_fieldsover an explicit subset): each listed field scores the whole query and the best field wins (dis-max).MATCHwith all text fields already dis-maxes;MATCH_BESTpicks the subset.
- Pattern predicates (not analyzed — they run against the indexed
terms, so with a lowercasing analyzer write patterns lowercase):
MATCH_PREFIX(<col>, '<prefix>')— term prefix.WILDCARD(<col>, '<pattern>')—*any run,?any one char.REGEXP(<col>, '<pattern>')— regular expression.
MORE_LIKE_THIS(<col>, '<like text>')— rows textually similar to the given text: its most distinctive terms (by in-index document frequency; terms in fewer than 2 docs are ignored, at most 25 terms) OR-ed together. Composes like the other predicates.BOOSTED(<required>, <optional> [, <optional> ...])— optional scoring composition (ES boolmust+should): rows match iff<required>matches; each<optional>predicate only raises the score of rows that already match. Every argument must itself be a search predicate (possibly AND/OR/NOT-composed).AT_LEAST(<n>, <predicate>, <predicate> [, ...])— rows match when at least<n>of the search predicates match (ESshouldwithminimum_should_match: n); every matching predicate adds its score.<n>is a positive integer no larger than the predicate count.BOOST(<predicate>, <factor>)— the predicate's score multiplied by the positive numeric<factor>(ES per-field^boost); matching is unchanged. Composes withAND/OR/NOTand the other shapes.<col>may also be a declared.keywordtwin (title.keyword, exact original string) or acopy_tocomposite field. Text predicates on a numeric/date/bool column error.NOTreturns only rows the index contains: a row with none of the indexed columns present is never returned by a search query.score()projects the BM25 relevance of the row against the search predicates; it is also injected as a_scorefield (like_distancefor vector search). Only valid together with a search predicate — else an error. Multi-field matches score dis-max (best field wins, ESbest_fields).HIGHLIGHT(<col> [, <max_chars> [, <pre_tag>, <post_tag> [, <no_match_size> [, <fragments> [, <boundary>]]]]] [, MATCH(...)])projects the best-scoring snippet of the column's text (default fragment size 150 chars), matches wrapped in tags (<b>…</b>by default, HTML-escaped otherwise; empty string if the column didn't match). Apre_tag/post_tagstring pair overrides the markers (ESpre_tags/post_tags);no_match_sizereturns that many leading characters when nothing matched (ESno_match_size);fragments2–10 returns an array of fragments,0the whole field with every match marked; a trailing'word'/'sentence'(default'chars') widens fragment cuts to word or sentence boundaries; aMATCH()/SEARCH()argument marks that query's terms instead of the statement's (EShighlight_query). Only valid together with a search predicate (SEARCH.md).ORDER BY score() DESC LIMIT kpushes BM25 top-k retrieval into the index (no full scan); it requiresLIMIT, andscore()orders only descending. WithoutORDER BY, matches return in unspecified order.ORDER BY <col> [ASC|DESC]also works with search predicates: a single declared fast-field column withLIMITretrieves index-ordered top-k directly; any other ordering (multi-key, expressions, non-fast columns) gathers the matches and sorts through the ordinary executor. Keyset pagination:ORDER BY <col> LIMIT kplus a residual range predicate (AND col > <last>).- The named column(s) must be covered by a search index on the table —
errors if none exists. Query text may be a bind parameter (
?). - Aggregates /
GROUP BYwork over search queries (SELECT region, COUNT(*) … WHERE MATCH(…) GROUP BY region) — keyword andtime_bucket(step, date-col)groupings withCOUNT/COUNT(DISTINCT col)/SUM/AVG/MIN/MAXpush down as exact fast-field facets; every other shape (HAVING, text-column grouping, residual predicates) aggregates the gathered matching rows. See SEARCH.md. COUNT(DISTINCT <expr>)counts distinct non-null values, exactly — in any aggregate query, search or not.DISTINCTarguments to other aggregate functions are not supported.- Cannot combine with
JOIN,UNION,DISTINCT, orNEAREST. ResidualWHEREconditions,LIMIT, andOFFSETapply normally, post-search. - Visibility: writes become searchable within
refresh_ms(default 1 s, near-real-time); on the single-node write path a search after a write sees it immediately. The index is derived data — the table is the source of truth, and a lost or stale index rebuilds from it (automatically on restart, or viaREBUILD SEARCH INDEX). - Cluster mode: the DDL broadcasts like other DDL, every node indexes its shard from replicated writes, and a search scatters to all members — per-shard top-k merged by score at the coordinator, survivors re-read at read consistency. Every acked write is searchable cluster-wide (replicas commit pending index writes before answering). Unreachable members are skipped; their rows surface through reachable replicas.
EXPLAIN SCORE (one row's BM25 breakdown) is described with
EXPLAIN.
See also CREATE SEARCH INDEX · SUGGEST · Hybrid search · Reranking · Deep pagination · SEARCH.md
SUGGEST
SUGGEST '<text>' ON <index> [COLUMN <col>] [LIMIT <n>]
A standalone statement returning "did you mean" term suggestions
(input, suggestion, distance, doc_freq — closest first, most
frequent within a distance, at most n per input token, default 5).
COLUMN is required when the index covers several text columns. For
search-as-you-type completion, use an edge_ngram analyzer with
MATCH_PREFIX instead.
See also CREATE SEARCH INDEX · Full-text search
Vector search (NEAREST)
NEAREST (<path>, <query-vector>, <k>) runs an approximate nearest-neighbor
search over a vector index on (<table>, <path>), returning the
<k> closest rows ordered nearest-first, with the match distance exposed as a
_distance field:
CREATE VECTOR INDEX docs_emb ON docs (embedding) DIM 3 USING cosine;
SELECT id, _distance FROM docs NEAREST (embedding, [1.0, 0.0, 0.0], 5);
SELECT id FROM docs NEAREST (embedding, [1.0, 0.0, 0.0], 5) WHERE cat = 'news';
-- Managed (semantic_text) — embed a TEXT column via the [inference] provider:
CREATE VECTOR INDEX docs_sem ON docs (body) EMBED DIM 768;
SELECT id FROM docs NEAREST (body, 'natural language query', 10); -- query auto-embedded
The query position takes, depending on the index on <path>:
- a numeric array — a dense vector (
[0.1, …]); - an array of arrays — a multi-vector query against a
MULTIindex, scored by MaxSim ([[…], […]]); sparse([dims], [weights]), or a document in the stored shape, against aSPARSEindex (exact dot product;_distanceis its negation);recommend([positive keys], [negative keys])— the mean of the positive rows' vectors minus the negatives', with the seed rows excluded;- a string, auto-embedded, on a managed
EMBEDindex.
Several NEAREST clauses in one SELECT, each over its own vector column,
are a table's named vectors; they must be fused by RANK BY RRF (see
SEARCH.md). SELECT DISTINCT ON (g)
over a NEAREST query returns the best row per group, widening its own
retrieval to cover the groups. Details and the index options in
VECTOR.md.
EMBED makes the index managed: path names a TEXT column that skaidb
embeds via the configured [inference] endpoint (rather than reading a
pre-computed vector array), and a string NEAREST query on it is
auto-embedded. Embedding is out of band — a write commits with the raw text and
a background worker embeds it, so the model server being down delays
searchability but never blocks or fails a write. Needs [inference] enabled and
DIM matching the model; a managed index errors at create otherwise.
<query-vector> and <k> may be literals or bind parameters (?) in a
prepared statement. Requires a vector index on the path; errors if none
exists. Cannot combine with JOIN, UNION, aggregates/GROUP BY, or
ORDER BY (results are already ordered by distance) — WHERE, LIMIT, and
OFFSET apply normally, post-search.
See also CREATE VECTOR INDEX · Hybrid search · Reranking · VECTOR.md
Hybrid search (RANK BY RRF)
Fuse a full-text leg and a vector leg in one query by Reciprocal Rank
Fusion. The NEAREST clause supplies the vector leg, the WHERE search
predicate supplies the text leg, and RANK BY RRF fuses them:
SELECT id, rrf_score() FROM docs
NEAREST (embedding, [1.0, 0.0, 0.0], 100) -- vector leg (100 candidates)
WHERE MATCH(body, 'quick brown fox') -- text leg
RANK BY RRF -- fuse (default constant 60)
LIMIT 10;
Each leg fetches the NEAREST <k> candidates, then a hit at 1-based rank
r in a leg contributes 1 / (c + r) to its fused score; rrf_score()
exposes the sum. Because fusion is rank-based, BM25 scores and vector
distances need no normalization. Results are ordered by rrf_score()
descending; LIMIT takes the final top-k.
RANK BY RRF (<c>)overrides the rank constant (default 60; must be ≥ 1).- The residual (non-search) part of
WHEREfilters both legs, e.g.WHERE MATCH(body, 'fox') AND published = true. - Requires both a
NEARESTclause and a search predicate inWHERE. Cannot combine withJOIN,UNION,DISTINCT,GROUP BY, orORDER BY(ordering isrrf_score()desc). Works cluster-wide (both legs scatter-gather; the coordinator fuses).
Reranking (RERANK)
Second-stage relevance: retrieve the top candidates by BM25 (or hybrid RRF),
send (query, document) pairs to an external cross-encoder rerank
endpoint ([inference] rerank_url — see VECTOR.md), and
return the page in the reranker's order:
SELECT id, title, score() FROM docs
WHERE MATCH(body, 'how do I tune compaction')
RERANK TOP 100 -- rerank the top 100 BM25 hits
LIMIT 10;
RERANK [ON <col>] [WITH '<model>'] [QUERY '<text>'] [TOP <n>] [TIMEOUT <ms>]
— every part is optional:
ON <col>— the column whose text is sent as each candidate's document. Default: the columns the search predicate targets (all string fields for a field-lessSEARCH('…')). Document text is capped at 4000 chars.WITH '<model>'— the model name sent to the endpoint. Default:inference.rerank_model.QUERY '<text>'— the query the reranker scores against. Default: the search predicate's text (e.g. theMATCHstring).TOP <n>— the candidate window (default 100, capped at 10,000; windows past 1,000 go to the endpoint in batches of 1,000 — cross-encoder scores are per (query, document) pair, so batching does not change them).LIMIT/OFFSETthen page the reranked list.TIMEOUT <ms>— this statement's request timeout for the rerank endpoint, overridinginference.timeout_secs(a slow model or a large window).
score() reads the rerank score. Composes with hybrid retrieval — … RANK BY
RRF RERANK TOP 50 LIMIT 10 reranks the fused top-50 (rrf_score() keeps the
fusion score). Requires a search predicate in WHERE; cannot combine with
ORDER BY (the reranker orders results), GROUP BY, or aggregates. Runs
coordinator-side over the already-gathered candidates, cluster-wide; it
is opt-in per query, so a down rerank endpoint fails only queries that ask
for it. ES clients reach the same path via the text_similarity_reranker
retriever (SEARCH.md).
See also Model providers · Full-text search · VECTOR.md
Time-series tables
CREATE TIMESERIES TABLE declares a table whose rows are samples, stored
in the time-series engine (Gorilla-compressed chunks; feature status and
internals in TIMESERIES.md). Distributed: the DDL broadcasts, series place on the
ring and replicate at the write consistency, and queries union-merge across
members. Joins and
decommissions migrate series like any other data.
CREATE TIMESERIES TABLE cpu (SERIES KEY (host, core), RETENTION 30d);
INSERT INTO cpu (host, core, ts, value) VALUES ('web1', '0', 1712000000000, 0.63);
SELECT time_bucket(1m, ts) AS t, host, avg(value), max(value)
FROM cpu WHERE ts >= now() - 1h AND host = 'web1'
GROUP BY t, host ORDER BY t;
SELECT time_bucket(5m, ts) AS t, rate(value) FROM cpu
WHERE ts >= now() - 6h GROUP BY t;
CREATE TIMESERIES TABLE
CREATE TIMESERIES TABLE [IF NOT EXISTS] <table>
(SERIES KEY (<label> [, <label> ...])
[, RETENTION <duration>] [, OOO <duration>])
- Columns have roles.
SERIES KEYcolumns are string labels (every insert must set all of them);tsis the sample timestamp (atimestampor integer milliseconds, required, strictly increasing per series); every other inserted column is a numeric field. Names starting with__are reserved. - Append-only.
UPDATE/DELETEare rejected; old data expires viaRETENTION <duration>(whole storage blocks drop as they age out). Older timestamps for a series are rejected per sample unless the table declares an out-of-order window (OOO 10m): samples within the window of the series' newest are buffered and merged in time order; an equal timestamp overwrites (last write wins). - Dropped points are reported. An INSERT whose samples fall outside the
OOO window succeeds but returns the count that actually landed —
affected: 0when everything was discarded (one count per numeric field when rows carry several). Ingest code should compareaffectedagainst what it sent. - Retention and OOO are live-tunable.
ALTER TABLE cpu SET (retention = 60d)/SET (ooo = 1h)apply without recreating the table: retention takes effect at the next flush (widening it cannot resurrect already-dropped blocks), the OOO window on subsequent inserts — temporarily widening it is the supported way to backfill history into a live table.retention = 0clears retention (keep forever).
See also ALTER TABLE · CREATE ROLLUP · SHOW SERIES · TIMESERIES.md
Querying time series
- Pushdown.
AND-combinedtscomparisons and label=/!=predicates narrow the storage read; any other predicate still applies with full SQL semantics afterward. - Time-series aggregates, valid wherever other aggregates are:
rate(f)(per-second counter rate, reset-aware),increase(f)(total counter increase, reset-aware),delta(f)(last - first),first(f)/last(f)(value at the earliest/latesttsin the group).rate/increase/deltaare computed per series over its time-ordered samples, then summed across the series in the group — PromQLsum(rate(...))semantics. Group by label columns to keep series separate. GROUP BY/ORDER BYmay reference output aliases (GROUP BY twithtime_bucket(1m, ts) AS t) — on every table kind; see the general alias-resolution rules under Output aliases and positions.- Rollup query rewrite: an aggregate query on the source
table whose window reaches past the source's
RETENTIONhorizon answers the aged buckets from the coarsest rollup whoseBUCKETdivides the group'stime_bucketstep, stitched with exact source data for the within-retention part — so long-range dashboards keep working after raw samples age out, without naming the rollup. Applies tocount/sum/avg/min/max/first/last;rate/increase/deltaneed raw samples and never read rollups. In the rollup-served region,first()/last()order series at bucket granularity, and the window edge trims to whole rollup buckets. - Not supported on time-series tables:
JOIN,UNION,NEAREST, transactions.
See also Aggregate functions · Scalar functions · CREATE ROLLUP · TIMESERIES.md
SHOW SERIES
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)
See also CREATE TIMESERIES TABLE · TIMESERIES.md
CREATE ROLLUP
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 Event-table rollups)
Time-series rollups
Rollups (CREATE ROLLUP r30m ON cpu BUCKET 30m RETENTION 90d): a
derived time-series table holding per-bucket partials of its source,
maintained automatically when source windows flush. For each source field
f it stores f_count, f_sum, f_min, f_max, f_first, f_last
(so avg = f_sum / f_count), keyed by the same series labels at the
bucket-start timestamp. Query it like any TS table
(SELECT ts, value_sum / value_count FROM r30m WHERE host = '...').
BUCKET must evenly divide the 2 h storage window. Dropped with
DROP TABLE (dropping the source cascades). Repair-merged samples do not
retroactively update rollups.
Event-table rollups
The event-table form, CREATE ROLLUP <r> ON <event-table> BUCKET
<duration> [BY (<dims>)] AGGREGATE (<agg> AS <alias>, …) [REFRESH
<duration>] [RETENTION <duration>] maintains a pre-aggregated bucket
table over a partitioned or clustered regular table: one row per
(bucket, dims…) holding the declared aggregates (count(*),
count(col), sum, min, max) — dashboards query the small rollup
(SELECT bucket, n FROM r WHERE bucket >= …) instead of re-scanning
raw events.
The bucketed column is inferred: the source's partition
column, else its clustering head (a source with neither is refused —
recompute must prune). The rollup is a real table with PRIMARY KEY
(bucket, dims…): query it, index it, back it up like any other.
A background pass (~1 min cadence; on clusters one member owns each
rollup) recomputes the trailing REFRESH window (default 2
buckets) from the raw source and upserts the buckets — recompute
replaces rather than increments, so retries, ownership handovers and
replica divergence all converge to the same values; late rows within
the window are absorbed on the next pass. The same pass deletes
the window's buckets the recompute no longer produces (every source
row of the bucket deleted, or a dim value moved to another group),
so the rollup tracks removals as well as additions. A row arriving
LATER than the refresh window does not update its bucket, and a
bucket outside the window is not reconciled — both wait for a
repopulation. On creation the first pass populates the full history
(and so does the first pass after a restart, which reconciles every
bucket).
RETENTION expires rollup rows by age (independent of the source's ttl —
keep 13 months of hourlies over 30 days of raw events). Groups whose bucket
or dim value is missing/NULL are skipped. Dropping the source drops its
rollups; SHOW CREATE TABLE <r> shows the full rollup definition followed
by its indexes. Rollups of rollups are refused.
Indexes on a rollup
CREATE INDEX <i> ON <r> (<col>) on a
dimension or an aggregate alias is an ordinary range-capable secondary
index: the planner uses it for WHERE col >= a AND col < b, for
ORDER BY col [DESC] LIMIT k (an index-ordered walk with early stop)
and for index-only range counts, exactly as on a plain table (a
rollup with RETENTION counts by scan, the rule for every table with
a ttl). Every refresh maintains it through the write path — an
upserted bucket replaces its old entry, a reconciled-away bucket loses
its entry — on every replica of a cluster. It is listed by SHOW
INDEXES, rendered by SHOW CREATE TABLE <r>, replayed with the
rollup on schema sync, mirrored to witnesses with witness.mirror_indexes,
and dropped with the rollup (and with the source, through the cascade).
See also CREATE TIMESERIES TABLE · Range partitioning · Clustering · Querying time series · SHOW CREATE TABLE
Streams, procedures, jobs and triggers
CREATE STREAM
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 STREAM declares a standing filter over a table's writes:
every committed change to <table> is matched against the WHEN
predicate, which is an ordinary boolean expression evaluated like a
WHERE clause. retention accepts a duration ('24h', '90m', or a
bare 30d); start is 'now' (future writes only, the default) or
'earliest' (first backfills every row that already matches), and any
other start is refused rather than silently ignored. pre_image = true
adds the row as it was before the change, in an old column. The predicate is stored as text and
re-parsed on open, which is also what SHOW STREAMS displays.
Events land in a table you can read today. Each matching write
appends to _stream_<name>, an ordinary table in the same database:
columns id (position), op (put, exit when a row stops matching,
or delete), k (the row's primary-key value), ts, doc (the row; for
a delete, the row as it last was) and, with pre_image, old. It
replicates like any table, retention is 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. With the MQTT broker enabled, each
event is also pushed live to $stream/<db>/<name> (best-effort, so a
consumer that missed one replays the log from its last id). Delivery is
at-least-once and ordered per key only; see STREAMS.md.
A stream that matches most writes roughly doubles the table's write
volume, since every matching change writes a second replicated row.
See also CREATE TRIGGER · PUBLISH · STREAMS.md
CREATE PROCEDURE
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 PROCEDURE stores a statement list in the catalog that CALL
runs server-side, so a fixed sequence of statements costs one round trip
instead of N. A body takes SELECT, INSERT, UPDATE, DELETE and
nested CALL — no DDL, no BEGIN/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. A CALL returns the
last statement's result set, inherits the caller's scan budget and
deadline, and runs with invoker rights: EXECUTE on 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 WHILE has no iteration
cap — it is bounded by the caller's scan budget and deadline, which is the
only termination guarantee — and an EXCEPTION handler 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.
See also CREATE JOB · CREATE TRIGGER · GRANT · PROCEDURES.md
CREATE JOB
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 JOB runs a stored procedure on a schedule — EVERY '<duration>'
(a duration since the last tick) or CRON '<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_runs tables, which
clients may read and nothing else. Delivery is at-least-once, so a body
should key its writes on SCHEDULED_AT() — the logical tick, stable across
attempts. Timezones are UTC or a fixed +HH:MM offset; named zones are
refused, since skaidb embeds no timezone database. See
PROCEDURES.md.
See also CREATE PROCEDURE · CREATE TRIGGER · PROCEDURES.md
CREATE TRIGGER
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)
CREATE TRIGGER is the same job with a change stream as its source
instead of a schedule: it owns a hidden stream carrying pre-images, and
DROP TRIGGER removes both. The body sees NEW, OLD (NULL on an
insert), EVENT() and OP, 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.
See also CREATE STREAM · CREATE PROCEDURE · CREATE JOB · PROCEDURES.md
PUBLISH
PUBLISH '<topic>' PAYLOAD <expr> [QOS 0|1|2] [RETAIN]
PUBLISH sends one MQTT message from SQL, handed to the node's broker
(MQTT.md): subscribers on this node receive it, peers get the
usual forward, a RETAIN lands in the retained store, QOS is the delivery
QoS (default 0). The topic is a literal concrete name (no +/#, not the
broker-owned $ tree); the payload is any expression — a string publishes
its bytes, a document or array its JSON, NULL an empty message, other
values their text. Needs PUBLISH on the topic under the role's MQTT topic
grants (a role with no topic grants keeps the open default, like a connected
client); the node must run the broker ([mqtt] enabled = true). Allowed in
procedure bodies, so a trigger can publish a change (docs/PROCEDURES.md);
a CALL is then gated on the body's topics. Returns one affected row.
Delivery only: topic→table capture rules run on client publishes.
See also CREATE TRIGGER · GRANT · MQTT.md
Transactions
BEGIN, COMMIT, ROLLBACK
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]
BEGIN/COMMIT/ROLLBACK wrap several statements in a transaction
with read-your-writes: buffered writes are invisible to every OTHER
session until COMMIT (transaction state is per-connection), and
ROLLBACK discards them. Available on the embedded engine and over
binary-driver connections to a standalone server; stateless surfaces
(REST/ES/UI) autocommit, and cluster mode refuses (no distributed
transaction coordinator). Concurrent sessions' transactions coexist;
conflicting commits resolve last-writer-wins. DDL is not transactional.
Measured ACID contract (acid-crash harness, kill -9 rounds):
every acknowledged statement survives a crash (durability is
fsync-on-ack), and a transaction commit is all-or-nothing across
crashes — COMMIT persists a redo journal before applying, and
recovery replays an interrupted commit to completion (a crash before
the journal is durable means the transaction cleanly never happened).
See also BEGIN ATOMIC · BEGIN ATOMIC PARTITION · Concurrency and atomicity
BEGIN ATOMIC
BEGIN ATOMIC [TRANSACTION] (no pin) is the CLUSTER cross-partition
transaction: rows of any row tables — several partitions, plain
replicated tables — buffer with the same overlay and commit
all-or-nothing; a write set spanning more than one placement group
takes two-phase commit (a prepared manifest entry per group, then one
decision-row round as the atomic point, then the applies; replicas'
sweepers carry out a committed decision and abort an entry left
undecided past a minute). Same refusals as the pinned form; UNIQUE
indexes enforced at COMMIT. Details in
CLUSTER_PLACEMENT.md.
See also BEGIN ATOMIC PARTITION · CLUSTER_PLACEMENT.md
BEGIN ATOMIC PARTITION
BEGIN ATOMIC PARTITION (col = lit, ...) is the CLUSTER transaction:
a single-shard transaction pinned to one partition of distribute_by
tables (over a binary-driver connection). Every statement inside may
touch only rows whose distribution columns equal the pinned literals —
across any number of tables distributed by those columns — and reads see
the session's own buffered overlay. COMMIT applies the write set
all-or-nothing: it serializes on the partition through a consensus
round whose committed manifest embeds the write set, so a coordinator
crash after the round is completed forward by every replica's sweeper
(never a partial commit).
Isolation is atomicity only — concurrent transactions and non-transactional writes resolve last-writer-wins per row; there is no read-set validation.
UNIQUE indexes are enforced at COMMIT (every introduced value is claimed
through the reservation round before the partition round, so a duplicate
fails the whole transaction with nothing applied; retired values are
released after the apply).
Constraints: the buffered write set is capped at 8 MB;
tables with GLOBAL indexes or value-TOAST are refused (those write legs
live outside the partition);
DDL and USE are refused while a transaction is open; time-series
writes cannot participate.
Transaction control requires the INSERT privilege on the session's
current database (the same gate as embedded BEGIN) — a grant ON * also
satisfies it. The gate decides only whether a session may open a
transaction; every statement inside is still authorized against its own
table.
Clients MUST stop on a failed BEGIN: the statements that follow would
otherwise execute in autocommit — durable partial writes where a transaction
was intended.
See also BEGIN ATOMIC · CLUSTER_PLACEMENT.md
Databases and sessions
CREATE DATABASE
CREATE DATABASE [IF NOT EXISTS] <name>
DROP DATABASE [IF EXISTS] <name>
USE [DATABASE] <name>
SHOW DATABASES
CREATE/DROP DATABASE, USE, and SHOW DATABASES manage
databases — each is an isolated set of tables and indexes. A database is a
namespace: internally a table is stored under a per-database name, with the
implicit default database using unprefixed names (so an existing
single-database directory keeps working unchanged — its tables are the
default database). USE sets the current database for the connection
(the skaidbsh shell, or a binary-protocol connection); unqualified table
names resolve against it, and db.table reaches another database without
switching. SHOW DATABASES lists them as (database, current) rows with *
marking the current one; SHOW TABLES/SHOW INDEXES are scoped to the current
database. Server-owned databases whose name starts with _ (_bi, _mqtt)
are listed only for a role holding ADMIN — a listing convenience, not a
privilege boundary: access to them is governed by grants like any other
database. The default database cannot be dropped; dropping the current
database (or its cascade) reverts the connection to default.
DROP DATABASEcascades to everything in the database: its tables (time-series tables and rollups included) with every index on them, views and materialized views, sequences, types and domains, streams, procedures, jobs and triggers. It also revokes, from every role, each grant on the database itself (ON DATABASE <name>) and on its tables, columns and procedures. A database created later under the same name therefore starts empty and with no grants; global grants, topic grants and grants on other databases are untouched.- Replication: in cluster mode,
CREATE/DROP DATABASEbroadcast to every node (like other DDL), and writes inside any database replicate by the same quorum/hinted-handoff/read-repair path as thedefaultdatabase. The REST/querygateway is stateless — it always starts atdefault, so reach other databases there withdb.tablequalifiers rather thanUSE. USEwaits on nothing: it checks the name against the node's catalog as of its last schema change, which the node keeps apart from its data — a long write elsewhere on the node does not delay it.SHOW TABLESreads the same copy.- Ensure-schema statements stay local:
CREATE DATABASE,CREATE TABLEandCREATE INDEX(notWITH (global = true))… IF NOT EXISTSof an object the node receiving them already holds change nothing, and the node answers them itself from that copy of its catalog — without contacting the other members of a cluster, and without waiting for a long write — so an application may run them on every start or cycle. A member that missed the object gets it from the schema sync of the next anti-entropy pass, as for any other DDL.IF NOT EXISTSof an existing object neither compares its definition nor validates the rest of the statement (a vector, search or geo index'sIF NOT EXISTSdoes compare — those still broadcast).
See also SHOW TABLES · GRANT · Identifiers & literals
Session settings
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
SET CONSISTENCY is per-connection session state on binary-protocol
sessions (it overrides the wire consistency until changed); the stateless
REST gateway rejects it with guidance.
SET SCAN BUDGET ROWS <n> [BYTES <n>] is
per-connection too, and tightening-only: statements run under
min(node config, session) per axis, so a session can restrict what
its own statements may scan but never lift the operator's ceiling —
which is why any authenticated role may issue it (values must be
positive; DEFAULT clears). One trap to know: after a silent driver
reconnect the session reverts to node defaults — drivers replay the
session database but not this setting (the Rust driver's
set_scan_budget_rows is the exception: it stores and replays it).
See also SET CONFIG · Streamed queries · CLUSTER_CONSISTENCY.md
Access control
Users and roles
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>
A user authenticates (SCRAM on the binary protocol, HTTP Basic on REST)
and acts as its own-named role; GRANT ROLE r TO u adds inherited roles. A
user created … GSSAPI is external: it has no local password — a
Kerberos KDC vouches for the principal, and skaidb only maps the
authenticated principal to its own-named role (grants and role inheritance
work identically). An external user cannot authenticate by password/SCRAM,
and a password user is never reachable through the external path.
Users/roles persist in the catalog, replicate like other DDL (passwords
travel between nodes only as salted SCRAM verifiers, never plaintext), and
converge via schema repair. Each credential carries a random salt,
drawn once when the password is set and stored inside the verifier, so it
is identical on every member; ALTER USER … PASSWORD draws a fresh one. The config-file superuser remains the
bootstrap principal. The Prometheus endpoints are covered too:
remote_write requires INSERT and /api/v1/query* require SELECT on
the metrics table.
See also GRANT · KERBEROS.md · CLUSTER_SECURITY.md
GRANT and REVOKE
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>]
<privilege> is one of SELECT, INSERT, UPDATE,
DELETE, CREATE, DROP, GRANT, MONITOR, PUBLISH, SUBSCRIBE,
EXECUTE, BI,
ADMIN (ADMIN on * =
superuser; ADMIN on a table implies every privilege on it). BI
(granted ON *) lets a role use the business-intelligence app on a BI
node — a separate decision from reading a table through a driver, and it
widens nothing: what the role may read is still exactly its SELECT
grants (see BI.md). MONITOR
(granted ON *) opens the read-only control plane — SHOW CLUSTER,
SHOW CONFIG (secrets stay masked), SHOW SLOW QUERIES, and the matching
read-only HTTP admin endpoints — so an application role can report cluster
health and its effective config without an admin credential; it never
authorizes a mutation (SET CONFIG, repair, membership stay ADMIN).
Delegating GRANT
GRANT is delegable per object: GRANT / REVOKE of a privilege
need the GRANT privilege on the object the statement names — on the
database for ON DATABASE <db>, on the table or its database for
ON <table> (and likewise a procedure). A role holding GRANT ON DATABASE
app therefore administers access to app — it can grant and revoke any
privilege on app, its tables and its procedures, to any role — and to
nothing else: ON *, ON TOPIC, another database, and user/role
management (CREATE/ALTER/DROP USER, CREATE/DROP ROLE, GRANT
ROLE, SHOW GRANTS) need GRANT ON *. Holding GRANT on a database
gives no access to its data by itself.
Index DDL privileges
Index DDL is table-scoped: CREATE INDEX needs CREATE on the target
table, and DROP INDEX / REBUILD/ALTER of an index need that same
CREATE on the index's owning table — an index is derived data, so a
role that can create its own indexes can also drop or retune them (no
DROP on Global needed; DROP TABLE still requires DROP). Dropping a
nonexistent index needs no privilege (IF EXISTS stays an idempotent
no-op; index existence is already free via SHOW INDEXES).
Database and table grants
A grant ON DATABASE db covers every table in that database (checked
against the session's current database; shown by SHOW GRANTS as
db:<name>).
A table grant is scoped to the table's canonical <database>.<table>
identity, not the raw name. A bare GRANT … ON t issued while the
session is in database d grants d.t — never a wildcard that would
match a same-named t in another database — and a qualified
GRANT … ON d.t authorizes the natural USE d; … t query just as it
authorizes … FROM d.t (both resolve to the same table). SHOW GRANTS
renders the object as <database>.<table> (bare for the default
database).
Column-level grants
GRANT SELECT (id, email) ON users TO r (also
INSERT (…) and UPDATE (…)) grants the privilege on the listed
top-level fields only, one grant per column; REVOKE SELECT (email) ON
users FROM r takes one back. A statement runs under a column grant when
every column it touches on the table is granted: for SELECT, whatever
the projection, predicate, ORDER BY/GROUP BY, join condition (a
qualified u.col is charged to the aliased table), subquery, or
set-operation leg names — SELECT * needs the table grant, count(*)
needs no column; for INSERT, the statement's column list (a list-less
INSERT needs the table grant); for UPDATE, the assigned columns. A
column-level INSERT/UPDATE still needs SELECT on whatever the
statement reads of the table (assignment expressions, the predicate,
RETURNING), because an assignment or RETURNING could copy a column
out. A nested path (addr.city) is charged to its root field (addr).
Column grants inherit through GRANT ROLE, a table- or database-level
grant supersedes them, and SHOW GRANTS renders each as
<table>(<column>). A denial names the granted set and the column the
statement wanted. DELETE has no per-column form.
Who may manage access
All management statements (and SHOW GRANTS) require the GRANT privilege
cluster-wide — except SHOW GRANTS FOR <your own role>, which any
authenticated role may run to inspect itself. Auth DDL (user/role/grant
changes) is recorded in the identity audit log (the login-log category) with
the acting role and a secret-free statement summary.
See also Users and roles · CREATE VIEW · Writes through a view · BI.md
Introspection
Read-only catalog statements; none of them reads table data unless its section says so.
SHOW TABLES and SHOW INDEXES
SHOW TABLES
SHOW INDEXES
SHOW TABLES lists catalog tables as
(table, primary_key, replication, nodes, witness, transition, kind)
rows — replication/nodes per-table placement, witness the
mirroring flag, transition whether a placement change's
dual-placement window is currently open, kind one of
row / timeseries / rollup / view / matview. On a cluster a trailing sharded
column is true when no single member holds every row of a row table
(its effective RF, the per-table override or the cluster default, is
below the member count and it is not pinned);
SHOW INDEXES lists secondary, vector, and search indexes as
(index, table, kind, columns, local, definition) — definition is the
canonical CREATE … IF NOT EXISTS statement that recreates the index
exactly (the same rendering cluster schema sync replays, so vector
DIM/metric/QUANTIZED/EMBED, search analyzer options, UNIQUE and
global flags are all present); local is this node's live
state for the index: ok (open and serving), building (backfill or
catch-up running), or missing (in the catalog but no live index — the
divergence to check first when one node's searches fail while its peers'
succeed). Both are read-only and require no special privilege,
so a monitoring/tooling agent can enumerate the schema without /query
data access. In cluster mode they answer from the local catalog — ask each
node to compare local states across the ring. SHOW TABLES reads the
node's catalog as of its last schema change without waiting for the node's
other work, so a long write does not delay it.
See also DESCRIBE · SHOW CREATE TABLE · Per-table placement · CREATE INDEX
SHOW CREATE TABLE
SHOW CREATE TABLE <table> -- the table's DDL + its indexes + constraints, replayable
SHOW CREATE TABLE <t> renders the table's definition as replayable
DDL — the CREATE TABLE (or CREATE TIMESERIES TABLE / CREATE
ROLLUP) statement carrying every catalog option (ttl, memory,
replication, nodes, witness, toast_threshold, distribute_by), followed by one row per
index on the table (secondary/global, vector, search, geo). One
statement column, one row per statement; works on hidden __toast__/
__gidx__ companions when named explicitly. Needs no privilege beyond
connecting (same class as DESCRIBE).
See also DESCRIBE · SHOW TABLES
DESCRIBE
DESCRIBE <table> -- alias DESC <table>; catalog columns only
DESCRIBE <table> FULL [SAMPLE <n> | EXACT] -- also reads rows: every field and its types
DESCRIBE <table> (alias DESC <table>) is one table's structure as
(column, key, indexes) rows — one per column that is part of the primary key
or an index. key is primary key (with a (n/m) position for a composite
key); indexes lists the covering indexes as name (kind) (secondary,
secondary, building, vector, search), and a MULTIKEY [] suffix is
stripped from the column name. A generated column is listed too, with
generated stored / generated virtual as its key (an index over a
VIRTUAL column's expression is attributed to the column). Rows come out
primary-key columns first (in key order), then the remaining columns
alphabetically.
Like the SHOW pair it is read-only, needs no privilege, and answers from
the local catalog — no data is read. The store is schema-less, so a column
that is neither part of the key nor indexed is not in the catalog and does
not appear here. An unknown table is an error. Accepts a db.table
qualifier.
DESCRIBE <table> FULL [SAMPLE n | EXACT]additionally reads the data to surface every field — the schema-less fields the catalog can't know. It returns(column, type, key, indexes): the addedtypeis the set of value types seen for that field, joined by|(a field can hold several types). Because it reads rows,FULLrequires theSELECTprivilege (plainDESCRIBEdoes not) and, on a cluster, reads the local shard (complete when RF ≥ members). Time-series tables report catalog columns only (blank types).- Default/
SAMPLE n: reads the firstnrows in primary-key order (default1000), streamed so it reads at mostnrows and never materializes the table — a field that appears only in rows outside the sample is not seen; widenSAMPLEto trade cost for completeness. EXACT: scans all rows (streamed — memory stays bounded regardless of table size) and caches the field map in a RAM field registry, stamped with the table's write sequence. RepeatedEXACTs on an unchanged table answer from RAM in O(fields); any write, delete, or reclaim to the table invalidates the stamp so the next call rescans — results are always exact, including fields disappearing when their last row is deleted. The registry is process-local (a restart clears it; the firstEXACTafter rebuilds) and is skipped for TTL tables, whose row visibility decays without writes —EXACTon those rescans every call.
- Default/
See also SHOW TABLES · SHOW CREATE TABLE · Generated columns
SHOW STATUS
SHOW STATUS
SHOW STATUS returns storage and runtime statistics as (metric,
value) rows — the node's totals (table/index counts, on-disk and memtable
bytes, SSTable count, WAL bytes/fsyncs, compactions, cache
hit/miss/hit-rate) and a per-table table.<db>.<name>.* breakdown of the
current database only (other databases' tables, search indexes and
time-series tables are not listed): row tables report
{live_keys,tombstones,disk_bytes}, TIME-SERIES tables report
{kind=timeseries,series,samples_appended,disk_bytes} (there are no
tombstones and no cheap exact live-sample count; the legacy
timeseries.<name>.* keys remain). It
is the same data the server publishes at GET /metrics, surfaced as SQL.
See also SHOW MAINTENANCE · METRICS.md
Other catalog listings
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 ENDPOINTS -- every member's client endpoint, its state
-- (ready | resyncing | unreachable, as the
-- answering member sees it) and `local` on the
-- answering member; no privilege needed, no rows
-- on a standalone server. What driver pools read
-- to balance across members (PROTOCOL.md §6.1)
Each listing is described with the objects it lists:
SHOW CONSTRAINTS with Column constraints,
SHOW FOREIGN KEYS with FOREIGN KEY, SHOW SEQUENCES with
Listing and exporting sequences, SHOW VIEWS with
CREATE MATERIALIZED VIEW, and SHOW TYPES with
Enum types and domains. SHOW ENDPOINTS is what
driver pools balance on —
PROTOCOL.md §6.1.
See also SHOW TABLES · DESCRIBE
Administration
The admin control plane runs on the network server only. Reads need
MONITOR on *, mutations need ADMIN on * (ADMIN implies MONITOR).
Admin statements
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
Admin statements (SHOW CLUSTER/SHOW CONFIG/SET CONFIG/
SHOW SLOW QUERIES/REPAIR CLUSTER/RECLAIM/ALTER CLUSTER) are the
SQL spellings of the HTTP /admin/* control plane — identical handler,
RBAC (reads: MONITOR on *; mutations: ADMIN on *), and audit;
results come back as (key, value)
rows (LIKE uses %/_). They execute on the network server — the
embedded --local engine rejects them.
See also Cluster membership and repair · SHOW QUERIES · Session settings · GRANT and REVOKE
SHOW QUERIES and KILL QUERY
SHOW QUERIES -- statements running NOW on this node
-- (id, user, db, via, elapsed_ms,
-- rows_examined, sql)
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
See also SHOW MAINTENANCE · CHECK INDEX · Streamed queries
SHOW MAINTENANCE
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
See also SHOW QUERIES · SHOW STATUS · CHECK INDEX
Cluster membership and repair
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)
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")
See also Per-table placement · SHOW TABLES · CLUSTER_MEMBERSHIP.md · CLUSTER_REPAIR.md
Advisor
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')
See also Model providers · EXPLAIN · ADVISOR.md
Model providers
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
A provider of the embedding type serves managed EMBED vector indexes
(CREATE VECTOR INDEX) and one of the rerank type
serves RERANK.
See also Advisor · ADVISOR.md
BACKUP
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
BACKUP TO '<path>' copies the whole data directory (tables, WALs,
catalog, search and time-series stores) under the exclusive lock —
the copy only: the markers that describe it (the WAL cut and the
completion marker, below) are written after the lock is released, and
the result's locked_ms is the time the lock was held, elapsed_ms
the whole statement —
crash-consistent by construction (opening the copy replays WALs like a
crash recovery); vector indexes load their persisted snapshot on open and
replay only rows newer than its watermark (full rebuild if absent).
A target already holding a skaidb backup is updated incrementally:
unchanged files are reused, changed ones re-copied, removed ones deleted —
always a faithful full snapshot, costing only the delta (the result reports
files/bytes copied, reused_files/ reused_bytes, deleted). A target
that exists but is not a backup is refused.
The process lock (.lock) and the disk guard's reserve file
(.skaidb-reserve) are live-node state and are never copied.
Completion markers
A backup writes BACKUP_IN_PROGRESS into the
target before its first copy and BACKUP_COMPLETE after its last: the
file list with sizes, a checksum of catalog.json, and the completion
time. The completion marker is the LAST thing written (atomically), so a
backup interrupted at any point — the node killed, the disk full, the
statement cancelled — leaves BACKUP_IN_PROGRESS and no valid
BACKUP_COMPLETE; that is what an incomplete backup looks like, and
ls <path> shows it. RESTORE refuses such a target (backup at
'<path>' is incomplete: the backup that wrote it was interrupted …),
refuses one whose files disagree with its marker (backup at '<path>' is
damaged: <file> …).
The next BACKUP into an interrupted target takes it over: files that
match their source (size and mtime — a half-copied file never does) are
kept, everything else is copied again, and the run ends with a valid
BACKUP_COMPLETE, so a scheduled backup recovers by itself from a run that
was killed (the server log says backup target '<path>' holds an interrupted
backup … taking it over). Only a target that another BACKUP of the same
node is still writing is refused (another backup into this target is still
running).
A
target with a valid BACKUP_COMPLETE beside a leftover
BACKUP_IN_PROGRESS is complete (the marker is authoritative). A
backup with NEITHER marker was written by a skaidb without them: it
restores, and the result carries a warning column saying its
completeness was not verified — the only way to restore an unverified
backup; there is no override that accepts an interrupted one. The next
BACKUP into such a target adds the markers. Extra files beside the
listed ones (CLUSTER_CUT) never fail verification.
BACKUP CLUSTER
On a cluster each node backs up its own shard — and BACKUP CLUSTER TO
'<path>' does that on EVERY member in one statement, then mints a single
cut instant strictly AFTER all backups completed and records it beside each
one (CLUSTER_CUT, and the cut_ms result column). Restoring every node
with RESTORE FROM '<path>' TO TIMESTAMP <cut_ms> lands the whole ring on
that one instant: each node's archive replay extends its backup FORWARD to
the cut (replay is exact at its end — it can reach forward, never rewind),
which is why the cut postdates the backups. Requires
storage.wal_archive_dir for the restore to be exact.
See also RESTORE · CLUSTER_OPERATIONS.md
RESTORE
RESTORE FROM '<path>' -- embedded / single node only; old data kept aside
RESTORE FROM '<path>' TO TIMESTAMP '<when>' -- point-in-time recovery
RESTORE FROM '<path>' swaps the backup in and reopens,
moving the previous data aside to <dir>.pre-restore[-<n>] (never
deleted; the restored directory keeps the mode of the one it
replaces); it refuses to run on a cluster — stop the node and restore
offline, then let repair converge it.
Point-in-time recovery
RESTORE FROM '<path>' TO TIMESTAMP '<when>' is point-in-time
recovery: the backup is restored, then archived WAL segments are
replayed on top of it up to <when> — so a bad DELETE at 14:07 is
undone without losing the work done since the last backup. <when> is
ISO-8601 ('2026-08-19T14:06:00Z', '2026-08-19 14:06') or epoch
milliseconds, and the window INCLUDES everything stamped in that
millisecond. Requires storage.wal_archive_dir; the result adds
replayed_to, segments, records and segments_skipped columns,
and tables_skipped when a table could not be replayed: a table whose
name nests below another path (u/a) shares its WAL archive with other
names, so it is left at backup time and listed there.
If the restore fails after the swap (the restored data does not open,
or the archive replay fails), the previous data is put back: the failed
directory is renamed to <dir>.failed-restore-<n>, the set-aside copy
renamed into place, then the failed copy deleted — a crash mid-way
leaves the previous data whole, either at <dir> or still at
<dir>.pre-restore[-<n>]. A leftover <dir>.failed-restore-<n> is a
failed restore's data: delete it. If the error says putting the data
back failed too, it names where the previous data is, and the node
refuses every statement until it is restarted: stop it, move the
previous data back to <dir> and restart. A restore cut short by a
crash leaves a RESTORE_IN_PROGRESS file in <dir>, and a server
refuses to start there (or on a missing <dir> with a
.pre-restore* / .failed-restore-* directory beside it) until the
right directory is moved back.
The replay costs what the window costs, not what the archive holds:
BACKUP TO records where the backup sits in the node's archive (a
WAL_CUT file in the backup), and the restore skips every archived
segment up to there — the backup holds those records already
(segments_skipped). A backup without that record (taken by an older
skaidb, or streamed) or one whose record does not match the archive
it is restored against is replayed from the archive's first segment;
the result is the same either way. Records apply in batches with one
WAL sync per segment, and the replayed records are not archived a
second time.
Refused, rather than silently approximated, when: no archive is configured, the instant is unparseable, or the window crosses a schema change — the archive holds row mutations only, so DDL inside the window cannot be replayed and rows belonging to it would be dropped. Restore to a point before the change, or use a later backup.
Replay is idempotent (every record keeps its original stamp and the
engine is last-writer-wins: a replayed record older than the version
the table holds changes nothing, index entries included), so running
the same restore twice gives the same state. Replayed rows go through the same apply path a
replicated write does, so secondary, full-text, vector and geo
indexes are maintained — a recovered row is found by MATCH,
NEAREST and an indexed lookup, not only by a scan. Change streams
observe replayed rows as they would any other write, so a consumer may
re-see changes it already processed. Time-series tables replay too, cut on the sample's
ARRIVAL time rather than its own timestamp — a backfill written at
14:10 carrying ts=13:00 does not appear in a restore to 14:06.
See also BACKUP · Point-in-time reads · CLUSTER_OPERATIONS.md
Expressions
Nesting limit and chain width
Nesting limit: a statement nests at most 128 levels — parentheses,
function arguments, array/object literals, subqueries and CTE bodies,
NOT/- chains, procedure blocks (the statement, its SELECT and a
subquery's SELECT count as levels too). Deeper statements fail to parse
with expression nesting exceeds 128 levels.
Chain width: a flat AND/OR chain of any width parses (it is
folded into a balanced tree; operands keep their order and short-circuit
as usual), as does a long IN list or VALUES list. A flat + - * /
chain keeps left-to-right grouping and is limited to 512 terms; longer
ones fail to parse with expression chain of <op> exceeds 512 terms —
group with parentheses ((…) + (…)) to go wider.
Literals
Integer (42), float (3.14, scientific 1.2e-5/3E8), string ('ada'), TRUE,
FALSE, NULL, array literals [<lit> [, <lit> ...]] (constant
elements only — a literal or a negated number, e.g. [0.1, -0.2, 0.3]),
and object/document literals {<key>: <lit> [, ...]} — the way to write
a nested document in SET/VALUES. Keys are bare identifiers or string
literals (quote reserved words: {'from': 1}); values must be constant and
may nest arrays and objects: {name: 'ada', tags: ['x'], addr: {city: 'paris'}}.
A duplicate key keeps the last value; {} is the empty document. Assigning
an object literal to a path (SET meta.addr = {…}) replaces that whole
sub-document (sibling fields outside the path survive); dotted-path SET
(SET meta.addr.city = 'x') remains the idiom for updating a single scalar
leaf in place.
Integer literals span the whole int64 range,
-9223372036854775808 … 9223372036854775807; a larger literal is a
parse error (write a float: 9.3e18).
Duration literals: an integer immediately followed by a unit — 250ms,
15s, 5m, 2h, 30d, 1w — is a duration, valued as integer
milliseconds (5m = 300000). Usable anywhere an integer is.
See also Identifiers & literals · Types · Numbers
Column and field paths
name, or a dotted path into a nested document:
address.city, a.b.c. Usable in projections, WHERE, GROUP BY,
ORDER BY, and UPDATE … SET targets.
See also Table qualifiers
Operators
By increasing precedence:
ORANDNOT <expr>- comparison:
=,!=(or<>),<,<=,>,>=, and&&(range overlap: both sides two-element[lo, hi)arrays, a NULL bound = unbounded; NULL for anything else) <expr> IS [NOT] NULL- postfix predicates:
<expr> [NOT] IN (<expr> [, ...]),<expr> [NOT] BETWEEN <expr> AND <expr>,<expr> [NOT] { LIKE | ILIKE } <pattern-expr> - additive:
+,- - multiplicative:
*,/,%(remainder;a % bismod(a, b)) - unary:
-<expr>,NOT <expr> - parentheses
( … )
in, between, like, and ilike are contextual keywords — still usable
as column names outside the operator position.
% / mod(a, b) — the remainder of the truncating division, with
the sign of the dividend (7 % 3 = 1, -7 % 3 = -1), an integer for
two integers and a float otherwise; a zero divisor yields NULL, like
/.
See also CASE · EXCLUDE constraints · Numbers
Array membership
Array membership: comparing an array-valued column to a non-array
scalar tests containment: labels = 'work' matches rows
whose array holds 'work'; != is not-contains. Array-to-array
comparison remains whole-value equality.
See also IN · Multikey components
CASE
The conditional expression, in both standard forms:
CASE WHEN <condition> THEN <result> [WHEN <condition> THEN <result> ...] [ELSE <result>] END
CASE <operand> WHEN <value> THEN <result> [WHEN <value> THEN <result> ...] [ELSE <result>] END
The searched form returns the <result> of the first <condition> that
is true; the simple form compares <operand> = <value> arm by arm (so a
NULL operand matches no arm). With no arm taken the value is the ELSE
result, or NULL without one. Arms are tried in order and only the
taken result is evaluated; a condition that is NULL or not a boolean is
simply not taken. Usable wherever an expression is — select items,
WHERE, GROUP BY / ORDER BY, inside aggregates
(sum(CASE WHEN ok THEN 1 ELSE 0 END)), UPDATE … SET, CHECK,
generated columns and expression indexes; an unaliased CASE column is
labelled case. At most 512 WHEN arms. CASE is contextual: case
remains a valid column name, and reads as the keyword only where a
WHEN follows it in the same expression (write (case) for the column
directly before a WHEN). if(<condition>, <then> [, <else>]) is the
two-way shorthand.
See also Scalar functions
IN and NOT IN
Set membership: x IN (a, b, c) is true when x
equals any listed element (three-valued: unknown if x is NULL, or if
no element matches but some element is NULL); NOT IN negates it. The
list needs at least one element (IN () is a parse error). A list element
that is an array is flattened — each of its elements becomes a
candidate — so a bound array parameter works directly: WHERE id IN (?)
with ? = [1, 2, 3] tests membership in that set (the "fetch these N
ids" pattern). When the left side is an array column, IN matches if the
array holds any listed value, mirroring the = containment rule (Array membership).
Performance: when every primary-key column is pinned by
=or a literalINlist (bound array parameters included), the query resolves as a point-read set — one bloom-gated point read per candidate key (cross product on composite keys, any size up to 1,000,000 keys; the keys are not counted againstscan_row_budget, while the byte budget and the statement deadline still bound the result; only the predicates the key set does not cover — other columns,NOT IN, a second pin on a key column — are re-checked per row), routed to each key's replica set on a cluster in batches of 128 keys per round trip;EXPLAINshowspoint-read set(and the batching on a cluster). A result streamed in keyset pages (ORDER BY <pk> LIMIT n AFTER (…), which is how an unbounded row-returning statement is streamed) reads each page as the nextnlisted keys past the cursor, by the same point reads — the rows between two listed keys are never walked. A key that finds no row is read again under its=-twins — the same number in the other numeric types (2,2.0, the decimal2, epoch-ms2as a timestamp) and the timestamp an ISO-8601 string names — soWHERE id = 2finds a row stored as2.0, and an epoch-ms integer finds a timestamp key, as a scan does;UPDATEandDELETEreach that row too. The twins are read only on a miss, and only in the key types the table holds (a table keyed by integers alone reads none) — on a cluster that check uses the coordinator's own copy, so it applies atONE; aboveONEevery twin is read on a miss, since a lagging member cannot rule out a key type other replicas already hold.2and2.0are distinct keys, and a table holding both answers the spelling the statement used. A spelling is a twin only when=holds exactly — an integer beyond 2^53 has no float twin (its nearest double is another number), and0reaches a key stored as-0.0. Past the cap the statement plans a scan, andEXPLAINsays so. With a search predicate (MATCH()family) in the same WHERE, the pinned keys instead restrict the search itself (up to 4096 keys): the search index returns only the listed rows' documents, scored exactly as without the list — including keys stored as a type the row-side=coerces to (id = 1.0finds Int id 1; an ISO string finds a Timestamp key; a Timestamp literal or |number| ≥ 2^53 leaves the search unrestricted);EXPLAINshowssearch_key_set. OtherINshapes (non-PK columns,NOT IN, non-literal elements) evaluate as a residual row filter over the scanned range and can tripscan budget exceededon large unindexed scans — pair those with a narrowing indexed predicate.
See also Subqueries · Bind parameters · Streamed queries
BETWEEN
x BETWEEN lo AND hi is the inclusive range x >= lo AND x <= hi
(three-valued: a NULL operand or bound makes the undecided side unknown);
NOT BETWEEN negates it. A col BETWEEN lo AND hi with literal bounds
contributes both bounds to index/PK range pushdown, exactly like writing
the two comparisons.
Performance: a range on the leftmost primary-key column is a bounded key slice, not a scan — the table is stored in primary-key order, so
WHERE id >= ? AND id < ?seeks. A time-sortable PK (ULID/snowflake) therefore serves time ranges, including range aggregations (GROUP BY,count). An equality-pinned PK prefix plus a range on the next column slices the same way.EXPLAINshowsprimary-key range slice. The one shape that does NOT take the slice: anORDER BYa secondary index can walk in order, under aLIMIT— the index-ordered walk stops afterLIMITmatches, where the slice would read the whole prefix for the executor to sort (WHERE account = ? ORDER BY internal_date DESC LIMIT 1with an index on(account, internal_date)reads one row, not the mailbox).
LIKE and ILIKE
SQL pattern match on strings: % matches any run of
characters (including none), _ matches exactly one; every other character
matches itself, and there is no escape sequence (a literal %/_ in the
data cannot be targeted — use equality or a search index). ILIKE is
case-insensitive (Unicode-aware lowercasing). The pattern is any expression
(usually a literal or a ? parameter). Non-string operands — including
NULL — compare as unknown, so a mixed-type column never errors the query.
This is exact substring/prefix matching, complementing the analyzed
full-text MATCH() (which tokenizes words): use LIKE '%needle%' for
verbatim fragments, MATCH for word search.
Performance note:
LIKE/ILIKEevaluate as a residual row filter (no index acceleration, including'prefix%') — the same scan-budget caveat asINapplies on large unindexed scans.
See also Full-text search
Subqueries
<expr> [NOT] IN (SELECT …) (membership in the
subquery's first column), [NOT] EXISTS (SELECT …), a scalar
(SELECT …) (one column, at most one row — NULL when none; more
rows or columns is an error), and the quantified comparison <expr>
<op> ANY (SELECT …) / SOME (…) / ALL (…) with op one of =,
!= / <>, <, <=, >, >= — anywhere an expression goes: a
SELECT's items / WHERE / HAVING / ORDER BY / GROUP BY / join
ON, an UPDATE's SET and WHERE, a DELETE's WHERE, an
INSERT's VALUES; they nest, and may name a view.
Each runs before the statement, at the session consistency and under the
statement's scan row/byte budgets and timeout, and is folded into it — IN
becomes an IN (…) list (NULLs dropped, so NOT IN behaves like a plain
list rather than Postgres' three-valued trap), EXISTS a boolean (the inner
runs with LIMIT 1), a scalar its value, ANY / ALL a comparison over
the column's values — so the statement that then runs is an ordinary one:
single-table WHERE pushdown, index scans, keyset paging and every cluster
path see a plain predicate.
An IN list is checked per candidate row by a linear scan, as any IN (…);
a large membership set is what a JOIN is for. Not allowed in a CHECK /
DEFAULT / generated expression. RBAC: SELECT on every table a statement
reads — its FROM, join sides, set-operation legs, subqueries (correlated
or not) and CTE bodies — is checked on the user's own grants.
ANY and ALL
SQL three-valued logic over the
subquery's first column, NULLs included. x op ANY (…) is true when
some comparison is true, false when every comparison is false (and
over no rows), otherwise NULL; x op ALL (…) is false when some
comparison is false, true when every comparison is true (and over no
rows), otherwise NULL. So x <> ALL (SELECT c …) is never true when
c holds a NULL, and a NULL x is NULL against any non-empty set.
= ANY is IN except for that NULL handling. With a parenthesised
non-subquery operand the quantifier ranges over that ARRAY value:
'b' = ANY (tags), x > ALL ([1, 2]) (a NULL operand is NULL).
Correlated subqueries
A subquery may read the enclosing row
through the outer query's alias or bare table name —
SELECT name FROM cust c WHERE EXISTS (SELECT 1 FROM orders o WHERE
o.cust_id = c.id), SELECT name, (SELECT max(total) FROM orders o
WHERE o.cust_id = c.id) FROM cust c, UPDATE cust SET spent =
(SELECT sum(total) FROM orders o WHERE o.cust_id = cust.id). A
correlated reference is a column qualified by an alias of the
enclosing query that the subquery does not declare itself (an alias
inside the subquery shadows it; an explicit alias hides its table's
name, so FROM orders o2 leaves an outer orders visible). An
UNqualified name is always the subquery's own — the store is
schema-less, so an inner name can never be proven to belong to the
outer row. Inside a subquery, its own single-table alias qualifies
its columns as expected (o.total). An INSERT has no outer row.
Evaluation, in two shapes:
- Equality shape — every correlated reference sits in a top-level
ANDconjunct<inner expr> = <outer column>of the subquery'sWHERE, and the subquery is plain (one item, noGROUP BY/DISTINCT/ORDER BY/LIMIT/ set operation). It is decorrelated into ONE inner pass whatever the outer size:EXISTSreads the distinct keys, an aggregate item runs grouped by the keys (count(*)is 0 for a key with no rows), a plain item is collected per key. NULL keys never match, and numbers that compare equal match across integer / float / decimal. - General shape (anything else — a non-equality correlation, an
ORDER BY … LIMITinside, correlation in the item): the inner query runs once per DISTINCT combination of the outer values it reads (the outer rows gathered by the outerFROMscope and its uncorrelatedWHEREconjuncts), with the outer values substituted, cached per statement; at most 10,000 combinations per statement, beyond which the statement fails and suggests the equality form or aJOIN. - Primary-key probes — the equality shape where the correlation
equalities (plus, for
x [NOT] IN (SELECT t.k …), the item against the tested value) pin the inner table's WHOLE primary key, the inner table being a stored one (not a view, CTE or time-series table):NOT EXISTS (SELECT 1 FROM message_tags t WHERE t.account = m.account AND t.id = m.id [AND <t-only predicates>]). Each outer row asks about at most one inner row, so the inner table is read as batches of primary-keyINreads of the keys the outer rows present — never a pass over it. A scalar subquery with a plain item ((SELECT t.tags FROM t WHERE <whole key>)) probes the same way.
When the
outer query is one table ordered and cut by LIMIT (no grouping,
aggregates, DISTINCT, windows or search) and the subquery is its
only correlated top-level WHERE conjunct, the outer query's own
walk drives the probes until OFFSET + LIMIT rows pass. Outer filters
such as (m.labels != 'SPAM' OR m.labels IS NULL) remain candidate
filters just like their unqualified forms: they run before the walk
chooses its passing rows. Qualification in that same query scope is
not a correlation; a nested SELECT may shadow an outer alias.
Candidate filters use the normal table/document qualifier rules, so
a stored document path named like the alias stays a document path. An
ORDER BY key naming a qualified column such as o.d takes the
every-key run below instead.
The final statement point-reads exactly
the passing rows (at every
consistency: a statement whose WHERE pins every primary-key column
to literals — up to 1,000,000 keys — is point reads whatever its ORDER
BY; past 1,000,000 passing rows, or when a passing row's order value is
NULL — which an index walk and the sort of point-read rows place at
opposite ends — the final statement walks its own plan under the
probe answers, bounded to the order values the probe walk reached
(NULL-ordered rows kept when one passed), so it re-reads the walked
range but never beyond it) — NOT EXISTS
(…) ORDER BY internal_date DESC LIMIT 10 reads a prefix of the
mailbox's index walk whatever the tag table holds; the sparser the
passing rows, the longer that prefix.
When the outer table has a primary key and the
first ORDER BY key is a plain column, the walk is a keyset walk:
pages of ORDER BY … LIMIT n rows reading only the correlation
keys, the outer primary key and that column (n from
4 × (OFFSET + LIMIT), at least 64, doubling to 2,048), each page
continuing from the previous page's last value (col <= v
descending, col >= v ascending — a tie run crossing a page edge is
re-read and its seen rows skipped; a page that finds nothing new
grows fourfold, up to 10,000, and the next page after the bound
moves on is back to at most 2,048); every page is one bounded ordered
read, so the walk's depth costs pages, never a larger gather, and
the byte budget counts what the walk keeps — the passing rows' keys
and probe answers, the primary keys seen at the current bound's
value, and a 16-byte hash of every walked row's primary key (a row
whose order value moves under the walk is met again: it passes, and
counts as walked, once) — not the rows it walked past: the cost of a deep walk (a
sparse NOT EXISTS, say 1 passing row in 40) grows linearly with its
depth, about two rows examined per walked row, and its memory with
OFFSET + LIMIT.
At QUORUM each page is one ordered read that a full-copy table answers from the coordinator's window once a read quorum agrees on it (the agreed window, see CLUSTER_CONSISTENCY.md), so the walk examines what it examines at ONE; above ONE the next page is read while this page's keys are probed, unless this page's pass rate already finishes the walk, so a page costs about what the slower of the two costs (a page read ahead that the walk then does not need still counts toward the statement's rows examined).
A walk that finds fewer than OFFSET + LIMIT passing rows runs dry:
it has then read every candidate row's keys and probed each one
(about two rows examined per outer row), so on a large outer match set it can reach the row budget
or the statement timeout. A walk that runs dry under a page bound
reads one candidate row at the far end of the order (LIMIT 1, at
the statement's consistency — so rows only a lagging coordinator's
peers hold count; walking descending, NULL order values first):
an order value of another type there probes every candidate key, a
NULL one reads exactly the rows the bound cannot compare and
probes them — the walk's answer stands when none of them passes.
Order values of more than one type (an Int and a
Float, a number and a string), a passing row with a NULL order
value under a bound, or a walk that reaches 10,000-row pages
without progress (a run of NULL order values longer than that)
probe every candidate key once and the final statement's own plan
decides where those rows sort.
Without a primary key or with
another first ORDER BY key the pages restart from the top, n
growing fourfold up to 10,000, and past that the rest of the outer
rows are read in one ordered pass, only their not-yet-probed keys
probed: the cost is then the outer match set, not a prefix.
Otherwise
(a count, the subquery in the select list or under OR) one pass
collects the outer rows' distinct keys and every one is probed —
unless the inner table's fast row count is no bigger than that key
set (every probed key counts as a row examined, found or not), in
which case the one-pass equality shape reads it instead.
Keys match
the way = does, not by encoding: each key is point-read in its own
encoding first, and only a key that found nothing is read again in
every encoding = equates with it (an integer, integral float or
decimal and an epoch-ms timestamp; the timestamp an ISO-8601 string
names), so a hit costs one read and a miss on a numeric key up to
five; the one gap is an outer timestamp against a stored ISO-8601
string key, which is not found. NULL correlation values match
nothing; NULL [NOT] IN a non-empty set is NULL.
EXPLAIN names the
strategy (subquery: anti-join via primary-key probes of message_tags
(account, id) …, semi-join, anti-join (NOT IN), scalar
lookup) and which run applies: the keyset walk (keyset pages of 64
to 2048 rows, each page cut to the rows the walk still expects to
need once some pass), the restarting walk (with its 10,000-row page cap) or
every key probed (naming the one-pass alternative the row counts
decide at run time).
A fallback names the blocking condition: no
LIMIT, a non-plain outer query, multiple correlated conjuncts,
placement under OR or in the select list, scalar/quantified shape,
or an unsupported projection/order. Correlations without a supported
whole-primary-key probe shape report the equality-pass or per-tuple
strategy instead. These decisions use the execution recognizers.
A LEFT JOIN t ON <t's whole primary key> = <FROM-table columns> [AND
<t-only predicates>] WHERE … AND t.<key column> IS NULL, with t read
nowhere else in the statement, is the anti-join: it is rewritten to
NOT EXISTS (SELECT 1 FROM t WHERE <the ON>) over the FROM table
alone (its m. qualifiers dropped, output names kept) and resolved by
primary-key probes as above.
Either way the result travels inside the statement as a per-key
lookup the outer predicate evaluates per row, so the outer query keeps
its ordinary plan and every cluster path. Every inner read runs at
the outer statement's consistency and shares its scan budgets and
timeout.
See also JOIN · WITH (common table expressions) · EXPLAIN
Aggregate functions
COUNT(*) / COUNT(<expr>) /
COUNT(DISTINCT <expr>) (exact distinct non-null values),
APPROX_COUNT_DISTINCT(<expr>) (opt-in approximate distinct: the
search-index pushdown may answer from an HLL sketch — never truncates,
~±1–2% at high cardinality; every other path answers exactly), SUM,
AVG,
MIN, MAX, PERCENTILE(<expr>, <p>) (the linear-interpolated
percentile_cont quantile of the group's numeric values; <p> is a
literal fraction in (0, 1], e.g. PERCENTILE(latency_ms, 0.95) —
computed exactly over the gathered rows),
APPROX_PERCENTILE(<expr>, <p>) (the same quantile, but the caller opts into approximation: over
a search predicate it pushes into the index as a t-digest sketch —
global, not grouped — and merges across shards; every other path
computes it exactly), and the time-series aggregates
RATE, INCREASE, DELTA,
FIRST, LAST (see Querying time series). Using an aggregate (or
GROUP BY) puts the query in aggregate mode.
See also GROUP BY, HAVING and TOP · Window functions
Window functions
ROW_NUMBER(), RANK(), DENSE_RANK(),
LAG(<expr> [, <offset> [, <default>]]), and
LEAD(<expr> [, <offset> [, <default>]]), each followed by an
OVER ( [PARTITION BY <expr> [, ...]] [ORDER BY <key> [ASC|DESC] [, ...]] )
clause. Rows split into partitions by the PARTITION BY values (one
partition when absent), order within each partition by the window's
ORDER BY (ties — and the whole order when absent — break by input
position), and the function assigns per row along that order:
ROW_NUMBER counts 1, 2, 3, …; RANK gives equal order-keys equal rank
with gaps after ties; DENSE_RANK the same without gaps; LAG/LEAD
evaluate <expr> against the row <offset> positions earlier/later in
the partition (<offset> defaults to 1, must be a non-negative
integer) and yield <default> (else NULL) when that row falls outside
the partition. Window functions are valid only in the SELECT list
(alias them and ORDER BY the alias to sort by a window value); using
one in WHERE/GROUP BY/HAVING is an error, and they cannot combine
with GROUP BY/aggregate mode. Values are computed over the full
filtered row set before the outer ORDER BY/LIMIT applies — a
LIMIT never truncates what a window sees:
SELECT id, rank() OVER (PARTITION BY region ORDER BY amount DESC) AS rk FROM sales ORDER BY rk LIMIT 10.
See also Aggregate functions · DISTINCT and DISTINCT ON
Scalar functions
distance('<n><unit>') (a distance literal in
metres — '500m', '5km', '1mi', '3NM'; the natural radius for
geo_distance comparisons, and constant, so the geo index still prunes:
WHERE geo_distance(loc, 40.7, -74.0) <= distance('5km')),
now() (the query's start time, as a timestamp —
one instant per statement), nextval('<seq>') / setval('<seq>', <n>
[, <is_called>]) (sequence counters — see CREATE SEQUENCE for where they may appear),
coalesce(<a>, <b>, ...) (the first
non-NULL argument, NULL if all are), the string functions lower(s),
upper(s), trim(s), length(s) (characters; elements for an array),
substr(s, start [, len]) (1-based), concat(a, b, ...) (NULLs
skipped, numbers rendered), starts_with(s, p) / ends_with(s, p),
the geo helpers geo_lat(p) / geo_lon(p) (a point's coordinates)
and geohash(p [, precision]) (the base-32 geohash cell, 1–12
characters, default 5 — a grid key to GROUP BY),
and the number functions abs(n), floor(x), ceil(x), round(x [, digits])
(one argument rounds to a whole number and yields an INT; two keep the
fraction and yield a FLOAT — round(avg(v), 2) — with negative digits
rounding to tens and hundreds; a non-string / non-number argument yields
NULL) —
all deterministic, so usable in CHECK, generated columns and
expression indexes,
if(<condition>, <then> [, <else>]) (conditional value; <else>
defaults to NULL, and a NULL/non-boolean condition takes the else
branch rather than erroring) and nullif(<a>, <b>) (NULL when the two
are equal, else <a>) — the pair that makes conditional aggregates a
single pass, since COUNT ignores NULLs:
SELECT count(if(status >= 400, 1, NULL)), count(*) FROM reqs gives an
error count and a total in ONE scan instead of two,
time_bucket(<step>, <ts>) (floors <ts>
to a <step>-wide bucket: time_bucket(5m, ts)), and
to_timestamp(<value>) — coerce to a timestamp: numeric epoch-ms passes
through and an ISO-8601 string is parsed (YYYY-MM-DD,
YYYY-MM-DD[T ]HH:MM[:SS[.fff]], optional Z/±HH[:MM] offset; no offset
= UTC). Unparseable or mistyped input yields NULL, never an error — so
string timestamps (e.g. from a document-store migration) range-filter in-query:
WHERE to_timestamp(created_at) >= now() - 30d. Comparing a TIMESTAMP
column directly with a string literal coerces the string the same way
(WHERE ts >= '2026-08-10' behaves exactly like a typed timestamp bound,
including time-series scan pruning); an unparseable string compares as
NULL.
The standard spelling CAST(<expr> AS <type>) (types: INT, FLOAT,
STRING, BOOL, TIMESTAMP) desugars to the matching coercion (to_int,
to_float, to_string — timestamps render as ISO-8601 —, to_bool,
to_timestamp), all with the same NULL-on-unconvertible policy. cast
stays usable as a column name.
A bare identifier directly followed by ( parses as a function call;
unknown functions are execution errors. The full-text search functions
MATCH, MATCH_PHRASE, FUZZY, SEARCH, and score parse the same way
and are only valid in a search query (see Full-text
search).
See also Array functions · Geospatial functions · CASE
Array functions
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 inside UPDATE/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) and array_contains(a, x)
(false for non-arrays), both usable in WHERE.
See also UPDATE · UNNEST · Array membership
Geospatial functions
geo_distance(<point>, <lat>, <lon>) returns the great-circle
(haversine) distance in metres from a point column to (<lat>, <lon>);
geo_bbox(<point>, <min_lat>, <min_lon>, <max_lat>, <max_lon>) tests whether
a point lies inside a bounding box (min_lon > max_lon crosses the
antimeridian); geo_polygon(<point>, <polygon>) tests whether it lies
inside a polygon — [[lat, lon], …] (three or more vertices, the closing
vertex optional) or the string 'lat,lon; lat,lon; …', even-odd rule,
edges count as inside. A point is a {lat, lon} object (lng also
accepted) or a [lat, lon] array; a non-point / NULL value (or an
unparsable polygon) yields NULL (so it drops from a filter) rather than
erroring. Use them anywhere:
WHERE geo_distance(loc, 40.71, -74.0) <= 5000,
ORDER BY geo_distance(loc, 40.71, -74.0) LIMIT 10 (nearest-first),
WHERE geo_bbox(loc, 40.4, -74.3, 40.9, -73.7), or
WHERE geo_polygon(loc, [[40.8, -74.0], [40.75, -73.9], [40.7, -74.0]]).
A CREATE GEO INDEX makes these predicates prune to a
neighborhood instead of scanning the whole table (a polygon prunes by its
bounding box, then the exact test runs on the candidates); without one
they still work over a (scan-budget-bounded) scan.
See also CREATE GEO INDEX · GEO.md
Bind parameters
? marks a positional parameter in any expression
position of a prepared SELECT/INSERT/UPDATE/DELETE (e.g.
INSERT INTO t (id, v) VALUES (?, ?)), and additionally in LIMIT ? /
OFFSET ? (the bound value must be a non-negative integer). Parameters
are numbered by order of appearance and bound to values at execute time
via the binary protocol's prepared-statement messages (or a driver's
prepare/execute API). EXPLAIN of a preparable statement is itself
preparable, so the exact bound query can be explained. DDL and
session-control statements cannot be prepared, and a ? submitted
through the plain one-shot query path is an error at execution.
See also EXPLAIN · PROTOCOL.md
Three-valued logic
Comparisons follow three-valued logic (NULL compares as unknown).
See also IN · ANY and ALL
Identifiers & literals (lexical)
- Bare identifiers start with a letter or
_and continue with letters, digits, or_. Keywords are case-insensitive. - Quoted identifiers use double quotes:
"select","weird name". A quoted identifier is ONE name whatever it contains:"a.b"names the objecta.bof the current database anddb."a.b"that ofdb; only an unquoted.separates a database qualifier. A quoted identifier may not contain NUL, U+0001 (names starting with it are the engine's own hidden fields) or U+001F (the internal name separator) — on every entry point, SQL, REST, the BI editor and the Elasticsearch gateway alike — and statement text may not contain U+001F at all. - Object names (tables, indexes of every kind, views, materialized
views, rollups, streams, triggers, sequences, databases, constraint
names, and
ALTER TABLE … RENAME TOtargets) become directory names on disk, so a NEW object's name may not contain/,\,%,.or a NUL character, and its encoded qualified form may not exceed 240 bytes —CREATE TABLE "a/b" …fails withinvalid name. Objects created under such names by older versions keep working (their directories are stored under an escaped name, and a cluster peer replaying their DDL is not refused). - String literals use single quotes; embed a quote by doubling it:
'O''Brien'. ;may terminate a statement. Only one statement is executed per call.
Types
Values are dynamically typed: null, bool, int64, float64, decimal,
string, bytes, uuid, timestamp (Unix time, milliseconds), array,
document. Only int, float, string, bool, null, array, and
document (object) literals can be written directly in SQL;
decimal/uuid/bytes/timestamp values arrive via stored data or the
value codec (e.g. bound parameters), not as SQL literals.
Numbers
int64, float64, decimal and timestamp values compare with each other
by value (1 = 1.0). Integers and timestamps compare exactly over the
whole 64-bit range, with each other and against a float (the mathematical
order of the two numbers; the integer is never rounded to a double) — in
comparisons, IN, BETWEEN, ORDER BY, join keys and min/max, with
or without an index. 64-bit identifiers beyond 2^53 (snowflake ids,
nanosecond timestamps) are therefore distinct values:
ref = 1234567890123456789 does not match 1234567890123456790. A
decimal compares exactly with integers, timestamps and other decimals
(1.0 = 1.00, while 1 = 1.000000000000000001 is false), and with a
float through the float's precision — the decimal's nearest float64, as
PostgreSQL compares numeric with double precision — so the float
literal 0.1 matches the decimal 0.10 a driver stored.
GROUP BY, DISTINCT, count(DISTINCT …), the duplicate removal of
UNION, primary keys and index order identify a value by its storage key
instead: by type first — 1, 1.0 and the decimal 1 are three groups,
three keys — and a decimal by its float64 value, so decimals that differ
only beyond double precision are one group and one key.
Arithmetic on two integers stays int64 and is checked: a result
outside the int64 range fails the statement —
type error: integer out of range: 9223372036854775807 + 1 does not fit
int64 — for +, -, *, unary -, abs() and -9223372036854775808 /
-1. Nothing wraps around. Integer / truncates toward zero (7 / 2 is
3, -7 / 2 is -3). Division by zero yields NULL, for integers
and floats alike (it is not an error). An operation with a float, decimal
or timestamp operand is computed in float64.
SUM over integers is exact and never wraps: the result is an int64
while the total fits and the same total as a decimal (scale 0) beyond
that; a sum that includes any non-integer value is a float64.
Indexing notes (query-relevant)
- A
WHEREpredicate is index-accelerated when it constrains an indexed column by equality or range (=,<,<=,>,>=, andAND-combined ranges /BETWEEN-style), or whenORDER BYfollows an index. Composite indexes use a leftmost prefix of equalities plus an optional trailing range, and serveORDER BYalong that prefix. Everything else falls back to a scan; results are identical either way. - The primary key is the routing key in a cluster:
WHERE pk = <v>is a point read to the key's replica set; other indexed predicates are pushed to each node's local index.
Not part of the SQL surface
- No data-modifying CTEs (
WITH x AS (INSERT …)),LATERALsubqueries (LATERAL UNNEST(…)is supported — seeUNNEST), comma joins between tables (FROM a, b— a comma introduces only anUNNEST), or row-valued comparisons ((a, b) IN (SELECT …),(a, b) = ANY (…)). Correlated subqueries,ANY/SOME/ALL,WITH RECURSIVEand aWITHclause beforeINSERT/UPDATE/DELETEare supported — see Subqueries and Recursive CTEs.DISTINCT ON,FULL [OUTER] JOINandINTERSECT/EXCEPT[ALL]are supported (set operations apply left to right). Window functions are supported in the SELECT list only (see Window functions), never inWHEREorHAVING. - No writes through a view whose body joins, groups, aggregates,
uses DISTINCT / set operations / LIMIT, or through a materialized
view; no
ALTER MATERIALIZED VIEW(drop and create it again); noINSTEAD OFtriggers or rules. - No
ALTERbeyondRENAME TABLE/RENAME COLUMN/ constraintADD/VALIDATE/DROP CONSTRAINT/ALTER COLUMN … SET|DROP NOT NULL | DEFAULT/ALTER COLUMN … ADD GENERATED … AS IDENTITY | DROP IDENTITY/ALTER COLUMN … ADD GENERATED ALWAYS AS (…) STORED | SET EXPRESSION | DROP EXPRESSION/ALTER SEQUENCE(the store is schema-less, so there is nothing toADD/DROP COLUMNand no column types to alter). - No generated column as a key / distribution / clustering /
partition column, and no
NOT NULLon aVIRTUALone. - No plain
BEGINon a cluster.BEGIN/COMMIT/ROLLBACKwork against an embeddedDatabaseor a standalone-server driver connection; the cluster coordinator autocommits each statement and rejects plainBEGIN. A cluster commits atomically instead:BEGIN ATOMICacross partitions (two-phase commit) andBEGIN ATOMIC PARTITIONwithin one partition ofdistribute_bytables — atomicity, not isolation. - Joins have no pushdown in a cluster: a single-table
WHEREis pushed to the shards (each node returns only matching keys, re-read at quorum), but a join pulls each table to the coordinator and joins there — so joins suit modest tables / lookups, not large fact-to-fact joins. - Vector search uses
NEAREST, not anORDER BY embedding <-> [...]operator (see Vector search).