SurrealDB Cloud Dedicated is now available
Sign up for early access β†—

Ten tips and tricks for your SurrealDB queries

featured company

Aug 18, 2025

Dave MacLeod
Dave MacLeod
Show all posts
Ten tips and tricks for your SurrealDB queries

We recently took a look at ten ways to improve your database schema, followed by ten more! Schema is all about setting definitions and expecations for your database, after which the operations are entirely passive. This blog post takes a look at the other part of working with your data: how to get the most out of active queries.

Did you know that you can…

1. Use the ?? operator to specify a default value for a field

The following person records are pretty similar, except that one doesn’t have a value for the age field.

CREATE person SET name = "Billy", age = 10;
CREATE person SET name = "Huma";

A query on both name and age works fine, with age showing up as the value NONE for one of them.

SELECT name, age FROM person; -- Output [ { age: NONE, name: 'Huma' }, { age: 10, name: 'Billy' } ]

But you might need to pass this information into an app that expects a value, such as either an integer or the value β€œunknown”. In that case, you can change age to age ?? "unknown" . An easy mnemonic to remember this syntax:

β€œIs there a value for age ?? If not, return "unknown" ”

After making that change, be sure to add AS age so that the field name stays as age instead of "age ?? 'unknown'" , the expression used to calculate it.

SELECT name, age ?? "unknown", // Works but output is ugly age ?? "unknown" AS age // Much cleaner output FROM person; -- Output [ { age: 10, "age ?? 'unknown'": 10, name: 'Billy' }, { age: 'unknown', "age ?? 'unknown'": 'unknown', name: 'Huma' } ]

2. Use the ? operator to skip out ahead of time and avoid errors

Another questionable SurrealQL operator is ? , which lets you avoid errors by skipping out ahead of time and returning NONE .

Let’s add a value for friends for the above queries, which contains an array of record IDs. Here as well, only one person record has a value for this field.

CREATE person SET name = "Billy", age = 10; CREATE person SET name = "Huma", friends = [minotaur:kaz, person:magius]; SELECT name, age, friends.len() AS num_friends FROM person;

This time we get an error, because .len() (the function array::len() ) can’t be called on a NONE.

'There was a problem running the len() function. no such method found for the none type'

A single question mark is enough to fix this. If the query finds no value for the field, it will give up at that point and the .len() method will not be called.

SELECT name, age, friends?.len() AS num_friends FROM person; -- Output: [ { age: NONE, name: 'Huma', num_friends: 2 }, { age: 10, name: 'Billy', num_friends: NONE } ]

The ?? can be used here too, because friends? may end up returning a NONE value, which is what the ?? operator looks for.

SELECT name, age ?? "unknown" AS age, friends?.len() ?? 0 AS num_friends FROM person; -- Output: [ { age: 10, name: 'Billy', num_friends: 0 }, { age: 'unknown', name: 'Huma', num_friends: 2 } ]

3. Use a SELECT expression at any point inside a graph query

Take the following example that uses graph relations to connect a recipe with its ingredients.

CREATE recipe:cake SET name = "Cake"; CREATE ingredient:flour SET name = "Flour"; CREATE ingredient:sugar SET name = "Sugar"; CREATE ingredient:cinnamon SET name = "Cinnamon"; RELATE recipe:cake->contains->ingredient:flour SET quantity_grams = 375; RELATE recipe:cake->contains->ingredient:sugar SET quantity_grams = 250; RELATE recipe:cake->contains->ingredient:cinnamon SET quantity_grams = 10;

A query on the ingredients can be done by using .{} on the contains edge and selecting which fields you want to display.

SELECT name AS recipe_name, ->contains.{ grams: quantity_grams, name: out.name } AS ingredient_list FROM recipe; -- Output [ { ingredient_list: [ { grams: 375, name: 'Flour' }, { grams: 250, name: 'Sugar' }, { grams: 10, name: 'Cinnamon' } ], recipe_name: 'Cake' } ]

Now what if you wanted to only show the main ingredients, those with the largest quantity? No problem here either, because SELECT can be used at any point inside a graph query. Just wrap it in parentheses and use SELECT as you normally would. The only difference is that SELECT at this point will be done on only the output from the previous step (the -> arrow), instead of the whole contains table.

SELECT name AS recipe_name, ->(SELECT quantity_grams, out.name AS ingredient FROM contains WHERE quantity_grams >= 100) AS major_ingredients FROM recipe; -- Output [ { major_ingredients: [ { ingredient: 'Sugar', quantity_grams: 250 }, { ingredient: 'Flour', quantity_grams: 375 } ], recipe_name: 'Cake' } ]

4. Use the SPLIT clause instead of [0]

This example shows two companies, one the parent of the other.

CREATE company:one, company:two; RELATE company:one->owns->company:two; RELATE company:one->owns->company:two;

To see each company’s parent company, you can use a graph query. Since a company can only have a single parent company, parentheses are used to grab the expression and then [0] to access the first record.

SELECT id, (<-owns<-company)[0] AS parent_company FROM company; -- Output: [ { id: company:one, parent_company: NONE }, { id: company:two, parent_company: company:one } ]

