Graph queries in SurrealDB use SurrealQL’s -> arrow syntax to walk relationships between records.
Basic forward and reverse traversals
SELECT ->wrote->post.* AS userPosts
FROM users:alice;FROM users:alicestarts at that node.->wrote->post.*follows thewroteedge to posts and returns full post records asuserPosts.
Traverse in the reverse direction (for example, from a post to its authors):
SELECT <-wrote<-author AS authors
FROM post:helloworld;<-wrote<-author means “follow any wrote edge into this post from an author”.
A single related record as a value
A traversal always returns an array, even when a record has only one edge of that kind. When there is only ever one, take the first element, either of the traversal or of the field read through it:
CREATE user:1;
CREATE card:1 SET name = 'visa';
RELATE user:1->has_card->card:1;
SELECT
->has_card->card.name AS all_names,
(->has_card->card.name)[0] AS name,
->has_card->card[0].name AS also_name
FROM user:1;[
{
all_names: ['visa'],
also_name: 'visa',
name: 'visa'
}
]Taking [0] assumes there is only one. If a user has a second card, [0] returns one of the two and the other is ignored without an error. To make the query fail instead, select from the traversal with ONLY, which returns a single value and returns an error if the traversal holds more than one:
SELECT (SELECT VALUE name FROM ONLY ->has_card->card) AS name FROM user:1;[
{
name: 'visa'
}
]After a second card is related to the same user, the same query returns an error:
CREATE card:2 SET name = 'amex';
RELATE user:1->has_card->card:2;
SELECT (SELECT VALUE name FROM ONLY ->has_card->card) AS name FROM user:1;'Expected a single result output when using the ONLY keyword'A user with no card gets NONE rather than an error. SPLIT does not help here: it does not split the array that a traversal returns, and on a stored array it returns one result per element rather than an error.
To prevent a second card instead of detecting it, add a UNIQUE index on the edge's in field, so that a user cannot have a second has_card edge.
Chaining paths
From comments, find authors and then all of their comments:
SELECT <-wrote<-user->wrote->comment FROM comment;Traverse directly from a record id
Traversal can start at one or more record IDs without wrapping everything in SELECT:
CREATE ONLY user:mcuserson SET name = "User McUserson";
CREATE ONLY comment:one SET
text = "I learned something new!",
created_at = time::now();
CREATE ONLY cat:pumpkin SET name = "Pumpkin";
RELATE user:mcuserson->wrote->comment:one SET
location = "Arizona",
os = "Windows 11",
mood = "happy";
RELATE user:mcuserson->likes->cat:pumpkin;-- Equivalent to:
-- SELECT VALUE <-wrote<-user FROM ONLY comment:one;
comment:one<-wrote<-user;
-- Equivalent to:
-- SELECT VALUE ->likes->cat FROM ONLY user:mcuserson;
user:mcuserson->likes->cat;-------- Query --------
[user:mcuserson]
-------- Query --------
[cat:pumpkin]To include fields when starting from an id, use destructuring:
-- Equivalent to:
-- SELECT name, ->likes->cat AS cats FROM ONLY user:mcuserson;
user:mcuserson.{ name, cats: ->likes->cat };Filtering a traversal
An arrow itself is already a filter on the edge table. For example, ->wrote->comment follows only wrote edges and leaves every other kind alone. Two further filters sit alongside it - one on the edge's own fields, and one on how many edge tables to follow at once.
These examples use a user with two outgoing edges, one of which carries fields:
CREATE user:mcuserson SET name = "User McUserson";
CREATE comment:one SET text = "I learned something new!";
CREATE cat:pumpkin SET name = "Pumpkin";
RELATE user:mcuserson->wrote->comment:one SET
location = "Arizona",
os = "Windows 11",
mood = "happy";
RELATE user:mcuserson->likes->cat:pumpkin;A condition in parentheses applies to the edge, and only the edges that pass it are followed:
SELECT VALUE ->(wrote WHERE location = "Arizona")->comment FROM ONLY user:mcuserson;[comment:one]The fields named in the condition are the edge's own, so a condition nothing matches ends the walk there:
SELECT VALUE ->(wrote WHERE location = "Oregon")->comment FROM ONLY user:mcuserson;[]Square brackets look and act similar but are a different mechanism. [WHERE ...] is the generic array filter, so ->wrote[WHERE location = "Arizona"]->comment walks every wrote edge and then filters the array that comes back. Inside the parentheses the condition is part of the path instead, and becomes the edge scan's own predicate, so edges that fail it are never followed. Both return the same records, and EXPLAIN shows the difference: the parenthesised form carries a predicate on the scan, the bracketed one carries none. On a record with a few edges the difference does not matter, while on one with thousands, only the first stays fast.
To follow edges to one specific record, name its ID in the condition on the target table. A record ID straight after the arrow, as in ->follows->user:janet, is a parse error:
CREATE user:ann, user:janet, user:bob;
RELATE user:ann->follows->[user:janet, user:bob];
SELECT VALUE ->follows->(user WHERE id = user:janet) FROM ONLY user:ann;[user:janet]A range with the same start and end, ->follows->user:janet..=janet, gives the same result.
To ask only whether the edge exists, compare the traversal with the ID instead:
SELECT VALUE ->follows->user CONTAINS user:janet FROM ONLY user:ann;trueNaming several edge tables walks all of them at once, and ? stands for any table:
SELECT VALUE ->(wrote, likes)->? FROM ONLY user:mcuserson;[comment:one, cat:pumpkin]->?->? therefore follows every edge leaving a record, whatever its type. Where the edges on a record are not known in advance, reading the table name off each one gives the kinds of relationship it actually has:
SELECT VALUE array::distinct(->?.map(|$e| $e.id.tb())) FROM ONLY user:mcuserson;['likes', 'wrote']An edge is a record like any other, so the same question can be asked from the edge table instead, with in and out naming the two ends:
SELECT out, mood FROM wrote WHERE location = "Arizona";[
{
mood: 'happy',
out: comment:one
}
]Both forms reach the same edges. The arrow suits a walk that carries on to the records at the far end, and the table form suits a question about the edges themselves.
To return the edges in pages, put a full SELECT inside the parentheses. It takes ORDER BY, LIMIT and START: LIMIT sets the size of a page, START skips the pages before it, and ORDER BY keeps the order the same from one page to the next. The path then continues to the records at the far end. Here user:ann follows four users, returned two to a page in the order she followed them:
CREATE user:ann, user:bob, user:cleo, user:dev, user:eve;
RELATE user:ann->follows->user:bob SET since = d"2026-01-04";
RELATE user:ann->follows->user:cleo SET since = d"2026-02-11";
RELATE user:ann->follows->user:dev SET since = d"2026-03-20";
RELATE user:ann->follows->user:eve SET since = d"2026-05-02";
-- First page
SELECT VALUE ->(SELECT * FROM follows ORDER BY since LIMIT 2 START 0)->user FROM ONLY user:ann;
//- [user:bob, user:cleo]
-- Second page
SELECT VALUE ->(SELECT * FROM follows ORDER BY since LIMIT 2 START 2)->user FROM ONLY user:ann;
//- [user:dev, user:eve]Another way to get the next page is to continue from the last value of the page before, with a WHERE condition in place of START. Pages built this way stay correct when edges are added between requests, because a new edge does not move the edges after it into a different page:
-- The page after the one that ended with since = 2026-02-11
SELECT VALUE ->(SELECT * FROM follows WHERE since > d"2026-02-11" ORDER BY since LIMIT 2)->user FROM ONLY user:ann;[user:dev, user:eve]To page the records at the far end instead of the edges, put the whole path in FROM: SELECT * FROM user:ann->follows->user ORDER BY id LIMIT 2 START 2. The graph clauses section lists the other clauses a path can take.
--END--
Records with no edges
A traversal that finds no edges returns an empty array, and an empty array is falsy, so ! in front of a path selects the records that have no edge in that direction. In a tree where each record points to its parent with a child_of edge, the roots are the records with no outgoing child_of, and the leaves are the records with no incoming one:
CREATE thing:a, thing:b, thing:c, thing:d;
RELATE thing:b->child_of->thing:a;
RELATE thing:c->child_of->thing:a;
RELATE thing:d->child_of->thing:b;
RELATE thing:d->child_of->thing:c;
-- Roots: no parent
SELECT VALUE id FROM thing WHERE !->child_of;
//- [thing:a]
-- Leaves: no children
SELECT VALUE id FROM thing WHERE !<-child_of;
//- [thing:d]Each record checks only its own edges, so the query does not need to collect the ids of every child first.
Automatic flattening in graph queries
When two lookups follow each other, or a filter follows a lookup, results can flatten in a way that mirrors the graph shape. This example builds a small org chart and walks up and down works_for:
CREATE
-- One president
person:president,
-- Two managers
person:manager1, person:manager2,
-- Four employees
person:employee1, person:employee2, person:employee3, person:employee4;
-- Employees work two to a manager, managers work two to a president
RELATE [person:manager1, person:manager2]->works_for->person:president;
RELATE [person:employee1, person:employee2]->works_for->person:manager1;
RELATE [person:employee3, person:employee4]->works_for->person:manager2;
[person:employee1, person:employee2, person:employee3, person:employee4]
->works_for->person
->works_for->person
<-works_for<-person
<-works_for<-person;The result is four arrays, each reflecting the up-and-down path through the president.
You can approximate the same with nested SELECT / map, but nesting grows quickly:
[person:employee1, person:employee2, person:employee3, person:employee4]
-- Each $p is a single record: an array<record>
.map(|$p| SELECT VALUE out FROM works_for WHERE in = $p.id)
-- Each $p is an array, so now you have to map each item inside that
.map(|$p| $p.map(|$p| SELECT VALUE out FROM works_for
WHERE in = $p.id))
-- Now an array<array<array<record>>>
.map(|$p| $p.map(|$p| $p.map(|$p| SELECT VALUE in FROM works_for
WHERE out = $p.id)))
-- Now an array<array<array<array<record>>>>
.map(|$p| $p.map(|$p| $p.map(|$p| $p.map(|$p| SELECT VALUE in
FROM works_for WHERE out = $p.id))));Graph syntax keeps structure aligned with the traversal. Calling flatten() at each step produces a flat list of every record seen along the way instead:
[person:employee1, person:employee2, person:employee3, person:employee4]
.map(|$p| SELECT VALUE out FROM works_for WHERE in = $p.id).flatten()
.map(|$p| SELECT VALUE out FROM works_for WHERE in = $p.id).flatten()
.map(|$p| SELECT VALUE in FROM works_for WHERE out = $p.id).flatten()
.map(|$p| SELECT VALUE in FROM works_for WHERE out = $p.id).flatten();Treat graph flattening as preserving the shape of the walk, not necessarily a single post-hoc array.
Grouping children under each parent
A path that continues past a level gives the records at the end of the path, and the level in between is not part of the output. In the store below, ->has->category->has->item gives every item in the store in one array, without the categories they belong to:
CREATE store:local SET name = 'Local store';
CREATE category:food SET name = 'Food';
CREATE category:drinks SET name = 'Drinks';
CREATE item:burger SET name = 'Burger';
CREATE item:roll SET name = 'Roll';
CREATE item:water SET name = 'Water';
RELATE store:local->has->[category:food, category:drinks];
RELATE category:food->has->[item:burger, item:roll];
RELATE category:drinks->has->item:water;
SELECT name, ->has->category->has->item.name AS items FROM store:local;[
{
items: ['Roll', 'Burger', 'Water'],
name: 'Local store'
}
]To keep the items under their category, stop the path at category and continue from each category with a destructuring .{ }. The paths inside the braces start from the category:
SELECT name, ->has->category.{ name, items: ->has->item.name } AS categories FROM store:local;[
{
categories: [
{ items: ['Roll', 'Burger'], name: 'Food' },
{ items: ['Water'], name: 'Drinks' }
],
name: 'Local store'
}
]A nested SELECT over the path gives the same result, and also allows a WHERE, ORDER BY or LIMIT for each level:
SELECT name, (SELECT name, ->has->item.name AS items FROM ->has->category) AS categories FROM store:local;The same approach works for record links, for example categories.{ name, items: items.name } for a store whose categories field holds record links to categories that each hold an items field.
Ranking records by shared connections
A path through a shared record returns a record once for every route that reaches it, so the repeats count the connections. Going from a post to its tags and back to the posts with those tags, a post that shares two tags appears twice. Grouping the result and counting gives the related posts in order of how much they share:
CREATE post:a SET title = 'A';
CREATE post:b SET title = 'B';
CREATE post:c SET title = 'C';
CREATE post:d SET title = 'D';
CREATE tag:rust, tag:db, tag:web;
RELATE post:a->tagged->[tag:rust, tag:db];
RELATE post:b->tagged->[tag:rust, tag:db, tag:web];
RELATE post:c->tagged->tag:web;
RELATE post:d->tagged->tag:db;
LET $post = post:a;
SELECT title, count() AS shared
FROM ($post->tagged->tag<-tagged<-post).filter(|$p| $p != $post)
GROUP BY title
ORDER BY shared DESC;[
{ shared: 2, title: 'B' },
{ shared: 1, title: 'D' }
]The .filter() removes the starting post, which the path also reaches through each of its own tags. A SELECT over an array of record IDs reads the records, so title is available to group by.
Querying symmetric (“between equals”) relations
For edges like friends_with, it may be unclear whether a person is on the in or out side. The <-> operator traverses both directions on that edge table:
SELECT *, <->friends_with<->person AS friends FROM person;Each record lists the other people in the relation, regardless of which side they were on:
[
{
friends: [
person:one,
person:two
],
id: person:one
},
{
friends: [
person:one,
person:two
],
id: person:two
}
]To drop the current record’s own id from the list, use array::complement():
SELECT
*,
array::complement(<->friends_with<->person, [id]) AS friends
FROM person;[
{
friends: [
person:two
],
id: person:one
},
{
friends: [
person:one
],
id: person:two
}
]For more on this pattern, see the RELATE statement and Chapter 7 of Aeon's Surreal Renaissance.
Graph paths in schema fields
Paths are not limited to SELECT; they can appear in DEFINE FIELD:
DEFINE FIELD employers
ON TABLE person VALUE SELECT VALUE <-employs<-company FROM ONLY $this;
CREATE person:1, person:2, company:1;
RELATE company:1->employs->person:1;
person:1.*;A plain VALUE field is computed when the record is created or updated; if the RELATE runs later, the field can be stale until an UPDATE:
UPDATE person:1;[
{
employers: [
company:1
],
id: person:1
}
]A computed field is recomputed on read:
DEFINE FIELD employers ON TABLE person COMPUTED <-employs<-company;
CREATE person:1, person:2, company:1;
RELATE company:1->employs->person:1;
person:1.*;{
employers: [
company:1
],
id: person:1
}Using SurrealDB Studio Explorer to debug paths
SurrealDB Studio’s Explorer view lets you step through records and relations one hop at a time, useful for building intuition for queries like SELECT ->wrote->comment FROM user.
Example setup with two outgoing edges:
CREATE user:mcuserson SET name = "User McUserson";
CREATE comment:one SET
text = "I learned something new!",
created_at = time::now();
CREATE cat:pumpkin SET name = "Pumpkin";
RELATE user:mcuserson->wrote->comment:one SET
location = "Arizona",
os = "Windows 11",
mood = "happy";
RELATE user:mcuserson->likes->cat:pumpkin;Walkthrough:
Open
user, thenuser:mcuserson.Open the Relations tab (outgoing
->).Follow
wroteinto its edge record, thencommentto the comment record, matching->wrote->comment.
Working backward in the Explorer is a good way to assemble a path while learning the syntax.
Further reading
Recursive traversals for
@.{n}and nested recursive shapes.