Version 3 of the Python SDK supports SurrealDB 4.0 and changes what several common calls return. This guide lists each change that can break a project written for version 2, with the 2.x code and the 3.x code that replaces it. The sections are in order of how much code each change usually affects, so work through them from the top.
Upgrade the SDK before you upgrade the server to SurrealDB 4.0. Version 2 sends every datetime as text, which SurrealDB 4.0 refuses, so any write that holds a datetime fails with ValidationError: CBOR tag 0 (a datetime as text) is not accepted: send tag 12 [seconds, nanos]. Version 3 works with SurrealDB 2.x, 3.x and 4.x, so you can move to it on the server you run today.
What changed
A plain string is always a table name. Use
RecordIDorStringRecordIdto target a record.On a blocking connection,
select(),delete()andquery()return a builder that sends nothing until you call.execute().query()returns one result for each statement, where 2.x returned the first statement's result only.select()on a single record returns the record orNone, where 2.x returned a list.merge(),patch()andinsert_relation()are now clauses and options on the other methods.live()returns a subscription that you can iterate, and each notification is the full notification object.NULLreads asNull, sets read asSurrealSet, and datetimes keep their nanoseconds.Every request times out after 30 seconds by default, also over a WebSocket.
Transport failures raise SDK errors rather than errors from
requestsoraiohttp.The embedded engine is an optional extra, and it runs SurrealDB 4.0.
1. Install version 3
Upgrade the package:
pip install --upgrade "surrealdb>=3.0.0"With uv:
uv add "surrealdb>=3.0.0"Version 3 still needs Python 3.10 or later. It needs websockets 15.0 or later and aiohttp 3.14.0 or later, so raise any lower pin on those packages in your own requirements.
Some features are now optional extras:
| Extra | Install | Use it for |
|---|---|---|
embedded | pip install "surrealdb[embedded]" | mem://, memory, file:// and surrealkv:// connections. In 2.x the engine was part of the main package; without the extra, an embedded URL raises UnsupportedEngineError. |
pydantic | pip install "surrealdb[pydantic]" | RecordID and StringRecordId fields in pydantic models. |
memory | pip install "surrealdb[memory]" | The Agent Memory client, imported as from surrealdb.memory import Memory. |
2. Replace plain strings that name a record
In 2.x, the SDK inserted a string target into the SurrealQL text as it was, so the server read "person:tobie" as a record and "person:1..10" as a range of records. In 3.x a plain string is always a table name, and the SDK sends it as a value rather than as query text.
A write does not fail when it gets a string that names a record. db.create("person:tobie", data) creates a record with a random id in a new table that is named person:tobie. On SurrealDB 3.x and 4.0, a read of the same string raises NotFoundError: The table 'person:tobie' does not exist, and on SurrealDB 2.x it returns an empty list.
Target a record with a RecordID, and a range of records with a RecordID whose id is a Range:
person = db.select("person:tobie")
db.create("person:jaime", {"name": "Jaime"})
db.delete("person:1..10")from surrealdb import BoundExcluded, BoundIncluded, Range, RecordID
person = db.select(RecordID("person", "tobie")).execute()
db.create(RecordID("person", "jaime"), {"name": "Jaime"})
ids = Range(BoundIncluded(1), BoundExcluded(10))
db.delete(RecordID("person", ids)).execute()When a record id arrives as text, for example from a URL or a configuration file, wrap it in a StringRecordId. The SDK sends the text for the server to parse, so every record id form the server accepts works. RecordID.parse() also reads text, but it always makes a string id, so it misses a record whose id is a number:
from surrealdb import RecordID, StringRecordId
# Finds the record post:1
db.select(StringRecordId("post:1")).execute()
# Looks for the string id "1", finds nothing, and returns None
db.select(RecordID.parse("post:1")).execute()To find the calls to change, search the project for a CRUD method whose first argument is a string literal that holds a colon:
grep -rnE "\.(select|create|update|upsert|delete|merge|patch)\([[:space:]]*f?['\"][^'\"]*:" --include='*.py' .The search does not find a variable that holds such a string, so also check each place where a record target comes from user input or configuration.
Two other methods are strict about strings. insert() accepts a plain string only when it is a simple identifier, so a table name such as my-table needs Table("my-table"). The new relate() method refuses a plain string for either end and raises TypeError.
3. Run blocking builders
In 2.x, every method on a blocking connection sent its request at once. In 3.x, select(), delete() and query() return a builder, and a builder sends nothing until you run it:
people = db.select("person")
db.delete(RecordID("person", "tobie"))
db.query("DELETE person WHERE active = false")people = db.select("person").execute()
db.delete(RecordID("person", "tobie")).execute()
db.query("DELETE person WHERE active = false").execute()The rule on a blocking connection is that a write you give its data runs at once, and everything else returns a builder:
| Runs at once and returns the result | Returns a builder |
|---|---|
create(target, data) | select(target) |
update(target, data) | delete(target) |
upsert(target, data) | query(sql, vars) |
insert(table, data) | create(target), update(target), upsert(target) and insert(table) without data |
Run a builder with .execute(), with a data clause such as .merge(data), or by iterating it. A builder never runs because you call len(), bool() or == on it, and len() or an index raises a TypeError that names .execute().
In 2.x, db.create(target, None) created a record with no content. In 3.x, None is sent as the content and the server refuses it with ValidationError: Cannot use NONE in a CONTENT clause. Leave the data out and run the builder: db.create(Table("person")).execute().
A builder that you forget to run does not raise an error. When it is garbage-collected without running, it emits a RuntimeWarning that names the call and the line. Run your test suite after the upgrade and look for this warning, which pytest shows in its warnings summary:
RuntimeWarning: delete() was called at app.py:42 but its builder was never executed (.execute(), .rows(), or iterating it), so nothing was sent to the server.On an async connection a builder is awaitable, so await db.select(...) and await db.query(...) still send the request. Only the shape of the results changes, as the next two sections describe.
4. Read query() results as one entry per statement
In 2.x, query() returned the result of the first statement and dropped the rest. In 3.x it returns a list with one entry for each statement, also when the query holds one statement. .first() returns the result of the first statement:
| Call | 2.x | 3.x, .execute() or await | 3.x, .first() |
|---|---|---|---|
query("RETURN 1") | 1 | [1] | 1 |
query("RETURN 1; RETURN 2") | 1 | [1, 2] | 1 |
query("SELECT * FROM person") | [{...}, {...}] | [[{...}, {...}]] | [{...}, {...}] |
Code that ran one statement and used its result needs .first():
people = db.query(
"SELECT * FROM person WHERE age > $age",
{"age": 18},
)people = db.query(
"SELECT * FROM person WHERE age > $age",
{"age": 18},
).first()On an async connection, write await db.query(...).first(). To read every statement, unpack the list: people, count = db.query("SELECT * FROM person; SELECT count() FROM person GROUP ALL").execute().
The keyword for variables on query_raw() changed from params to vars, which matches query().
5. Read single records without a list
In 2.x, select() returned a list, also for one record. In 3.x a RecordID or StringRecordId target returns the record itself, or None when it does not exist:
| Call | 2.x | 3.x |
|---|---|---|
select(RecordID(...)) | [record] or [] | record or None |
update(RecordID(...), data) for a record that does not exist | [] | None |
delete(RecordID(...)) | record | record, or None when it does not exist |
select(Table(...)) or select("person") | a list | a list |
rows = db.select(RecordID("person", "tobie"))
person = rows[0] if rows else Noneperson = db.select(RecordID("person", "tobie")).execute()A RecordID whose id is a Range targets many records, so it returns a list.
6. Replace removed methods
| 2.x | 3.x |
|---|---|
db.merge(record, data) | db.update(record).merge(data) |
db.patch(record, patches) | db.update(record).patch(patches) |
db.insert_relation(table, data) | db.insert(table, data, relation=True) |
db.query_raw(sql, params=...) | db.query_raw(sql, vars=...) |
db.set_token(token) on an HTTP connection | db.authenticate(token) |
from surrealdb import TAG_RECORD_ID and the other TAG_* constants | from surrealdb.data.types.constants import TAG_RECORD_ID |
A data clause runs a blocking builder, so db.update(record).merge(data) sends the request and returns the updated record. On an async connection, write await db.update(record).merge(data). The same clauses work after create() and upsert().
like = {"in": RecordID("person", "jaime"), "out": RecordID("post", 1)}
db.merge(RecordID("person", "jaime"), {"vip": True})
db.insert_relation("likes", like)like = {"in": RecordID("person", "jaime"), "out": RecordID("post", 1)}
db.update(RecordID("person", "jaime")).merge({"vip": True})
db.insert("likes", like, relation=True)7. Update live queries
In 2.x, live() returned the UUID of the live query, and subscribe_live() yielded the changed record only. In 3.x, live() returns a subscription, and every notification is the full object, with the record under "result":
live_id = db.live("person")
for person in db.subscribe_live(live_id):
print(person["name"])with db.live("person") as people:
for change in people:
print(change["action"], change["result"]["name"])A notification holds action ("CREATE", "UPDATE" or "DELETE"), result (the record), record (its RecordID) and id (the live query id). Leaving the with block kills the live query. On an async connection, write async with await db.live("person") as people: and async for change in people:.
The subscription is a uuid.UUID, so code that passes it to subscribe_live() or kill() still works. Change only the code that reads each notification: notification["result"] holds what 2.x yielded.
8. Check NULL, sets, datetimes and other values
Some values now come back as different types, and some values that 2.x could not send now work. See Value types for the full mapping.
NULL is
Null. In 2.x,NULLandNONEboth read asNone, andNoneis sent asNONE, so a record that was read and written back lost everyNULLfield. In 3.x aNULLfield reads assurrealdb.Null, which is falsy and is not equal toNone. Code that tests a field that can holdNULLwithis Nonemust also test forNull.from surrealdb import Null nickname = person["nickname"] if nickname is None or nickname is Null: ...Sets are
SurrealSet. Version 2 could not read a set from SurrealDB 3.x or 4.0, and raisedBufferError. Version 3 reads a set as aSurrealSet, which is alistsubclass that is written back as a set. A Pythonsetis still sent as a set.Datetimes are
PreciseDatetime. A datetime reads as aPreciseDatetime, which is adatetimesubclass that keeps the nanoseconds adatetimecannot hold, soisinstance(value, datetime)is still true. A naivedatetimeis sent as UTC.Dates, times and durations can be sent. Version 2 sent a
Durationand adatetime.datein forms that SurrealDB 3.3 and 4.0 refuse, and adatetime.timeraisedTypeError. Version 3 sends all three. Against SurrealDB 4.0 adateand a naivetimebecome the newdateandtimetypes; against earlier servers they become a datetime at midnight UTC and a duration.Duration.parse()is strict. It raisesInvalidDurationErrorfor text that the server refuses, such as"1.5s","-1s"or"1H", where 2.x returned a different duration.Integers must fit in 64 bits. An
intoutside the signed 64-bit range raisesValueErrorbefore anything is sent. Version 2 sent2**63to SurrealDB 3.x, which stored it as-9223372036854775808. Use aDecimal, or aU64against SurrealDB 4.0.RecordIDandTablecheck their arguments. A table name must be astr, and an id must be astr,int,uuid.UUID,list,tuple,dictorRange, soTable(None)andRecordID("person", 1.5)raiseTypeError.RecordID(Table("person"), 1)also raises: pass the table name.Values the SDK cannot encode raise
TypeError, where 2.x raisedBufferError. Adatetime.timedeltais one of them: use aDuration.
9. Allow for the request timeout
In 2.x, a request over a WebSocket waited for its reply for as long as the server took. In 3.x, every request waits for at most 30 seconds by default, on every transport, and then raises TransportTimeoutError:
TransportTimeoutError: timed out while query: no answer within 30sThe SDK stops waiting, but the server does not stop the statement, so a write that times out can still complete on the server. For a connection that runs long queries, set the default with timeout=, or change it for one block of code with timeout(). None waits indefinitely, which is what 2.x did over a WebSocket:
db = Surreal("ws://localhost:8000", timeout=120)
with db.timeout(None):
report = db.query("SELECT * FROM order").execute()Over HTTP, 2.x already stopped waiting after 30 seconds, so HTTP code sees no change.
10. Update error handling
Every error the SDK raises is a SurrealError, and the errors that describe a failed transport now have their own branch. See Error handling for the full hierarchy.
| Failure | 2.x raised | 3.x raises |
|---|---|---|
| A non-2xx HTTP response | requests.exceptions.HTTPError or aiohttp.ClientResponseError | HttpStatusError, with .status, .body and .url |
| A host that does not answer | requests.exceptions.ConnectionError, aiohttp.ClientConnectorError or ConnectionRefusedError | ConnectionUnavailableError |
| A request that timed out | an error from the transport library | TransportTimeoutError |
| A response the SDK cannot decode | BufferError | UnexpectedResponseError |
A URL with an unknown scheme, such as ftp:// | ValueError | UnsupportedEngineError |
| A URL that cannot be parsed, such as a bad port | ValueError | InvalidUrlError |
| A server other than SurrealDB 2.x, 3.x or 4.x | nothing, the connection opened | UnsupportedVersionError when the connection opens |
| A feature the server is too old for, such as sessions on SurrealDB 2.x | an error from the server | UnavailableFeatureError, a subclass of UnsupportedFeatureError, before the request is sent |
ConnectionUnavailableError, TransportTimeoutError and HttpStatusError share the new base class TransportError. A TransportError can succeed if you retry it, and a ServerError describes a decision the server made, which fails again. Each TransportError keeps the original exception as __cause__.
To connect to a server outside the supported range, pass version_check=False to Surreal() or AsyncSurreal(). QueryError.timeout now returns the Duration that the statement exceeded, where it always returned None before.
11. Move embedded databases
The embedded engine is now the surrealdb-embedded package, which the embedded extra installs. It runs SurrealDB 4.0, where the 2.x SDK ran SurrealDB 2.3, so the changes to SurrealQL between those releases apply to your queries. The server guides list them: 2.x to 3.x and 3.x to 4.0.
Sessions, client-side transactions and live queries are still not available on an embedded connection, and the factory options such as timeout= and auth= have no effect there. See Embedded databases.
Remove sign-in calls
An embedded connection is allowed to do everything and has no root user, so a signin() call on it raises NotAllowedError. Remove those calls and call use() directly:
from surrealdb import Surreal
with Surreal("mem://") as db:
db.use("app", "main")
db.create("person", {"name": "Tobie"})Move data on disk
The SurrealDB 4.0 engine cannot open a file:// or surrealkv:// directory that the 2.x SDK wrote. Move the data the way a SurrealDB 2.x server moves to 3.x: make a 3.x-compatible export of it, with SurrealDB Studio or with the surreal v2 export --v3 command, as the 2.x to 3.x guide describes. Then load the export into a new directory. An embedded connection has no import_() method, so run the export text through query():
from pathlib import Path
from surrealdb import Surreal
with Surreal("surrealkv://data/app-v3") as db:
db.use("app", "main")
db.query(Path("v2_exported_for_v3.surql").read_text()).execute()Loading into a new directory leaves the old one as it was, so keep the old one until you have checked the new one. In-memory databases need no move.
Method mapping
The table lists each 2.x method with what replaces it. Calls that are not listed work as before.
| 2.x | 3.x |
|---|---|
Surreal(url), AsyncSurreal(url) | Unchanged, with new keyword options such as timeout=, reconnect= and auth= |
db.use(namespace, database) | Unchanged. db.use(database="other") changes one of them |
db.select(target) (blocking) | db.select(target).execute() |
db.select("table:id") | db.select(RecordID("table", id)).execute() or db.select(StringRecordId("table:id")).execute() |
db.create("table:id", data) | db.create(RecordID("table", id), data) |
db.update(target, data) | Unchanged for a RecordID or a table. Returns None for a missing record |
db.merge(target, data) | db.update(target).merge(data) |
db.patch(target, patches) | db.update(target).patch(patches) |
db.delete(target) (blocking) | db.delete(target).execute() |
db.insert(table, data) | Unchanged. Use Table(name) for a name that is not a simple identifier |
db.insert_relation(table, data) | db.insert(table, data, relation=True) |
db.query(sql, vars) (blocking) | db.query(sql, vars).first() for the first statement, .execute() for all |
await db.query(sql, vars) | await db.query(sql, vars).first() for the first statement, await db.query(sql, vars) for all |
db.query_raw(sql, params=...) | db.query_raw(sql, vars=...) |
db.live(table) | Unchanged. Returns a subscription that is also the UUID |
db.subscribe_live(live_id) yielding records | Yields notifications: read notification["result"] |
session.begin_transaction() | Unchanged. db.begin_transaction() is new on the connection |
db.set_token(token) (HTTP) | db.authenticate(token) |
New in version 3
These additions need no changes to existing code:
Connection options for timeouts, reconnection and keepalive, with connection events and a server version check.
Sign-in with
auth=, automatic token renewal, andrefresh(),revoke()andreset().relate(),fields=,into=and builder modifiers such as.where(),.limit()and.output().Streaming results from SurrealDB 3.3 and later.
Transactions as context managers, also on the connection itself.
Import and export and files.
The SurrealDB 4.0 value types: sized numbers, dates, times, timestamps, intervals, big decimals and regexes.
Migration checklist
Install
surrealdb3.0.0 or later, and theembeddedextra if you use embedded databases.Replace every string that names a record with a
RecordIDor aStringRecordId.Add
.execute()to blockingselect(),delete()andquery()calls, or.first()where the code used the result of one statement.Remove the list unwrapping around single-record
select()results.Replace
merge(),patch(),insert_relation()andset_token(), and renameparams=tovars=onquery_raw().Read
notification["result"]in live query loops.Check the code that tests for
Noneon fields that can holdNULL.Set
timeout=on connections that run requests longer than 30 seconds.Replace
exceptclauses that namerequests,aiohttporValueErrorfor connection failures with the SDK errors.Remove
signin()calls on embedded connections, and move on-disk embedded data with an export.Run the test suite and fix every
RuntimeWarningabout a builder that was never executed.Upgrade the server to SurrealDB 4.0 only after the SDK upgrade is deployed.
Learn more
Python SDK for the full documentation of version 3
Surrealreference for every method and optionUpgrading from 3.x to 4.0 for the server side of a SurrealDB 4.0 upgrade
Changelog for every change in the 3.0.0 release