Skip to content

Graph

Record links vs graph relations

The first question when modelling connections is whether graph edges are the right tool: SurrealDB can also connect records with record links.

A record link is a pointer from one record to another: any field that holds a record id. Record ids are efficient direct pointers and avoid table scans.

Example: one user with two comment records linked from the user:

LET $new_user = CREATE ONLY user SET name = "User McUserson";
-- Create a new comment, use the output to update the user
UPDATE $new_user SET comments += (CREATE ONLY comment SET 
    text = "I learned something new!", 
    created_at = time::now())
    .id;
UPDATE $new_user SET comments += (CREATE ONLY comment SET
    text = "I don't get it, can you explain?",
    created_at = time::now())
    .id;

Querying is like reading any other field:

SELECT 
    name, 
    comments.{ created_at, text }
FROM user;
Output
[
	{
		comments: [
			{
				created_at: d'2024-12-12T02:39:07.644Z',
				text: 'I learned something new!'
			},
			{
				created_at: d'2024-12-12T02:39:07.645Z',
				text: "I don't get it, can you explain?"
			}
		],
		name: 'User McUserson'
	}
]

Historically, record links were unidirectional, so the other direction often needed a subquery, while graph edges made reverse walks easy:

SELECT 
    *,
    -- Check the `user` table's `comments` field
    -- for the id of the current comment
    (SELECT id, name FROM user WHERE $parent.id IN comments) AS author
FROM comment;

-- Equivalent graph query is much easier
-- to read and write
SELECT 
	*,
	<-wrote<-author
FROM comment;
[
	{
		author: [
			{
				id: user:f3t90z8uvns76sr3nxrd,
				name: 'User McUserson'
			}
		],
		created_at: d'2024-12-12T02:39:07.645Z',
		id: comment:gj1vtsd9d19z9afrc14j,
		text: "I don't get it, can you explain?"
	},
	{
		author: [
			{
				id: user:f3t90z8uvns76sr3nxrd,
				name: 'User McUserson'
			}
		],
		created_at: d'2024-12-12T02:39:07.644Z',
		id: comment:zhnbfopxspekknsi6vx6,
		text: 'I learned something new!'
	}
]

Since version 3.0.0, a record link can be bidirectional by defining a field with the REFERENCE clause and using a computed back-reference:

DEFINE FIELD comments ON user TYPE option<array<record<comment>>> REFERENCE;
DEFINE FIELD author ON comment COMPUTED <~user;

LET $new_user = CREATE ONLY user SET name = "User McUserson";
-- Create a new comment, use the output to update the user
UPDATE $new_user SET comments += (CREATE ONLY comment:one SET 
    text = "I learned something new!", 
    created_at = time::now())
    .id;
UPDATE $new_user SET comments += (CREATE ONLY comment:two SET
    text = "I don't get it, can you explain?",
    created_at = time::now())
    .id;

-- 'author' field is populated with the 'user' who wrote the comment
SELECT * FROM ONLY comment:one;

-- Regular queries on incoming references work too
SELECT text, <~user.id[0] AS commenter FROM comment;
Output
-------- Query --------

{
	author: [
		user:igi77zrlewsqemgjz4zi
	],
	created_at: d'2025-09-02T02:56:03.408Z',
	id: comment:one,
	text: 'I learned something new!'
}

-------- Query --------

[
	{
		commenter: user:igi77zrlewsqemgjz4zi,
		text: 'I learned something new!'
	},
	{
		commenter: user:igi77zrlewsqemgjz4zi,
		text: "I don't get it, can you explain?"
	}
]

Each kind of relationship maps to a record link field, and the field definition decides how strict it is. The schema below models a blog with posts and tags:

RelationshipExampleField
One-to-oneEach user owns at most one blogowner ON blog TYPE record<user>, with a UNIQUE index
One-to-manyA blog has many postsblog ON post TYPE record<blog>, stored on the "many" side
Many-to-manyA post has many tags, and a tag is on many poststags ON post TYPE array<record<tag>>
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD name ON user TYPE string;

DEFINE TABLE blog SCHEMAFULL;
DEFINE FIELD title ON blog TYPE string;
DEFINE FIELD owner ON blog TYPE record<user>;
DEFINE INDEX one_blog_per_owner ON blog FIELDS owner UNIQUE;
DEFINE FIELD posts ON blog COMPUTED <~post;

DEFINE TABLE tag SCHEMAFULL;
DEFINE FIELD name ON tag TYPE string;
DEFINE FIELD posts ON tag COMPUTED <~post;

DEFINE TABLE post SCHEMAFULL;
DEFINE FIELD title ON post TYPE string;
DEFINE FIELD blog ON post TYPE record<blog> REFERENCE ON DELETE CASCADE ASSERT $value.exists();
DEFINE FIELD tags ON post TYPE array<record<tag>> DEFAULT [] REFERENCE ASSERT $value.all(|$t| $t.exists());