Using SPLIT is another option here, which will return one object for each result at the <-owns<-company path. This will remove company:one from the results because there is nothing for it to split, so in this case SPLIT also acts as a filter.

In addition, using SPLIT shows that we mistakenly used a RELATE statement twice for the same two companies!

(Did you catch that?)

SELECT id, <-owns<-company AS parent_company FROM company SPLIT parent_company; -- Output: [ { id: company:two, parent_company: company:one }, { id: company:two, parent_company: company:one } ]

By the way, you can ensure that the two RELATE statements couldn’t happen in the first place by creating a UNIQUE index. While this is a query tips and not schema tips blog, we would be remiss to not mention this fact!

DEFINE INDEX only_one ON owns FIELDS in, out UNIQUE;

5. Graph edges can have non-standard IDs too

The usual way to add metadata to a graph edge is by using RELATE and then SET to add the fields.

CREATE user:one, post:one; RELATE user:one->likes->post:one SET liked_at = time::now();

The liked_at edge will have a random ID like likes:geci4id19wk3k5g45iqb .

You can then use a filter query of this sort to see a user’s posts over the last five days. This approach is great if you are doing queries from the user’s side.

SELECT ->likes[WHERE liked_at > time::now() - 5d] AS likes FROM user;

However, if you are using a lot of queries from the likes table itself, you might want to opt for a complex array ID such as this one that includes a record and a datetime.

likes:[user:one, d'2025-08-14T02:09:30.303Z']

The relation can be created in the same way, except that this time the ID of the edge is specified.

RELATE user:one->likes:[user:one, time::now()]->user:two;

If the RELATE statement can’t parse the ID you are attempting to pass in, you can use INSERT RELATION instead.

// RELATE can't handle function calls yet, returns error RELATE user:one->type::thing("likes", [user:one, time::now()])->user:two; // But this will work INSERT RELATION { id: type::thing("likes", [user:one, time::now()]), in: user:one, out: user:two };

With this complex ID, you can use the former query in almost the same way (changing liked_at to id.liked_at ) while also being able to use a record range query on the likes table itself, thereby avoiding a full table scan.

SELECT ->likes[WHERE id.liked_at > time::now() - 5d] AS likes FROM user; SELECT * FROM likes:[user:one, time::now() - 5d]..[user:one, time::now()];

6. You can use fields for polymorphic relationships in graph queries

A graph query can be done on multiple types of graph edges. The example below shows how you can combine multiple edges into a single field.

CREATE person:one, person:two, person:three, person:four; RELATE person:one->friends_with->person:two; RELATE person:one->works_with->person:three; RELATE person:one->married_to->person:four; SELECT ->(friends_with, works_with, married_to)->person AS knows FROM person;

If you feel like you are using too many types of edge relations, you can combine them into a single relation and differentiate by field values instead.

CREATE person:one, person:two, person:three, person:four; RELATE person:one->knows->person:two SET friends_with = true, knows_since = time::now() - 10y; RELATE person:one->knows->person:three SET works_with = true, friends_with = true, knows_since = time::now() - 1y; RELATE person:one->knows->person:four SET married_to = true, knows_since = time::now() - 10y;

Now there is only one edge connecting person records, but you can use the [WHERE] syntax at any point to filter by relationship type.

SELECT id, ->knows[WHERE friends_with AND works_with]->person AS friendly_coworkers, ->knows[WHERE friends_with AND time::now() - knows_since > 5y]->person AS long_term_friends FROM ONLY person:one; -- Output: { friendly_coworkers: [ person:three ], id: person:one, long_term_friends: [ person:two ] }

7. Use SELECT to get around current closure limitations

A current limitation of SurrealQL is that closures are not aware of variables. In the query below, this means that the $now parameter will show up as NONE instead of a datetime when attempting to map some record IDs into an array of objects.

LET $now = time::now(); [user:one, user:two, user:three].map(|$user| { id: $user, name: "User number " + <string>$user.id(), created_at: $now }); -- Output: [ { created_at: NONE, id: user:one, name: 'User number one' }, { created_at: NONE, id: user:two, name: 'User number two' }, { created_at: NONE, id: user:three, name: 'User number three' } ]

However, since .map() returns an array of objects with field names, these field names can be selected in the usual way - along with the $now parameter as the created_at field.

LET $now = time::now(); SELECT id, name, $now AS created_at FROM [user:one, user:two, user:three].map(|$user| { id: $user, name: "User number " + <string>$user.id() }); -- Output: [ { created_at: d'2025-08-13T05:16:36.492Z', id: user:one, name: 'User number one' }, { created_at: d'2025-08-13T05:16:36.492Z', id: user:two, name: 'User number two' }, { created_at: d'2025-08-13T05:16:36.492Z', id: user:three, name: 'User number three' } ]

8. Use .reduce() for the time being

Parameters in SurrealDB are currently immutable, meaning that this sort of pattern to increment the value of a parameter will not work.

LET $num = 0; FOR $int IN [1,2,3] { $num += $int };

