Indexing guide
Everything about making skaidb queries fast: what index types exist, how to create them, how the planner picks one, and — most importantly — how to look at a slow workload and decide which index it needs.
Related: QUERY_SYNTAX.md (grammar), SEARCH.md (full-text), VECTOR.md (HNSW), LLM.md (the condensed LLM-facing reference).
Index types at a glance
| Type | Create | Answers | Notes |
|---|---|---|---|
| Primary key | CREATE TABLE t (PRIMARY KEY (a [, b ...])) |
point reads, prefix-equality slices (+ one trailing range) | the table IS the index; WHERE a = ? on PK (a, b) scans only that slice |
| Secondary | CREATE INDEX i ON t (a, b, ...) |
equality/range filters, index-served ORDER BY, index-only counts |
composite = leftmost-prefix; see planner section |
| Multikey | CREATE INDEX i ON t (a, tags[]) |
array element equality (tags = 'x' containment), exact index-only counts |
one [] component per index; entry per element |
| Global | CREATE INDEX i ON t (a) WITH (global = true) |
full-tuple equality probes routed to the value's replica set (one round-trip, no scatter) | ranges/partial prefixes fall back to scatter; backfill runs in the background after DDL; see GLOBAL_INDEXES.md |
| Vector | CREATE VECTOR INDEX v ON t (emb) DIM n [USING cosine\|l2\|dot] |
NEAREST k-NN |
HNSW; snapshotted for fast restarts; DDL acks at schema-apply, local = building while the paged backfill runs (searches say "rebuilding — retry shortly") |
| Search | CREATE SEARCH INDEX s ON t (body, title) [WITH (...)] |
MATCH(), SEARCH(), BM25 ranking, fast-field aggregations |
see SEARCH.md; fast fields answer GROUP BY/counts index-side |
Memory tables (WITH (memory = true)) reject indexes — they are ephemeral
by contract.
Managing indexes
CREATE INDEX IF NOT EXISTS i_mail_star ON mail (account, _tombstone, is_starred);
SHOW INDEXES; -- name, table, type (secondary/global/vector/search,
-- with "(building)" while a backfill runs), paths
-- (multikey keep their []), and `local`: THIS node's
-- live state (ok/building/missing)
DROP INDEX IF EXISTS i_mail_star;
REBUILD INDEX i_mail_star; -- every node re-derives ITS copy from its rows
CHECK INDEX i_mail_star; -- audit THIS node's copy against its rows
CHECK INDEX ALL REPAIR; -- ... every index of the database, and fix it
- DDL broadcasts cluster-wide and acks at schema-apply; each node then
pages its backfill in the background (brief locks, memory-flat — a
183k-row backfill takes a few minutes per node). A standalone server does
the same:
CREATE INDEXreturns at once and a worker builds the index behind it, sizing each page to about 50 ms of lock hold (32–2048 rows, adapting to the disk) and resting at least as long as the hold between pages, so a mirror on a slow or busy disk keeps answering/readyand its own pulls while it builds. Every index a table gained is built in one scan of the table, however many were created together.SHOW INDEXESshowssecondary (building)until that node completes; the planner never uses a building index, so queries fall back to their pre-index plans until then. A restart mid-backfill resumes it: the node re-queues every index still marked building at open (embedded opens finish them inline; server modes page them from the background worker) and continues from the cursor its worker last saved with the catalog — every 64 pages and at least every 5 s, after the page's entries were made durable (one fsync per index per page) — so an interrupted backfill can never strand an index as building forever, and it never rescans the rows already done. - Every statement, DDL included, lands in the query log with its real duration; slow ones also land in the slow-query log (see Spotting the missing index below).
- Crash recovery retains row history until queued index maintenance is durable. If compaction must proceed past that retention window, or an equal-timestamp overwrite replaces the old body, a durable marker is set first. It is cleared again once the queued maintenance it covered has run, so only a process that stopped before then (a crash, a kill) leaves it behind; the next start reconciles that table's secondary and geo entries against its rows. A server does that in the background, in short lock holds, and starts serving at once; until it is done, index-only counts of that table read rows instead. An embedded open reconciles before it returns. Only marked tables incur that extra index scan. Full-text and vector catch-up never holds compaction back. Online partition adoption journals each moved page durably before deleting its legacy copies and checkpoints the search and vector indexes every few pages; a restart after a crash re-indexes the journaled rows.
- The UI's Inventory tab lists every index with type, definition, approximate entries, and disk size.
ALTER TABLE ... RENAME COLUMNrewrites index definitions (including multikey markers) automatically.REBUILD INDEX <name>re-derives an index from the rows. Each node holds its own copy of a secondary index over its own rows, and on a cluster where every member holds the whole table a filteredCOUNT(*)is answered from the local copy. A copy that drifted from its rows therefore shows as counts that differ by the member answering, while the rows themselves agree.REBUILD INDEXbroadcasts like DDL; each node wipes its copy and backfills it in pages from its own rows,buildingmeanwhile (queries use their pre-index plans until it completes). It also rebuilds a vector or search index by name; a search index (asREBUILD SEARCH INDEX) is cleared and backfilled the same way on a server — paged in the maintenance worker, writes made meanwhile kept,MATCH/SEARCHon it erroring "rebuilding … retry shortly" until done (a crash mid-rebuild reopens the last committed index). A GLOBAL index is refused: its entries are cluster-wide rows kept by anti-entropy.CHECK INDEX <name> | ALL [ON <table>] [REPAIR]finds such drift without rebuilding anything: on the node it is sent to (never scattered), it matches every entry the rows produce against the index through a hash ledger, and only where the two differ probes the rows' entries (an absent one is missing) and reads back the rows of the index's entries (an entry no stored row produces is stale; two current entries with one value on a UNIQUE index are a duplicate). On a UNIQUE index it also audits the reservation table: a live value without its reservation counts as missing, a reservation whose owner no longer holds the value (past the 10 s claim grace) as stale — the samples say "missing reservation" / "stale reservation",REPAIRfixes both, resume tokens gain a phaseu, and on a cluster member the reservation audit runs only where the node holds the whole companion table (askipped:status row says when it did not). A UNIQUE index over an array element created by an older release is listed asdemoted: …(it enforces nothing and is opened as a plain index; recreate it). A GLOBAL index no member holds whole is audited per member: one row per member over the rows and entries it owns, statusesskipped: member unreachable|busy|predates the auditandpartialwhen owner batches went unanswered,REPAIRhealing per member. It reports counts plus a few sample primary keys — a clean index costs one sequential walk of the table and two of the index, no random read. It pages under short engine-lock holds (about 8 ms each, with a pause between pages for writers), so it is safe on a busy member. Its memory is one page, the ledgers (at most 12 MB for a table) and an 8 MB cache of its own: it does not fill the node's block or row caches, whatever the table's size, and on a node under memory pressure it stops at the next page aspartialwith aRESUMEtoken. A row whose maintenance is still queued is countedin_flight, not drift. The statement timeout ends it aspartialwith aRESUMEtoken.REPAIRfixes exactly what it found — each finding re-checked under a short write hold — on that node only, and the index keeps serving throughout, whereREBUILD INDEXtakes every member's copy out of service for its backfill. It covers every kind of index a node keeps: geo indexes like secondary ones; search indexes (a document per row with an indexable value, read in key order from the index's term dictionary alongside the rows;REPAIRre-indexes a missing document from the row and removes a stale one, committing after each short hold); vector indexes (a node per vector, walked in node-id order), whose drift it reports forREBUILD INDEXto fix; and GLOBAL indexes on a node that holds every row and entry of the table (repaired in that node's copy of the entry table, which anti-entropy spreads).ALLwalks a table once for all of its indexes. QUERY_SYNTAX.md has the columns; CLUSTER_REPAIR.md has the audit runbook.- Event rollups are indexable tables.
CREATE ROLLUP r ON t BUCKET 1h [BY (dims)] AGGREGATE (...)(QUERY_SYNTAX.md) materializes a bucket table withPRIMARY KEY (bucket, dims…);CREATE INDEX i ON r (total)on a dimension or an aggregate alias is an ordinary secondary index with every capability in this guide — range filters,ORDER BY total [DESC] LIMIT kas an index-ordered walk, index-only range counts (a rollup withRETENTIONcounts by scan, like any table with a ttl),EXPLAINnaming it. The rollup's refresh writes through the ordinary write path, so each pass keeps the index exact: an upserted bucket replaces its entry and a bucket the recompute no longer produces (its source rows were deleted) is removed with its entry, on every replica. The index replays with the rollup on schema sync, mirrors to witnesses underwitness.mirror_indexes, lists inSHOW INDEXES/SHOW CREATE TABLE r, and drops with the rollup (or with the source, through the cascade). Thebucketcolumn needs no index: it leads the primary key, soWHERE bucket >= … AND bucket < …is a primary-key slice already.
Global (value-sharded) indexes
CREATE INDEX i_msgs_sender ON msgs (sender) WITH (global = true);
SELECT id, body FROM msgs WHERE sender = 'ada'; -- routed probe
SELECT id FROM msgs WHERE sender IN ('ada', 'bob'); -- one probe per value
EXPLAIN SELECT id FROM msgs WHERE sender = 'ada';
-- access: global-index probe via 'i_msgs_sender' (routed to the value's replica set)
-- cluster.fan_out: global-index probe routed to the value's replica set
What it is. A regular secondary index is local: each node indexes only
its own shard, so a non-PK equality probe must scatter to every member
to gather candidates. A global index instead stores its entries in an
internal replicated table placed on the ring by indexed value — all
entries for sender = 'ada' live on one replica set. An equality probe
routes there directly: one replica-set round-trip, like a PK point read,
regardless of cluster size.
When to use it. Only when RF < members (a genuinely sharded cluster) and the hot shape is full equality on the indexed columns — the "fetch everything for this value" class (dedup probes, per-user/per-account lookups). On a full-copy cluster (RF ≥ members) every node already holds all data and a local index answers without any fan-out — global buys nothing there. Measured: at 2 members the two are at parity (the candidate re-read dominates); the routing win grows with member count since scatter cost scales with members and the probe does not.
What routes, what doesn't.
- Routes: the full value tuple pinned by = or a literal IN list
(composite (a, b) needs both pinned; IN lists cross-multiply into one
probe range per tuple, up to 100). A multikey component (tags[]) routes
on element equality like the local form.
- Falls back to the scatter paths (still correct, just wider): value
ranges (sender > 'a'), partial prefixes of a composite, IN lists past
the cap, values hotter than 10 000 candidate rows, an entry replica set
below read quorum, or the index still building.
- Candidates are always quorum re-read and re-checked against the full
WHERE, exactly like local-index candidates — a stale or orphaned entry
can surface a candidate but never a wrong row.
Lifecycle. DDL acks at schema-apply; the coordinating node then
backfills pre-existing rows through the replicated write path in the
background and broadcasts readiness. Until then SHOW INDEXES reports
global (building) and probes fall back to scatter. After that, every
INSERT/UPDATE/DELETE maintains entries as part of the statement (one extra
replicated write per changed indexed value, at the statement's write
consistency, plus one point read of the row's old version on write). The
anti-entropy pass verifies entries against rows both ways — a crash between
a row write and its entry write self-heals within a repair interval.
Costs to keep in mind. Writes to the table pay the companion entry
write + old-row read; multi-row INSERTs take a per-row path. Entries are
rows in a hidden replicated table (__gidx__<name>), so they consume
storage on their owners and participate in repair like any table. Local
indexes remain the default and the better choice for everything except the
sharded equality-probe shape.
Design details and internals: GLOBAL_INDEXES.md.
How the planner chooses (what your index must look like)
- Leftmost prefix. A composite index
(a, b, c)serves filters that pina(thenb, thenc) by equality, plus one trailing range on the first unpinned column.WHERE b = 1alone does not use(a, b). - Selectivity ranking. Among usable indexes, the one consuming the most equality columns (then a range) wins — a two-equality probe beats a sibling index that pins one column and spans half the table.
- Global indexes are consulted last, on the cluster path only. The
coordinator tries PK point reads, then local-index plans, then a global
probe (full-tuple equality/
INonly), then the scatter fallbacks. A local index on the same columns therefore wins over a global one — don't declare both on one shape. - A fully pinned primary key is a point-read set. Every PK column
pinned by
=or a literalINlist (bound array parameters included) resolves to exact candidate keys — one bloom-gated point read each (cross product on composite keys, ≤ 1000), never a scan;EXPLAINshowspoint-read set. On a cluster the keys route to their replica sets, andORDER BY <indexed> LIMIT kat QUORUM takes a distributed sorted top-k (see CLUSTER_OPERATIONS.md) instead of gathering the match set. - A bare
min(col)/max(col)reads the index edge.SELECT max(ts) FROM events(one item, no GROUP BY, joins, ORDER BY or LIMIT) is planned asSELECT ts … ORDER BY ts DESC NULLS LAST LIMIT 1whenever that ORDER BY would walk a secondary index: one entry read instead of a whole-table gather. NULLs are not indexed and sort last either way, so the aggregate's semantics hold; a filter the index cannot serve takes the ordinary aggregate path. - ORDER BY + LIMIT prefers a sorted walk. An index whose next-unpinned
column matches the
ORDER BYcolumn serves the sort directly and stops at the limit ((account, date)forWHERE account = ? ORDER BY date DESC LIMIT 50). Both directions read about as many index entries as the limit needs — aDESCwalk pages backward from the range's end — however many entries the range holds. Multi-keyORDER BYworks when the leading sort key is the indexed one (tie groups re-sort exactly). But: when a strictly more selective equality index also covers the filter, the planner first probes its range (a capped, O(cap) peek) — if the filter matches almost nothing, it answers through the equality index instead of walking the whole sorted range finding nothing. - Index-only counts. A filtered
COUNT(*)whose conjunctive equality/range filter is fully consumed by one index is answered from index-range cardinality — no row reads, safe at any table size. One NULL-safe negated equality (col != v OR col IS NULL) beside an index-only conjunction (covering, or the intersection below) counts by complement (two index-only counts); a BAREcol != vcannot — SQL!=excludes rows wherecolis absent, and no index sees those. Bound strictness is exact here: a>/<counts strictly, excluding the boundary value, soCOUNTalways agrees with theSELECTover the same predicate. A numeric range (internal_date >= ? AND internal_date < ?) is counted index-only too, with one guard: the store is schema-less, soInt(1)andFloat(1.0)are distinct index keys while filter evaluation compares them numerically. The planner first checks which numeric encoding the index holds under the equality prefix; exactly one → the bounds are built in that encoding (integral and inclusive forInt/Timestamp, a strict bound stepping one unit in; inclusive-only forFloat/Decimal) and the entry count is exact; mixed encodings → the count narrows through the union of per-encoding ranges and re-reads each candidate row, exact across types and still pruned. Numeric equalities stay on that re-read path. Several indexes together cover a conjunction no single one does: when every constrained column is consumed by some index — each taking its equality prefix plus at most one trailing range — the count is the size of the intersection of their row-key sets, read from the indexes alone (account = ? AND labels = ? AND internal_date >= ? AND internal_date < ?is the label index's keys ∩ the date index's keys). Ranges are sized first and the intersection declines past 1,000,000 keys per range, where the streamed count answers as before. Without it that shape counted through the paged quorum merge over its candidates' key span — with random row ids, the whole table's rows — and a mail dashboard's per-label count tripped the byte budget. A single index's range is counted in pages: passes of about a millisecond, each under its own hold of the engine read lock, so writes land between them instead of queueing behind the whole range (a member serving a coordinator's or a peer's count, and a standalone server serving a plainSELECT count(*) … WHERE …). The answer is the count one hold would give at the last pass — every entry written, removed or moved meanwhile counted as it then stands, once or not at all. Intersection counts are read in one hold. - Multiple NULL-safe exclusions use bounded row-key sets. For example,
account = ? AND (labels != 'SPAM' OR labels IS NULL) AND (labels != 'TRASH' OR labels IS NULL)counts the account's primary keys minus the union of the two equality-index ranges. Primary-key stamps include missing/NULL labels and empty arrays; the union deduplicates overlapping labels and repeated array elements. Additional indexed positive equalities intersect their key streams. Every secondary index component must be equality-pinned, so all inputs share primary-key order; among such indexes the one pinning the most components (the narrowest range, e.g.(account, labels[])over(labels[])) is read. Execution holds at most 256 keys per input page, with at most 32 predicates; it does not retain the account's candidate IDs or evaluate row bodies. Current SSTables supply primary stamps from value-free sidecars; legacy files without sidecars may decompress body blocks to obtain stamps. Server counts release the engine read lock between passes. Range-scoped mutation watches restart a changed input, while writes to other accounts do not restart it. After two restarts the query uses the ordinary row path. The planner also falls back for queued index maintenance, known stale entries, TTL, transactions, historical reads, unpinned/trailing ranges, numeric or timestamp-coercible literals, and unsupported scalar/array type coverage. Bounded type-prefix probes reject a plain index holding arrays and incompatible scalar types whose!=result is SQL UNKNOWN; mixed-type multikey arrays conservatively use the row path as well.EXPLAINnamesINTERSECTION,UNION,DIFFERENCEand fallback reasons.EXPLAIN ANALYZEreports local primary/index keys examined, peak buffered keys, restarts, and row fallbacks separately from rows evaluated; these counters do not include remote peers.ONEuses the local set.QUORUMandALLrequire fresh matching count and eligible-key fingerprints from the required replicas, otherwise the normal row merge answers. - Bounded-but-not-covering counts prune on a cluster too. The distributed executor enforces the bounded range the local planner chose:
- A candidate set larger than 256 keys (the point-read cap) takes the
coordinator's paged merge with the candidate set applied as pages
arrive, so the scan meter charges only the bounded range —
SELECT,GROUP BYandCOUNT(*)alike stay withinscan_row_budgeton a range that is a small fraction of the table. -
COUNT(*)counts through the candidate slice, retaining nothing per row (quorum freshness is unchanged: the bound narrows the merge rather than answering from one replica). -
A candidate set bounds the WALK, not just the output. Past 256 candidates, the coordinator merge is bounded by the candidate set's own key span: replicas page only that slice — never every key of the table — and the predicate is re-applied on the coordinator, so the result is exact.
This holds for every index-bounded shape, LIMIT and GROUP BY
included. A grouped aggregation additionally streams through the
candidate slice rather than materializing it: group state is O(groups),
so a window spanning a million rows costs a handful of counters on the
coordinator, not a million row payloads.
EXPLAIN reports this directly. The access row is the local
plan, while cluster.access states what the coordinator executes and
whether the bound is enforced distributed. If you are diagnosing a query
that EXPLAIN calls bounded but that behaves like a scan, read
cluster.access first.
- Range bounds are retyped to the encoding the index holds.
A numeric literal encodes under its own type tag, so an Int-bounded
range would otherwise match only Int-tagged entries and miss rows
stored under a sibling numeric type. The planner
probes which encodings the index actually contains for the key prefix —
exactly one → the range is built in that encoding (a single ascending
range, so ORDER BY and early stop are unaffected); two or more → the
index is skipped and the filtered scan answers, correct across types.
Keep one numeric type per column to stay on the indexed path.
- Mixed-type bounds fall back. v >= 10.5 AND v <= 20 puts the lower
bound in FLOAT tag space and the upper in INT tag space, so no single
slice expresses it; such a predicate takes the filtered scan.
- Multikey gate. A multikey index is used only when every column through
the [] component is equality-pinned (the element probe). Under that
gate, counts are exact — the entry key embeds the row key, so duplicate
elements in one array collapse. Ranges or sorts on the array column fall
back to scans by design.
- Arrays in a plain index. A plain (non-[]) component keys an array
value as one entry — the whole array — while col = 'x' matches an
array that contains 'x'. While a plain index stores arrays at a
column the query bounds (under its equality prefix), a row query or
a count served from rows (=, IN, ranges, LIKE prefixes, nested
paths) reads the scalar range as always plus the index's whole-array
entries under that prefix, each array row re-checked: the cost follows
the arrays stored (deleted ones too, until compaction purges them), not
the table, and EXPLAIN says "plus its whole-array entries". An
index-ordered walk (ORDER BY the index serves), an index-only count
(covering, intersection, index-set) and a cluster coordinator's routed
index scan cannot re-check rows and skip such an index (the filtered
scan answers). The array check reads at most 32 entry stamps per
bounded column; a run of more than 32 deleted array entries counts as
arrays until compaction purges them. Index an array-holding column as
col[] to avoid all of this. Existing indexes need no rebuild.
- Consistency matters. On a full-copy cluster, reads at consistency
"one" answer non-covering counts, DISTINCT, and sorted+limited gathers
with a single local pass; quorum reads pay a cross-replica page merge.
Interactive/UI reads that tolerate a beat of staleness should send
"one".
Spotting the missing index
The workflow that has caught every production shape so far:
- Read the slow-query log (
slow.log; every statement is also in the query log with its duration). Group by statement shape — the masked literals make identical shapes easy to aggregate. - Classify each recurring shape:
- Point read? (
WHERE pk = ?) — should be milliseconds at any size. If slow, the row itself is fat (oversized documents; move blobs out of rows) — no index will help. - Equality filter, no order? — needs a secondary index consuming every equality column:(a, b)forWHERE a = ? AND b = ?. Watch for the scan-budget error (scan budget exceeded) — it is the engine telling you a filter had no usable index. - Filter +ORDER BY x LIMIT n? — needs the equality columns followed by the sort column:(account, date); add trailing sort tiebreakers as further index columns. - FilteredCOUNT(*)? — same index as the filter, and make it consume the whole filter so the count is index-only.!=/IS NULLcomplements need the positive-equality form indexed. - Array containment (tags = 'x'wheretagsis an array)? — a multikey index( ..., tags[]). Without it the filter cannot be served by any scalar index and always scans. - Equality probe on a sharded (RF < members) cluster whose latency grows with member count? — that is the local-index scatter tax; a global index (WITH (global = true)) routes the probe to one replica set. Irrelevant on full-copy clusters. - Substring/text search? — there is noLIKE '%x%'fast path; use a search index andMATCH/SEARCH(). Counts andGROUP BYover search predicates can be answered by fast fields. - k-NN / semantic? — vector index +NEAREST. - Verify with the application's real literals at its real consistency. A hand-written probe with a different type (string where the app sends an int) cross-type-compares to NULL, matches nothing, and tells you nothing.
- After creating the index, re-check the same log. The shape should either vanish or drop to the point-read/limit-bounded cost class.
Reading the query log
Every executed statement — DML, SELECT, and DDL alike — is recorded:
[query] 33ms SELECT * FROM accounts WHERE "email" = ? LIMIT ?
[query] 376769ms CREATE INDEX IF NOT EXISTS i_star ON mail (account, _tombstone, is_starred)
[slow-query] 6615ms SELECT count(*) FROM mail WHERE "_tombstone" = false AND "labels" = ?
Config keys (see INSTALL.md): query_log_enabled, query_log_masked
(literals → ?), query_log_file / slow_query_log_file (separate sinks),
slow_query_ms threshold. Errors are recorded on the statement line, so a
timeout or budget kill is visible next to its SQL.
Costs and caveats
- Indexes are write-amplification: every row write updates every index on the table (multikey: once per array element). Index only what queries need.
- Backfills stream but still read the whole table once per node — schedule
large ones accordingly;
IF NOT EXISTSmakes retries idempotent. - An index whose leading column is wrong for your filters is worse than useless — it can win selectivity ranking with a huge range. Prefer the most selective equality columns first, then the sort column.
- Index-only counts are exact but reflect the local replica when read at
consistency
"one"— a count lagging a write by a beat. - An index entry is served only while the row still produces it. On a
cluster, index maintenance is deferred off the write path, and a newer
version of a row can be stored before an older version's maintenance
runs: two writes to one row hand their maintenance over out of write
order, and a write the member coordinates itself is indexed in place
while a replica's older task for the row is still queued (as is a
REBUILD INDEX). The applier works from the row as it is stored when the task runs: an older version's maintenance never removes an entry the stored row still produces, and new entries go in only for the stored version itself — so neither a leftover entry nor a missing one results, in any order and across an unclean stop (a member replays the writes whose maintenance was still queued at its next start) (skaidb_index_superseded_entries_kept_totalcounts the entries this kept that the index would otherwise have lost). The read path guards the rest: an entry for a version the row has moved past is skipped, counted (skaidb_index_stale_entries_total), logged, and removed by the background sweep once the sweep has re-read the row and found that it still does not produce it — a covering count declines to trust the index while such entries are queued. A long-lived index that reports many of them was written by a version without this guard;REBUILD INDEX <name>clears it in one pass.