Some details about this schema:

  • One-to-one needs the index. owner holds one user, so each blog has one owner, but several blogs could name the same user. The UNIQUE index refuses a second blog for the same user.

  • The link is stored once. A post stores its blog, and the blog does not store an array of posts. The posts field on blog is computed from the incoming references with <~post, so it cannot go out of date. This needs REFERENCE on the field that holds the link.

  • record<blog> checks the table, not the record. The type accepts any blog:… id, including one for a record that does not exist. ASSERT $value.exists() adds the check that a foreign key makes in a relational database, and $value.all(|$t| $t.exists()) does the same for each element of an array.

  • An array field needs a value. A SCHEMAFULL field of type array<record<tag>> refuses NONE, so a post created without tags fails unless the field has DEFAULT [], or uses option<array<record<tag>>> instead.

  • ON DELETE CASCADE deletes a blog's posts when the blog is deleted. The other ON DELETE options refuse the delete, leave or remove the link, or run an expression.

With some data in place, each check refuses a link that it should:

CREATE user:ann SET name = 'Ann';
CREATE blog:news SET title = 'News', owner = user:ann;
CREATE tag:rust SET name = 'rust';
CREATE tag:db SET name = 'databases';
CREATE post:one SET title = 'Hello', blog = blog:news, tags = [tag:rust, tag:db];
CREATE post:two SET title = 'Second', blog = blog:news;

CREATE post:three SET title = 'Lost', blog = blog:gone;
//- 'Found blog:gone for field `blog`, with record `post:three`, but field must conform to: $value.exists()'

CREATE post:four SET title = 'Typo', blog = blog:news, tags = [tag:rsut];
//- 'Found [tag:rsut] for field `tags`, with record `post:four`, but field must conform to: $value.all(|$t: any| ($t.exists()))'

CREATE blog:other SET title = 'Other', owner = user:ann;
//- 'Database index `one_blog_per_owner` already contains user:ann, with record `blog:news`'

Queries follow the links in either direction:

SELECT title, posts.title AS posts FROM blog:news;
//- [{ posts: ['Hello', 'Second'], title: 'News' }]

SELECT name, posts.title AS posts FROM tag:rust;
//- [{ name: 'rust', posts: ['Hello'] }]

SELECT title, blog.owner.name AS owner, tags.name AS tags FROM post:one;
//- [{ owner: 'Ann', tags: ['rust', 'databases'], title: 'Hello' }]

A many-to-many relationship can also be a graph edge, such as RELATE post:one->tagged->tag:rust. Use an edge when the relationship has data of its own, such as who added the tag and when, as the sections below describe.

Record links are preferred if:

  • Performance is the top priority.

  • You do not need very complex multi-hop graph queries.

  • You want the schema to declare what happens when a linked record is deleted (cascade, refuse, ignore, etc.) via ON DELETE and related options.

Graph relations are preferred if:

  • You want to create links quickly without pre-defining every field, or you link across many record types in one go (for example RELATE person:one->wrote->[blog:one, book:one, comment:one] versus several DEFINE FIELD steps).

  • You need expressive arrow syntax for multi-hop patterns such as ->wrote->comment<-wrote<-person->wrote->comment starting from person.

  • You want SurrealDB Studio’s Designer to visualise edges clearly.

Graph edges are almost required when the relationship itself carries metadata that belongs neither to the source nor target record alone (for example, the moment a user posted a comment):

{
    ip_addr_location: "Arizona",
    os: "Windows 11",
    current_mood: "Happy"
}

That data is about the link event, not the whole user or the comment body, so it belongs on an edge table.

The same applies to durable facts about the relationship:

{
	friends_since: d'2024-12-31T06:43:21.981Z',
	friendship_strength: 0.4
}

Graph tables are a natural place to store weights on the edge record, or to derive interaction strength by counting how many edges exist between the same endpoints. If you need that kind of scoring or event metadata on the relationship itself, graph relations are usually the right abstraction.

Worked examples (random NPC interactions, knows vs greeted, aggregations) live in Social network patterns.

Record links can encode rich behaviour on delete (for example, removing an id from an array while appending to a history field), via REFERENCE ON DELETE.

Graph edges are simpler: if either endpoint record is deleted, the edge record is removed.

-- likes record created without problems
RELATE person:one->likes->person:two;
CREATE person:one, person:two;
DELETE person:one;
-- 'likes' record is now gone
SELECT * FROM likes;

Example of record-link cleanup:

DEFINE FIELD comments ON person TYPE option<array<record<comment>>> REFERENCE ON DELETE THEN {
    UPDATE $this SET
        deleted_comments += $reference,
        comments -= $reference;
};

For more on ON DELETE and references, see Record references.

Was this page helpful?