Functions like array::reduce() , array::fold() , and .chain() can be used in these cases instead.

The array::reduce() function performs an operation on each item in an array, array::fold() does the same but with an extra initial value, and .chain() allows you to take any value and perform one operation on it.

-- Returns 6 [1,2,3].reduce(|$one, $two| $one + $two); -- Returns 106 [1,2,3].fold(100, |$one, $two| $one + $two); -- Returns 53 [1,2,3].fold(100, |$one, $two| $one + $two).chain(|$final| $final / 2);

9. Use statements inside another statement

This example shows a laptop that is created and assigned to an new employee.

LET $laptop = CREATE ONLY computer SET model_num = 87687576; CREATE ONLY employee SET name = "Johnny Johnclaw McJohnson", dob = '1985-09-01T00:00:00Z', computer = $laptop.id; -- Output: { computer: computer:k1vshq9stmg3kxmqcp9k, dob: '1985-09-01T00:00:00Z', id: employee:je01eet3jxn4b69e889o, name: 'Johnny Johnclaw McJohnson' }

However, when creating records that depend on each other it can be preferable to do the entire creation inside a single query. This will allow all of the statements to fail at once if something has gone wrong, and you won’t have to manually undo any previous statements.

This time we’ll have the dob field as a datetime .

DEFINE FIELD dob ON employee TYPE datetime; CREATE ONLY employee SET name = "Johnny Johnclaw McJohnson", dob = '1985-09-01T00:00:00Z', computer = CREATE ONLY computer SET model_num = 87687576 RETURN VALUE id;

Oops! The value '1985-09-01T00:00:00Z' that we passed in is a string and not a datetime .

"Found '1985-09-01T00:00:00Z' for field `dob`, with record `employee:v17nqyhi6gxh8jd1cgg1`, but expected a datetime"

Fortunately, both create statements for employee and computer failed so we still have a clean slate to work with. All we need to do is add a d in front of the date to have the input recognized as a datetime and not a string .

DEFINE FIELD dob ON employee TYPE datetime; CREATE ONLY employee SET name = "Johnny Johnclaw McJohnson", dob = d'1985-09-01T00:00:00Z', computer = CREATE ONLY computer SET model_num = 87687576 RETURN VALUE id; SELECT * FROM employee; -- Output: [ { computer: computer:b5sh7jbjs5ixqzx9740n, dob: d'1985-09-01T00:00:00Z', id: employee:r057f7pvirlz9f4lfmz5, name: 'Johnny Johnclaw McJohnson' } ]

10. Add metadata with RETURN

This last tip is about a functionality that is pretty basic but easy to forget about.

There are quite a few options available to you when using RETURN after a query. You can RETURN BEFORE , RETURN AFTER , RETURN DIFF , RETURN VALUE field_name , or just about anything else.

Returning a variable output makes it possible to return some content once without touching the actual saved data. For example, you could return a message for a developer that you know is monitoring the database activity and needs to be alerted to something.

CREATE user SET created_at = time::now() RETURN *, "Jeff, seriously don't delete this user this time, he's threatening to cancel his membership!!!" AS `πŸ‘‰πŸ‘‰πŸ‘‰πŸ‘‰πŸ‘‰Look at this, JeffπŸ‘ˆπŸ‘ˆπŸ‘ˆπŸ‘ˆπŸ‘ˆ`, "Also please deposit 100 gold in the user's Bank of Astonia account as a way to say sorry" AS `πŸ‘‰πŸ‘‰πŸ‘‰πŸ‘‰πŸ‘‰Plus please do thisπŸ‘ˆπŸ‘ˆπŸ‘ˆπŸ‘ˆπŸ‘ˆ`; -- Output: [ { created_at: d'2025-08-14T02:52:18.156Z', id: user:amhq2v4errgbujwvzkgp, "πŸ‘‰πŸ‘‰πŸ‘‰πŸ‘‰πŸ‘‰Look at this, JeffπŸ‘ˆπŸ‘ˆπŸ‘ˆπŸ‘ˆπŸ‘ˆ": "Jeff, seriously don't delete this user this time, he's threatening to cancel his membership!!!", "πŸ‘‰πŸ‘‰πŸ‘‰πŸ‘‰πŸ‘‰Plus please do thisπŸ‘ˆπŸ‘ˆπŸ‘ˆπŸ‘ˆπŸ‘ˆ": "Also please deposit 100 gold in the user's Bank of Astonia account as a way to say sorry" } ]

But the original user record will stay untouched.

SELECT * FROM user; -- Output [ { created_at: d'2025-08-14T02:52:18.156Z', id: user:amhq2v4errgbujwvzkgp, } ]
Hybrid vector + text Search in the terminal with SurrealDB and Ratatui

featured

Hybrid vector + text Search in the terminal with SurrealDB and Ratatui

Aug 7, 2025

Multi-model RAG with LangChain

featured

Multi-model RAG with LangChain

Aug 11, 2025

Get insider access to Surreal's latest news and events

Trusted & Certified for Enterprise Security Learn more
SOC 2 Type 2
GDPR
Cyber Essentials Plus
ISO 27001