The first question when modelling connections is whether graph edges are the right tool: SurrealDB can also connect records with record links.
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;[
{
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'
}
]Reverse direction without edges
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!'
}
]Bidirectional record links (3.0+)
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;-------- 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?"
}
]One-to-one, one-to-many and many-to-many
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:
| Relationship | Example | Field |
|---|---|---|
| One-to-one | Each user owns at most one blog | owner ON blog TYPE record<user>, with a UNIQUE index |
| One-to-many | A blog has many posts | blog ON post TYPE record<blog>, stored on the "many" side |
| Many-to-many | A post has many tags, and a tag is on many posts | tags 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.
ownerholds one user, so each blog has one owner, but several blogs could name the same user. TheUNIQUEindex 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
postsfield onblogis computed from the incoming references with<~post, so it cannot go out of date. This needsREFERENCEon the field that holds the link.record<blog>checks the table, not the record. The type accepts anyblog:…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
SCHEMAFULLfield of typearray<record<tag>>refusesNONE, so a post created without tags fails unless the field hasDEFAULT [], or usesoption<array<record<tag>>>instead.ON DELETE CASCADEdeletes a blog's posts when the blog is deleted. The otherON DELETEoptions 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.
When to prefer record links
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 DELETEand related options.
When to prefer graph relations
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 severalDEFINE FIELDsteps).You need expressive arrow syntax for multi-hop patterns such as
->wrote->comment<-wrote<-person->wrote->commentstarting fromperson.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
}Weighting and counting on graph tables
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.
When links are deleted
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.