2.x to 3.xThis guide consolidates all breaking changes when upgrading from SurrealDB 2.x to 3.x, organised by severity level. If you are using Surrealist, you can use the migration diagnostics to automatically see your data. This will also provide you with a list of actions you need to take to migrate your data.
Surrealist provides a built-in migration diagnostics tool that can be used to automatically see your data and provide you with a list of actions you need to take to migrate your data.
NoteThe migration diagnostics tool is only available for SurrealDB version
2.6.0and above.

Select your 2.x database and click on the Migration option in the sidebar. This will open the migration diagnostics tool. First you’ll need to start the checks by clicking on the Start Checks button. This will return a migration report with a list of actions you need to take to migrate your data (If any).

After resolving the issue, click on the Mark as resolved button to mark the issue as resolved. This will remove the issue from the migration report.
Once all issues have been resolved, the migration diagnostics tool will allow you to export a V3 Compatible Export that can be imported into your updated SurrealDB 3.x instance.
Starting with SurrealDB 2.6.0, you can export your database in a format that is compatible with version 3.x. This export automatically performs several transformations to ensure your data and schema work correctly in version 3.x.
The V3 Compatible Export automatically handles the following transformations:
duration::from::* → duration::from_*string::is::* → string::is_*type::is::* → type::is_*time::is::* → time::is_*time::from::* → time::from_*rand::guid() → rand::id()type::thing → type::recordNoteSee the complete function mapping table below for the full list of function name updates.
SEARCH ANALYZER → FULLTEXT ANALYZER: Index definitions using SEARCH ANALYZER are automatically converted to FULLTEXT ANALYZERLET keyword where required for parameter declarationsMTREE → HNSW conversion: Vector search indexes using the deprecated MTREE type are automatically converted to use HNSW<future> fields are automatically converted to COMPUTED fieldsSome changes cannot be automatically converted and require manual intervention:
DEFAULT <future> or CREATE ... SET field = <future>)<future> values~, !~, ?~, *~)ANALYZE statement usageAfter completing the migration diagnostics in Surrealist and resolving all flagged issues, you can export your database using the v3 compatible export feature. This will generate a .surql file that can be safely imported into SurrealDB 3.x.
In this section, we will explore the different severity levels of the migration report and the actions you need to take to migrate your data. These severity levels are as follows:
3.xSeverity: Will break
What Changed: The <future> type has been completely removed and replaced with COMPUTED fields.
Migration Actions:
VALUE <future> { expression } with COMPUTED expressionBefore (2.x):
DEFINE FIELD age ON person VALUE <future> { time::year(time::now()) - time::year(born) }; CREATE foo SET field = <future> { expression };
After (3.x):
DEFINE FIELD age ON person COMPUTED time::year(time::now()) - time::year(born); -- Futures stored in records cannot be converted - requires redesign
NoteFor futures stored in records (using
DEFAULT <future>orCREATE ... SET field = <future>), there is no direct replacement in3.x. Fixing these cases will require re-architecting your schema, as storing arbitrary queries in record data is no longer supported.
COMPUTED Restrictions:
DEFINE FIELD statementsVALUE, DEFAULT, READONLY, ASSERT, REFERENCE, FLEXIBLEExample - Nested Field Workaround:
-- 2.x version DEFINE FIELD name.full ON person VALUE <future> { name.first + ' ' + name.last }; -- 3.x version - must rename to avoid nesting DEFINE FIELD full_name ON person COMPUTED name.first + ' ' + name.last;
Severity: Will break
Action: Update all function names according to the mapping table below.
Reason for Changes:
::is:: and ::from:: → ::is_ and ::from_ (matches method syntax)thing → record (consistent terminology)rand::guid() → rand::id() (default record ID format)string::distance::osa_distance → string::distance::osa (remove redundancy)Complete Mapping Table:
| New Function Name | Previous Name |
|---|---|
duration::from_days | duration::from::days |
duration::from_hours | duration::from::hours |
duration::from_micros | duration::from::micros |
duration::from_millis | duration::from::millis |
duration::from_mins | duration::from::mins |
duration::from_nanos | duration::from::nanos |
duration::from_secs | duration::from::secs |
duration::from_weeks | duration::from::weeks |
geo::is_valid | geo::is::valid |
| rand |
| rand::bool |
| rand::duration |
| rand::enum |
| rand::float |
| rand::guid |
| rand::int |
| rand::string |
| rand::time |
| rand::ulid |
| rand::uuid::v4 |
| rand::uuid::v7 |
| rand::uuid |
string::distance::osa | string::distance::osa_distance |
string::is_alphanum | string::is::alphanum |
string::is_alpha | string::is::alpha |
string::is_ascii | string::is::ascii |
string::is_datetime | string::is::datetime |
string::is_domain | string::is::domain |
string::is_email | string::is::email |
string::is_hexadecimal | string::is::hexadecimal |
string::is_ip | string::is::ip |
string::is_ipv4 | string::is::ipv4 |
string::is_ipv6 | string::is::ipv6 |
string::is_latitude | string::is::latitude |
string::is_longitude | string::is::longitude |
string::is_numeric | string::is::numeric |
string::is_record | string::is::record |
string::is_semver | string::is::semver |
string::is_url | string::is::url |
string::is_ulid | string::is::ulid |
string::is_uuid | string::is::uuid |
time::is_leap_year | time::is::leap_year |
time::from_nanos | time::from::nanos |
time::from_micros | time::from::micros |
time::from_millis | time::from::millis |
time::from_secs | time::from::secs |
time::from_ulid | time::from::ulid |
time::from_unix | time::from::unix |
time::from_uuid | time::from::uuid |
type::is_array | type::is::array |
type::is_bool | type::is::bool |
type::is_bytes | type::is::bytes |
type::is_collection | type::is::collection |
type::is_datetime | type::is::datetime |
type::is_decimal | type::is::decimal |
type::is_duration | type::is::duration |
type::is_float | type::is::float |
type::is_geometry | type::is::geometry |
type::is_int | type::is::int |
type::is_line | type::is::line |
type::is_none | type::is::none |
type::is_null | type::is::null |
type::is_multiline | type::is::multiline |
type::is_multipoint | type::is::multipoint |
type::is_multipolygon | type::is::multipolygon |
type::is_number | type::is::number |
type::is_object | type::is::object |
type::is_point | type::is::point |
type::is_polygon | type::is::polygon |
type::is_range | type::is::range |
type::is_record | type::is::record |
type::is_string | type::is::string |
type::is_uuid | type::is::uuid |
type::record | type::thing |
Learn more about the database functions in the SurrealQL functions documentation.
Severity: Will break
What Changed: Arguments changed from (offset, count) to (start, end) or accepting a range.
Action: Change all array::range calls to use start/end bounds instead of offset/count.
Before (2.x):
array::range(0, 5) // returns [0,1,2,3,4] array::range(-1, 5) // returns [-1,0,1,2,3] array::range(-5, 5) // returns [-5,-4,-3,-2,-1]
After (3.x):
array::range(0, 5) // returns [0,1,2,3,4] array::range(-1, 5) // returns [-1,0,1,2,3,4] ← different! array::range(-5, 5) // returns [-5,-4,-3,-2,-1,0,1,2,3,4] ← different! array::range(0..=1) // returns [0,1]
Migration Formula:
array::range(offset, count)array::range(offset, offset + count)Severity: Will break
What Changed: Parameter declarations now require LET keyword.
Action: Add LET before all parameter declarations.
Before (2.x):
$val = 10; // This was allowed
After (3.x):
LET $val = 10; // LET is now required
Error Message:
Parse error: Parameter declarations without `let` are deprecated. Replace with `let $val = ...` to keep the previous behavior.
Severity: Will break
What Changed: Using both GROUP and SPLIT in the same query is no longer allowed.
Action: Remove SPLIT from any query which also had a GROUP clause as it’s inclusion had no effect in 2.x. If the use of both a SPLIT and a GROUP is required put on of the two clause in a subquery.
Before (2.x):
SELECT age, emails FROM user SPLIT emails GROUP BY age; // SPLIT had no effect.
After (3.x) - Option 1 (split then group):
SELECT age, emails FROM (SELECT * FROM user SPLIT emails) GROUP BY age;
After (3.x) - Option 2 (group then split):
SELECT * FROM (SELECT age, emails FROM user GROUP BY age, emails) SPLIT emails;
Severity: Will break
What Changed: The ~, !~, ?~, *~ operators have been removed.
Action: Replace with string::distance or string::similarity functions.
Reason: Multiple similarity algorithms now available; users should choose their own cutoff point.
Before (2.x):
"Mario" ~ "mario"; // returns true
After (3.x):
string::similarity::jaro("Mario", "mario") > 0.8; // returns true -- Create reusable function DEFINE FUNCTION fn::similar($one: string, $two: string) -> bool { string::similarity::jaro($one, $two) > 0.8 }; fn::similar("Mario", "mario"); // returns true
Available Functions:
string::similarity::jaro()string::distance::osa()Severity: Will break
Action: Replace all instances of SEARCH ANALYZER with FULLTEXT ANALYZER.
Before (2.x):
DEFINE INDEX userNameIndex ON TABLE user COLUMNS name SEARCH ANALYZER example_ascii BM25 HIGHLIGHTS;
After (3.x):
DEFINE INDEX userNameIndex ON TABLE user COLUMNS name FULLTEXT ANALYZER example_ascii BM25 HIGHLIGHTS;
Severity: Will break (if using —strict flag)
What Changed: Strictness moved from instance-level flag to database-level definition.
Action: Add STRICT to DEFINE DATABASE statements for databases that need strictness.
Before (2.x):
surreal start --strict
After (3.x):
DEFINE DATABASE mydb STRICT;
Impact: Allows different databases on the same instance to have different strictness levels.
Severity: Will break
What Changed: MTREE vector search index was deprecated in 2.x and has been removed.
Action: Use HNSW instead of MTREE in index definitions.
Before (2.x):
DEFINE INDEX vec_idx ON table FIELDS embedding MTREE DIMENSION 768;
After (3.x):
DEFINE INDEX vec_idx ON table FIELDS embedding HNSW DIMENSION 768;
Severity: Will break
What Changed: Closures can no longer be stored as part of a record.
Action: Use of closures stored inside a record will have to be removed, there is currently no new feature which can replace the stored closures.
Before (2.x):
CREATE record SET closure = |$a| $a + 1
After (3.x):
# This will now throw an error CREATE record SET closure = |$a| $a + 1
Severity: Will break
What Changed: Record references where an experimental feature in 2.x and in 3.x the syntax of record references has been significantly altered.
Action: Use experimental record references in 2.x will have to be updated manually to 3.x syntax.
ANALYZE statement.Severity: Will break
What Changed: The ANALYZE statement which could provide some statistics about full text indexes has been removed.
Action: Use the ANALYZE stastement will have to be removed.
.* BehaviorSeverity: Can break
What Changed: .* behavior changed for arrays and objects.
Breaks When: Used to dereference record IDs in arrays or get object values.
Before (2.x):
[a:1, a:2].* // returns [a:1, a:2] [a:1, a:2].*.* // dereferences records { a: 1, b: "foo" }.* // returns [1, "foo"]
After (3.x):
[a:1, a:2].* // dereferences records directly { a: 1, b: "foo" }.* // returns { a: 1, b: "foo" }
Migration:
.*.* with .*.* with object::values() functionSeverity: Can break
What Changed: Field idioms on arrays now work on individual elements instead of the whole array.
Breaks When: Field idiom on array of objects is followed by another idiom part.
Before (2.x):
[{ a: ["a","b"]}, {a: [1,2]}].a[0] // returns ["a","b"] // Evaluated as: ([...].a)[0]
After (3.x):
[{ a: ["a","b"]}, {a: [1,2]}].a[0] // returns ["a",1] // Evaluated on each element: [(...).a[0], (...).a[0]]
Migration: Swap idiom parts if old behavior needed.
.field[0][0].fieldSeverity: Can break
What Changed: Multiple improvements to idiom fetching behavior.
Quick Reference Table:
| Example | 2.x Output | 3.x Output |
|---|---|---|
[1, a:1].* | [1, a:1] | [1, { id: a:1 }] |
[1, a:1].*.* | [NONE, { id: a:1 }] | [NONE, { id: a:1 }] |
a:1.* | { id: a:1 } | { id: a:1 } |
{ key: 123 }.* | [123] | { key: 123 } |
a:1<-edge[0] | { id: edge:1 } | edge:1 |
[{ n: 1 }, { n: 2 }].n[0] | 1 | [NONE, NONE] |
Action: Review queries using these idioms and rewrite if necessary.
Severity: Can break
What Changed: Optional operator changed from ? to .?
Action: Replace ? with .? after optional values.
Before (2.x):
["string", NONE].map(|$val| $val?.len());
After (3.x):
["string", NONE].map(|$val| $val.?.len());
Reason: Distinguishes between ?? operator and optional chaining on option<option<value>>.
Severity: Can break
Record ID Parsing:
-- 2.x r"a:b[r"c:d"]" // unescaped " was allowed -- 3.x r"a:b[r\"c:d\"]" // must escape "
Unicode Parsing:
-- 2.x "\uD83D\uDF15" // surrogate pairs -- 3.x "\u{1F715}" // single escape sequence
Identifier Escaping: Escaped identifiers now support escape sequences like \n, \u{AB1234}.
Severity: Can break
What Changed: Set type now both deduplicates AND orders items, displays with {} instead of [].
Before (2.x):
<set>[2,3,1,1]; // returns [2, 3, 1]
After (3.x):
<set>[2,3,1,1]; // returns {1, 2, 3}
Migration Options:
VALUE $value.distinct() to DEFINE FIELD definitionSeverity: Can break
Non-Existing Tables:
-- 3.x returns errors instead of empty arrays SELECT * FROM doesnt_exist; -- Error: "The table 'doesnt_exist' does not exist"
SCHEMAFULL Tables:
DEFINE TABLE user SCHEMAFULL; DEFINE FIELD name ON user TYPE string; -- 2.x: extra fields silently filtered -- 3.x: extra fields cause error CREATE user CONTENT { name: "Billy", other: "value" }; -- Error: "Found field 'other', but no such field exists" -- Use destructuring to select only defined fields CREATE user CONTENT { name: "Billy", other: "value" }.{ name };
Severity: Can break
What Changed: Numeric values in record now have different ordering and equality when used in keys. Previously a:[1], a:[1f] and a:[1dec] were all different record-ids and could have different records. With 3.0 numeric values in record-id’s are now ordered by their numeric value meaning the a:[1], a:[1f] and a:[1dec] are the same key. Furthermore a:[0f] now is ordered before a:[1].
Breaks When: Code depends on different numeric types resulting in different record-ids.
Before (2.x):
CREATE t:[1]; CREATE t:[1f]; SELECT * FROM t; // returns `[{ id: [1] }, { id: [1f] }]`
After (3.x):
CREATE t:[1]; CREATE t:[1f]; // returns an error, record with key `t:[1]` alread exisits. SELECT * FROM t; // returns `[{ id: [1] }]`
Severity: Unlikely break
What Changed: Returns NaN instead of NONE for negative numbers.
Action: Change checks from NONE to NaN.
-- 2.x math::sqrt(-1); // returns NONE -- 3.x math::sqrt(-1); // returns NaN
Severity: Unlikely break
What Changed: Returns Infinity instead of NONE for empty arrays.
Action: Change checks from NONE to Infinity.
-- 2.x math::min([]); // returns NONE -- 3.x math::min([]); // returns Infinity
Severity: Unlikely break
What Changed: Returns -Infinity instead of NONE for empty arrays.
Action: Change checks from NONE to -Infinity.
-- 2.x math::max([]); // returns NONE -- 3.x math::max([]); // returns -Infinity
Severity: Unlikely break
What Changed: Function now consistent with && operator.
Breaks When: Relying on specific values rather than truthiness.
Before (2.x):
array::logical_and(["a"],[true]); // returns ["a"] array::logical_and([""],[false]); // returns [""] array::logical_and([true],[]); // returns [NULL]
After (3.x):
array::logical_and(["a"],[true]); // returns [true] array::logical_and([""],[false]); // returns [""] array::logical_and([true],[]); // returns [NONE]
Action: Update if relying on specific return values; no change needed if only checking truthiness.
Severity: Unlikely break
What Changed: Function now consistent with || operator.
Breaks When: Relying on specific values rather than truthiness.
Before (2.x):
array::logical_or(["a"],[true]); // returns ["a"] array::logical_or([""],[false]); // returns [""] array::logical_or([],[false]); // returns [NULL]
After (3.x):
array::logical_or(["a"],[true]); // returns ["a"] array::logical_or([""],[false]); // returns [false] array::logical_or([false],[]); // returns [NONE]
Action: Update if relying on specific return values; no change needed if only checking truthiness.
Severity: Unlikely break
What Changed: Mocks now return arrays instead of special mock type.
Breaks When: Code depends on the specific mock type being returned.
Before (2.x):
|a:1..2|; // returns |a:1..2| (mock type) type::is_array(|a:1..2|); // returns false
After (3.x):
|a:1..=2|; // returns [a:1, a:2] (array) type::is_array(|a:1..=2|); // returns true
NoteMock ranges are no longer inclusive by default - use
..=for inclusive ranges.
Id field special behavior.Severity: Unlikely break
What Changed: Special behavior regarding .id idioms is removed. Before 3.0 .id idioms followed by another idiom expression would return the record-id key. After 3.0 the .id behaves like any other .field idiom.
Breaks When: Code depends the special behavior of that .id idioms had.
Before (2.x):
record:{ key_field: "value" }.id.key_field // returns "value"
After (3.x):
record:{ key_field: "value" }.id.key_field // returns whatever value is at .id.key_field in the record with key `record:{ key_field: "value" }` record:{ key_field: "value" }.id().key_field // returns "value"