Definitions
The catalog is reached only through the language: namespaces, tables, fields, indexes, and what a field may demand of its values.
There is no separate schema language and no migration tool. DEFINE is a
statement like SELECT is, and the catalog is reachable only through it.
DEFINE NAMESPACE prod;
USE NAMESPACE prod;
DEFINE DATABASE orders;
USE DATABASE orders;
DEFINE TABLE users (name string, email string);
On a cluster a namespace also declares how many copies of it are kept and how
many nodes may write it — REPLICATION FACTOR 3, MULTI MASTER, and their
opposites. Both are on
What replicates where; neither changes anything
on a node standing alone.
A table and its columns, in one statement
A table declares its columns. Declaring which fields there are is the act that says which ones there are not, so a table refuses a record carrying a field nobody named. Put the columns in parentheses after the name:
DEFINE TABLE people (
name string REQUIRED,
rank string DEFAULT 'viewer',
joined datetime DEFAULT time::now(),
level int ASSERT $value > 0
);
The type follows the column's name without TYPE, because nothing but a
type can stand there. Everything after it is what a field declaration always
takes, in any order: REQUIRED, DEFAULT, ASSERT, ANALYZER. Every type on
the Data types page can be written here.
This is a desugaring, not a second dialect: each column runs through the
same code a DEFINE FIELD reaches, so whatever a field does, a column does.
Including the part that only shows up later — a column is held against the rows
already in the table:
DEFINE COLLECTION readings;
CREATE readings = { level: 'high' };
DEFINE TABLE IF NOT EXISTS readings (level int);
Refused. The row already there is not an int, and a declaration that stood
over it would leave the catalog claiming something the table does not do. A
refusal at any column takes the columns before it and the table with it: the
statement is one unit, and a half-declared table is not a state it can leave
behind.
A collection is the word for a shape you have not fixed
Not every set of records has a shape worth declaring. That reading has its own word rather than a flag on the other one:
DEFINE COLLECTION notes;
CREATE notes = { body: 'ada', filed_under: 'history' };
Neither field was declared and both are accepted. Written against a table
declaring body, the same record is refused and the refusal says what to write
next:
DEFINE TABLE letters (body string);
CREATE letters = { body: 'ada', filed_under: 'history' };
Refused — UndeclaredField, reading table letters declares no field
filed_under, and record 1 carries one; declare it with DEFINE FIELD filed_under ON letters TYPE string. The statement in that message is parsed
before it is offered, so it is one you can paste rather than one you have to
adapt.
A DEFINE TABLE with no columns at all is refused, because there is nothing
for it to be strict about and the statement cannot tell which of two things its
author meant:
DEFINE TABLE notes;
Refused — TableWithoutColumns, and the refusal names both: write DEFINE COLLECTION notes for records that carry fields nobody declared, or DEFINE TABLE notes SCHEMALESS to keep the older lenient reading exactly.
SCHEMAFULL is still accepted and now says what is already true. SCHEMALESS
is the one word that buys the lenient reading back, and a table that wants it
says so where a reader can see it:
DEFINE TABLE loose (name string) SCHEMALESS;
CREATE loose = { name: 'ada', extra: 1 };
The flags stand after the column list. DEFINE TABLE t SCHEMAFULL (…) reads
as though the parentheses qualified SCHEMAFULL, so it is refused, and so is
() — writing nothing already says no columns, so empty parentheses can only
be a list somebody meant to fill in.
IF NOT EXISTS covers the whole declaration, the table and every column.
The kinds of table
A table has exactly one kind, and the kind is what its records are rather than a flag set beside them — which is what makes a table that is two of these at once something the catalog cannot hold rather than something it refuses.
| written | is |
|---|---|
DEFINE TABLE users (…) | records with declared columns — a field nobody named is refused |
DEFINE COLLECTION notes | records whose shape is not fixed — every field is accepted |
DEFINE TABLE follows EDGE | a relation table, written with RELATE — see Graphs |
DEFINE SPACE sessions | one value per key — see Spaces |
DEFINE BUCKET media | files, in chunks, under a ceiling it may declare — see Files |
DEFINE VECTOR embeddings DIMENSION 768 DISTANCE cosine | a vector store — the collection, the field of that width and its index, in one word — see Vectors |
DEFINE GEO places | a geo store — the collection, its required geometry field and its spatial index — see Geometry |
DEFINE GRAPH social is the fourth of these and is not in the table, because it
declares two things: the graph, and the collection its own nodes live in under
the graph's name. That collection's kind is the ordinary one — see
Graphs.
An edge table declares no columns and is not refused for it: nobody writes
out and in by hand, so it is not a declaration with nothing in it but one
whose fields the store supplies.
EDGE, bucket and the word a table was declared with cannot be changed later,
because all three describe what the records already are rather than what may
be written next. Strictness can: ALTER TABLE t SET SCHEMALESS and SET SCHEMAFULL both work, and only the second can be refused, by the rows already
there.
Changing a table after it exists
The fields of a table are reachable from the table, in the order somebody thinking about the table writes them:
DEFINE TABLE staff (name string);
ALTER TABLE staff ADD FIELD city TYPE string;
ALTER TABLE staff ALTER FIELD city TYPE string REQUIRED;
ALTER TABLE staff DROP FIELD city;
ADD FIELD and DROP FIELD say exactly what DEFINE FIELD … ON staff and
DROP FIELD … ON staff say — one function parses the declaration, so the two
spellings cannot drift into accepting different options.
Dropping a declaration is a loosening on a lenient table and a tightening on a
strict one. On a schemaless table the drop only widens what is admissible, so
no stored record can contradict it. On a SCHEMAFULL table the declaration is
what made the field legal, so the drop is refused while any record still carries
that field — otherwise those records would disagree with the table's own catalog,
silently, because the statement reads as a widening.
ALTER FIELD is the one that is not a second spelling. A second
DEFINE FIELD is refused because the catalog reserves the name, so redeclaring
needs a statement of its own. It replaces the declaration whole rather than
patching the parts it mentions: a statement that changed only what it named
would make leave the default alone and remove the default the same sentence.
The removal and the replacement land in one commit, so the rows answer for the
new declaration — altering a field to a type its rows do not satisfy is refused,
writing neither half. The same holds for REQUIRED: a field cannot become
required while a stored record leaves it empty, and the refusal names every
record that does, not the first one it meets.
A DEFAULT beside a REQUIRED is what makes that tractable going forward, and
the order it runs in is decided rather than incidental: the default is applied
first, and then the requirement is checked. A write that omits the field takes
the default, and so does one that sends none, which is the spelling of absence.
A write that sends null is refused — the field is present and holds nothing,
there is no absence for the default to fill, and a required field holding nothing
is exactly what REQUIRED refuses. A default does not reach the records already
stored, so the order for a populated table is still: fill the rows, then require.
Strictness moves either way:
ALTER TABLE staff SET SCHEMAFULL;
ALTER TABLE staff SET SCHEMALESS;
SET SCHEMAFULL holds the rows already in the table to the declarations, in the
same commit, and is refused when any of them carries a field nobody declared —
writing nothing at all, not even the flag. So the way to tighten a populated
table is to declare the fields its rows actually carry, and then alter it.
SET SCHEMALESS is never refused: it only widens what is admissible, so no
stored row can contradict it.
Records that expire
A table or a collection may declare that its records expire. It is opt-in: a table that never says so stores and reads exactly as before.
DEFINE TABLE message (chat string, body string) EXPIRE AFTER 7d;
DEFINE COLLECTION drafts EXPIRE;
CREATE drafts:1 = { text: 'later' } EXPIRE 1h;
CREATE message:1 = { chat: 'c1', body: 'hi' };
UPDATE message:1 SET body = 'hello';
ALTER TABLE message DROP EXPIRE;
EXPIRE AFTER dgives every record created after it the instantcommit + d.EXPIREalone lets records carry one without a default.- A write may say its own instant (
EXPIRE 1h,EXPIRE datetime '…'), orEXPIRE NONEto make the record permanent. - A plain write keeps the instant. An edit never silently makes an expiring record permanent, and never
extends it. A sliding lifetime is written out:
… EXPIRE 30m. - Declaring is never retroactive. Records already stored keep what they had, and the answer says
existing: 'unchanged'. DROP EXPIREstops new instants, and the stored ones still expire: a record promised to disappear does.
At its instant the record is gone from every read: scans, every index, joins, FETCH, gathered reads and
followers. The removal pass reclaims the space later. Every other kind of table refuses the clause by name
(ExpiryNotOnThisKind); a space and a topic have their own expiry. A store an
earlier build wrote needs ALTER STORE FINALIZE FORMAT first.
Asking whether the records still fit
Every statement above answers that question by refusing. CHECK TABLE asks it on
its own and changes nothing:
CHECK TABLE staff;
It reads the table and answers with a list of the records that disagree with what
the table declares now — one entry each, carrying the record, the field, the
rule it broke (required, undeclared, assert or type) and the same sentence
the store uses when it refuses a write for that reason. An empty list means the
table holds to its declarations. Match a repair script on rule, which is a
stable word, and show detail, which is written for a person and free to improve.
A store this engine wrote cannot fail the check: the apply path saw every write, and a refusal fails the whole commit. That is the reason to have the statement rather than to leave it out — a claim of that shape is worth something only when somebody can ask. What it is for is the store that arrived by another road: restored from a backup, replicated from a node running different rules, or repaired underneath the language. It is the honest thing to run after an upgrade, before trusting the invariant on data this release did not write.
It reads the whole table, which is the only truthful way to answer, so it is a statement an operator runs rather than something the store does on its own. Checking a table nobody declared is refused rather than answered with an empty list — an empty answer is indistinguishable from a clean table, so a typo in the name would read as a clean bill of health.
Removing a table removes its definition and its declarations, and refuses while anything still points at it:
DROP TABLE staff;
A name is unique within its parent
DEFINE TABLE users (name string, email string);
Refused — NameTaken. Redefining is not the default, because a definition that
silently replaced another would take an index with it.
Saying so out loud makes it a no-op:
DEFINE TABLE IF NOT EXISTS users (name string, email string);
DEFINE INDEX IF NOT EXISTS by_email ON users FIELDS email UNIQUE;
That is what makes a start-up script re-runnable, which is what a deployment that restarts actually needs.
Indexes
Over one field, or several, and unique or not:
DEFINE INDEX by_handle ON users FIELDS handle UNIQUE;
DEFINE INDEX by_name ON users FIELDS last, first;
A containment index keeps one entry per leaf of a document field, so a
CONTAINS with a document is a walk over the leaves it asks for rather than a
scan:
DEFINE COLLECTION orders;
DEFINE INDEX by_doc ON orders FIELDS doc CONTAINS;
EXPLAIN SELECT * FROM orders WHERE doc CONTAINS { customer: { city: 'Paris' } };
EXPLAIN says shape: 'containment'. What the walk finds is re-tested against
the whole condition, because a leaf's path forgets which array element it was in;
a document asking for nothing ({}) keeps the scan. From 0.28.0-beta, whose
on-disk format is 6.
An index changes what a read costs and not what it answers. Dropping one is therefore never a change of meaning:
DROP INDEX by_name ON users;
Rebuilding
REBUILD INDEX by_handle ON users;
For an ordinary index this is a no-op you never need. It exists for the vector index, whose recall decays as records churn — the edges into a removed node are left where they are — so its quality drifts while its answers stay plausible.
It is a statement rather than something the store does on its own reckoning because two replicas that each rebuilt when they felt like it would answer the same approximate question differently, and would differ in silence.
REBUILD INDEX by_nothing ON users;
Refused — Unknown, naming what is missing.
Fields
A column and a DEFINE FIELD are the same declaration written in two places —
the parentheses say it while the table is being made, and this statement says it
afterwards. On a collection it adds a type without closing the shape:
DEFINE COLLECTION accounts;
DEFINE FIELD email ON accounts TYPE string;
DEFINE FIELD seen_at ON accounts TYPE datetime;
DEFINE FIELD owner ON accounts TYPE record;
DEFINE FIELD tags ON accounts TYPE set;
A type name may be a reserved word, because after TYPE nothing else can
appear — the grammar knows what it is reading.
A declaration can be dropped without touching the data:
DROP FIELD seen_at ON accounts;
Required, and default
DEFINE COLLECTION ledgers;
DEFINE FIELD holder ON ledgers TYPE string REQUIRED;
CREATE ledgers:1 = { balance: dec 0.00 };
Refused — MissingRequiredField. And null is not a value for this purpose:
CREATE ledgers = { holder: NULL };
Also refused. Writing NULL into a required field is nearly always a bug
upstream, and accepting it would hide the bug rather than the value.
A field can fill itself in:
DEFINE FIELD opened ON ledgers TYPE datetime DEFAULT time::now();
The default is checked against the type the field declares, at declaration time rather than at the first write:
DEFINE FIELD tier ON ledgers TYPE int DEFAULT 'gold';
Refused — DefaultDoesNotMatch.
And a requirement is checked against the rows already there:
CREATE ledgers = { holder: 'grace' };
DEFINE FIELD nickname ON ledgers TYPE string REQUIRED;
Refused — MissingRequiredField. The declaration writes nothing rather than
standing over records that violate it.
ASSERT — what a value must satisfy
Beyond its type:
DEFINE FIELD balance ON ledgers TYPE int ASSERT $value >= 0;
CREATE ledgers = { holder: 'ada', balance: -1 };
Refused — AssertionViolation. A value it accepts lands normally, and an
absent value is not a violation, the same way an absent type is not.
A range is two comparisons and a permitted set is one membership:
DEFINE FIELD age ON ledgers TYPE int ASSERT $value > 0 AND $value < 150;
DEFINE FIELD tier ON ledgers TYPE string ASSERT $value IN ['bronze', 'gold'];
Comparing with another field of the same record
The right-hand side may be a bare name, which reads a value out of the record
being checked — the same thing that name means in a WHERE:
DEFINE FIELD ends_at ON bookings TYPE datetime ASSERT $value > starts_at;
CREATE bookings = { starts_at: d'2026-03-01T10:00:00Z', ends_at: d'2026-03-01T09:00:00Z' };
Refused — "record 1 of table 7 holds a ends_at its declaration refuses (it is compared with starts_at)". Both names came from the statement you just sent, so naming the second tells you which constraint you broke without reporting anything you did not supply.
The name may reach into a nested value (window.opens) and composes with AND,
OR and NOT like any other comparison.
Two rules worth knowing:
- the field it names does not have to be declared. The name reads the record, not the catalog, which is what keeps a lenient table lenient;
- if that field is absent or
null, the write is refused. An ordered comparison against a non-value is false, which is the answer you would get anywhere else in the language, from the same function.
What an assertion may not say
An assertion is checked where validation lives — on the store's apply path, from the record alone. That is what lets a replica reach the same verdict without re-running your session, and it is why the right-hand side may be a written value or a name in this record, and nothing else:
DEFINE FIELD seen ON ledgers TYPE datetime ASSERT $value < time::now();
AssertionNotAConstraint — a call, so the answer would depend on when the check
ran.
DEFINE FIELD cap ON ledgers TYPE int ASSERT $limit > 3;
AssertionNotAConstraint — any parameter but the value being checked.
DEFINE FIELD each ON ledgers TYPE int ASSERT $value > slots[*].opens;
AssertionNotAConstraint — a route holding [*] reaches several values and
a comparison wants one.
An assertion cannot read a second record. That would stop the verdict being a function of the record being written, so a replica would have to reproduce a read — and the refusal would report the existence of a record you never named.
INFO FOR — reading the catalog
INFO FOR STORE;
INFO FOR NAMESPACE;
INFO FOR DATABASE;
INFO FOR TABLE users;
A subject per question, never one subject with a filter argument, because each
has exactly one rule deciding what the caller may see — and a statement whose
answer mixed two permission levels could only give a partial answer or a
confusing refusal. The four above are the structural ones. GRAPH, VECTOR and
GEO ask after a declared store,
and each answers with something INFO FOR TABLE cannot: a vector store's
measured recall is a property of a measurement rather than of a declaration,
and a geo store's refinement figure says whether its index is earning its place.
USER, USERS, ACCESS, NODE, TOPIC CONSUMER, KAFKA CONSUMER and KAFKA CONSUMERS are
the rest, and the whole set is on the
Statements page.
INFO FOR INDEX by_email;
Refused — UnexpectedToken, and the message names the subjects it knows. INFO STORE without FOR is refused too: INFO alone asks nothing.
FOR is contextual, so it remains a perfectly good field name:
DEFINE COLLECTION scratch;
CREATE scratch = { for: 'reserved words are expensive' };
Reaching across a database
DEFINE DATABASE archive;
USE DATABASE archive;
DEFINE TABLE users (name string);
CREATE users = { name: 'grace' };
USE DATABASE orders;
SELECT * FROM archive.users;
A qualified name reaches it without changing what USE selected.