Skip to content

Graph

Graph traversal

Graph queries in SurrealDB use SurrealQL’s -> arrow syntax to walk relationships between records.

SELECT ->wrote->post.* AS userPosts
  FROM users:alice;
  • FROM users:alice starts at that node.

  • ->wrote->posts.* follows the wrote edge to posts and returns full post records as userPosts.

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”.

From comments, find authors and then all of their comments:

SELECT <-wrote<-user->wrote->comment FROM comment;

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;
Output
[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;
Output
[]

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.

Naming several edge tables walks all of them at once, and ? stands for any table:

SELECT VALUE ->(wrote, likes)->? FROM ONLY user:mcuserson;
Output
[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;
Output
['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";
Output
[
	{
		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.

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 row 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;
Output
[
	{
		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.

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;
Output
-------- 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 };

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.

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;
Output
[
	{
		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.*;
Output
{
	employers: [
		company:1
	],
	id: person:1
}

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, then user:mcuserson.

  • Open the Relations tab (outgoing ->).

  • Follow wrote into its edge row, then comment to the comment row, matching ->wrote->comment.

Working backward in the Explorer is a good way to assemble a path while learning the syntax.

Was this page helpful?