Graphs
A graph you declare and write your own records into, and edges as records for when an edge is a row.
There are two shapes for a graph here, and they are not versions of each other.
A declared graph — DEFINE GRAPH, with DEFINE EDGE inside it — is the
route in. It is a structure the store holds: it keeps its own records, and it
writes adjacency beside the node, so a hop is one range read rather than an index
lookup. Nothing has to exist before it, and no table has to be declared to hold
what it contains.
An edge table stores each edge as an ordinary record reached through an index. That shape is not going anywhere: it is the right one when edges are few, carry a lot of their own data, or get queried like rows.
Both ship, neither replaces the other, and the last section says how to choose.
A graph you declare
DEFINE GRAPH makes the graph an object the store holds — and gives it a place
to keep its own records:
DEFINE GRAPH social;
CREATE social:1 = { name: 'ada', team: 'core' };
CREATE social:2 = { name: 'grace', team: 'core' };
CREATE social:3 = { name: 'katherine', team: 'ops' };
SELECT * FROM social WHERE team = 'core';
DELETE social:3;
That is the whole script. No table is declared, because the word declares one:
DEFINE GRAPH social creates the graph and the collection its nodes live in,
under the graph's own name. It is the same shape DEFINE VECTOR and DEFINE GEO
already take — one word standing for statements you could write by hand, run
through the same code — and it is why embeddings, places and social are all
written into the same way.
Without that word, "the social graph" is a fact somebody holds in their head about which tables are related. Nothing enumerates it, nothing drops it, and nothing can be asked a question about it.
The nodes are ordinary records: CREATE, SELECT, UPDATE, DELETE, indexes,
grants, any fields you like. CREATE social = { … } takes an identity the store
produces. A graph you have just declared is empty and exists, which is not the
same as absent.
Bringing in a table you already have
A graph is not limited to its own collection. An existing table joins one with a clause:
DEFINE TABLE company (name string) IN social;
DEFINE TABLE person (name string) IN social;
INFO FOR GRAPH social;
INFO FOR GRAPH social answers with the graph's members — its own collection and
every table attached this way. A node kind added like that is an ordinary
table, and IN is the whole of the difference: selected from, inserted into,
indexed and granted on exactly as any other table is, so membership is a clause
rather than a second word for a table.
The two are a choice, not a sequence. Records that exist to be walked go in the
graph's own collection; a table that already holds your customers and is also a
node kind joins with IN.
A graph that was never declared refuses INFO FOR GRAPH, which is the different
question from one that is declared and holds nothing yet.
A graph is scoped to its database, so two tenants may each keep a social,
each with its own node collection, and neither shadows the other. The graph and
its collection may share a name because a name is reserved under its kind — and
that reservation is also why a table called social already standing in the
database refuses DEFINE GRAPH social rather than being quietly adopted.
INFO FOR TABLE person reports the membership under a graph key, as the id the
catalog holds rather than the name — the report says what is stored, and
resolving the name here would be a second read able to disagree with the first.
An edge kind, and the adjacency it writes
A node kind is a table — the graph's own collection, or one that says which graph it belongs to. An edge kind is not a table, so it gets a word of its own:
DEFINE EDGE knows IN social FROM social TO social;
RELATE social:1->knows->social:2;
SELECT * FROM social:1->knows->social;
SELECT * FROM social:2<-knows<-social;
Both endpoints there are the graph's own collection, which is the ordinary shape when the nodes are one kind of thing. They may equally be tables you attached:
DEFINE EDGE works_at IN social FROM person TO company;
RELATE person:1->works_at->company:1 = { since: 1943 };
Nothing selects from works_at. Its entries are held beside the node rather
than as records behind an index, in both directions, so reaching a node's
neighbours is one range read over that node's own prefix. The edges themselves
are never fetched while walking — the endpoints are in the key and the edge's
properties are in the value — which is why a reverse walk costs the same as a
forward one.
Both endpoint tables must belong to the same graph. That refusal is what bounds a walk: a kind whose far side sat outside the graph would let a traversal leave the structure it was told to stay inside and still answer, with records the graph does not contain.
An edge is identified by its endpoints, so relating the same pair twice replaces rather than doubles — the same rule the record-edge half of this page follows.
The arrows are the same in both shapes. One arrow, two
arrows, chains and
DEPTH are written out below against the
record-edge example, because that half of the page declares a fixture to walk;
every rule in them applies to a declared graph unchanged.
Removing an edge
An edge is removed by naming the pair it joins, exactly as it was written:
RELATE person:1->works_at->company:1;
DELETE person:1->works_at->company:1;
The identity is derived — that is what makes RELATE idempotent — and it is
never shown, so there is no other honest way to name an edge. The statement
derives the same identity by the same rule and removes what is under it, together
with the adjacency entries in both directions.
The refusals are the ones RELATE makes, and for the same reason: a pair the
edge does not join, and the right pair the wrong way round, are refused rather
than accepted as a delete that removes nothing. Silently succeeding would tell a
caller who wrote the endpoints backwards that their edge is gone.
An edge that is not there deletes the way a record that is not there does — without an error, because nothing about the statement was wrong.
Dropping a kind, and dropping the graph
DROP EDGE works_at;
DROP GRAPH social;
Refused at the second statement. The first one removes the edge kind and every adjacency entry it wrote; the graph then refuses to go while anything still belongs to it, and names one of them:
error: graph `social` still is joined by 2 tables, the first being `company`
— remove tables first; there is no `CASCADE`, because a statement that
removes an unbounded amount from one name is the mistake this refusal exists to
catch
DROP GRAPH takes the graph's own collection with it — that collection is
part of the structure rather than something depending on it, and a graph that
could not be dropped without first dropping a table nobody declared would never
be droppable at all.
That is the rule in general: DROP GRAPH is refused while a table you
attached, or an edge kind, belongs to the graph. Dropping anyway would leave every member pointing at a
graph the store no longer has, and that surfaces later — as a walk that finds
nothing — rather than now.
So a graph holding only its own nodes drops in one statement, and a graph you have joined other tables to asks you to detach them first.
The rule runs the other way too:
DROP TABLE social;
Refused, and the refusal names the statement to write instead:
error: `social` is graph `social`'s own node collection, not a table of its own
— write `DROP GRAPH social` to remove the graph and everything it holds
Allowing it would leave a graph that is still declared, still answers INFO FOR GRAPH, and can hold no record — with nothing in an error state anywhere, and no
statement that puts the collection back, because DEFINE GRAPH social refuses a
graph that already exists. A table you attached with IN still drops on its own:
that clause is one you wrote and may withdraw.
Edges as records
The other shape. An edge table is declared like any other table, with one word added:
DEFINE COLLECTION users;
DEFINE TABLE follows EDGE;
CREATE users:1 = { handle: 'ada' };
CREATE users:2 = { handle: 'grace' };
CREATE users:3 = { handle: 'katherine' };
Edges are written with RELATE:
RELATE users:1->follows->users:2;
RELATE users:1->follows->users:3;
An edge is a record, so it may carry properties of its own:
RELATE users:2->follows->users:3 = { since: datetime '2026-01-01T00:00:00Z' };
RELATE is idempotent
An edge is identified by its endpoints, so writing the same one twice leaves one edge:
RELATE users:1->follows->users:2;
SELECT * FROM users:1->follows;
Two rows, not three.
One arrow, two arrows
The number of arrows decides what you get back.
SELECT * FROM users:1->follows;
The edges themselves — two rows.
SELECT * FROM users:1->follows->users;
The far endpoints — the users followed.
The other direction asks the mirrored question:
SELECT * FROM users:3<-follows<-users;
Who follows users:3. A record nobody follows answers with no rows rather than
an error.
Chains
Hops compose, and three hops are not a special case of two:
SELECT * FROM users:1->follows->users->follows->users;
A chain may also stop on the edges of its last step, by leaving off the final table:
SELECT * FROM users:1->follows->users->follows;
The arrows of a chain all point the same way. Mixing them is refused where it is written:
SELECT * FROM users:1->follows->users<-follows<-users;
Refused — UnexpectedToken.
Bounding a repeated walk: DEPTH
A chain written out is bounded because its steps are written. DEPTH n repeats
one step instead, and answers with every distinct record reachable within n
hops — every one but the record it started from:
SELECT * FROM users:1->follows->users DEPTH 3;
n is an integer literal, and the grammar has no position here for anything
else — not a parameter, not an expression, not a field. Every walk this language
can write therefore states its own length, and a reader of the statement knows
how far it goes without knowing what the caller bound. DEPTH $n would be an
unbounded walk with a promise attached, and the promise would be kept somewhere
the statement cannot show.
The bound is on the work, not merely on the path length: the walk keeps a
visited set, so a cycle is traversed once rather than for as long as the depth
allows. DEPTH 100 around a three-record ring is a small read, not a large one.
The shortest path: PATH TO
From 0.22.0-beta a walk over a declared edge kind can answer the path to a
record rather than everything it reaches:
DEFINE GRAPH towns;
CREATE towns:1 = { name: 'oslo' };
CREATE towns:2 = { name: 'hamar' };
CREATE towns:3 = { name: 'drammen' };
CREATE towns:4 = { name: 'bergen' };
DEFINE EDGE road IN towns FROM towns TO towns;
RELATE towns:1->road->towns:2 = { km: 120 };
RELATE towns:2->road->towns:4 = { km: 400 };
RELATE towns:1->road->towns:3 = { km: 40 };
RELATE towns:3->road->towns:4 = { km: 420 };
RELATE towns:1->road->towns:4 = { km: 600 };
SELECT name FROM towns:1->road->towns PATH TO towns:4 DEPTH 3;
SELECT name FROM towns:1->road->towns PATH TO towns:4 DEPTH 3 WEIGHT km;
The answer is the path's records, start to end, in order: oslo, bergen for
the first read — one step — and oslo, drammen, bergen for the second, the
cheapest by the edges' km. A path note says how many steps it took and what
they cost (2 steps costing 460). The start alone answers when it is the end,
and nothing answers when no path fits within DEPTH.
One path, by a rule: the cheapest, then the fewest steps, then the one whose
record ids, read in order, come first — so the same graph always answers the same
path. DEPTH is required and bounds the work, as on every repeated walk;
without it the read is refused (PathNeedsDepth). A weighted path is the
cheapest within that many steps, not the cheapest path.
A weight is a number of zero or more: an edge without the field is no step for a
weighted path, and one holding text or a negative number is refused
(PathWeight). The step walks a declared edge kind; an edge table is refused
(PathOverEdgeTable). A deleted node drops out of every path, and the records
answered are read under the reader's grants.
An edge outlives its endpoint
Deleting a record does not delete the edges that pointed at it.
DELETE users:3;
SELECT * FROM users:1->follows->users;
One row: the traversal resolves what it can and drops what it cannot, rather than failing. But the edge is still there:
SELECT * FROM users:1->follows;
Two rows. A dangling edge is visible when you ask about edges and invisible when you ask about endpoints — which is the behaviour you want when a record is recreated at the same id, and the behaviour to know about when it is not.
Only edge tables relate and traverse
RELATE users:1->users->users:2;
Refused — NotAnEdgeTable. So is traversing one:
SELECT * FROM users:1->users;
Which shape to use
DEFINE GRAPH with DEFINE EDGE writes adjacency beside the node and holds its
own records. Use it for a graph that is walked, where the cost that matters
is a hop and a hop should not grow with how many neighbours a node has — and
whenever the graph is the thing you are storing rather than a relationship
between tables that already exist for other reasons.
DEFINE TABLE … EDGE stores an edge as a record reached through an index. Use it
when edges are few, carry a lot of their own data, or are queried like rows —
SELECT * FROM follows WHERE since > … is a question about edges as a table, and
that is what a table is for.