The Python SDK converts every value it sends to SurrealDB into a SurrealDB type, and every value it reads back into a Python type. Standard types such as str, int, dict and datetime map directly. For the SurrealDB types that Python does not have, such as record IDs, durations and geometry, the SDK has classes of its own. This page explains the mapping in both directions, and the cases where a value reads back as a different type from the one you sent.
API references
Class | Description |
|---|---|
RecordID(table_name, identifier) | One record, by its table name and ID |
StringRecordId(value) | One record, as SurrealQL text that the server parses |
Table(table_name) | A table, as a target or a query variable |
Null | The SurrealDB NULL, which is a different value from None |
PreciseDatetime, Datetime(dt) | A datetime with nanoseconds, and ISO 8601 text to send as a datetime |
Duration(elapsed) | A length of time in nanoseconds |
SurrealSet(iterable) | A set, which the SDK writes back as a set |
Range(begin, end) | A span of values or of record IDs |
GeometryPoint(longitude, latitude) | A point, and six other classes for geospatial data |
File(bucket, key) | A reference to a file in a bucket |
I8to U64, F16, F32, BigDecimal | The SurrealDB 4.0 sized numbers and big decimals |
PlainDate, PlainTime, Timestamp, Interval | The SurrealDB 4.0 dates, times, timestamps and intervals |
Regex(pattern) | The SurrealDB 4.0 regex |
How values convert
| Python value | SurrealDB type | Read back as |
|---|---|---|
None | NONE | None |
Null | NULL | Null |
bool, int, float, str, bytes | bool, int, float, string, bytes | The same type |
uuid.UUID | uuid | uuid.UUID |
decimal.Decimal | decimal | Decimal |
dict, list | object, array | dict, list |
set, frozenset, SurrealSet | set | SurrealSet |
datetime.datetime, Datetime | datetime | PreciseDatetime |
Duration | duration | Duration |
RecordID, StringRecordId | record | RecordID |
Table | table | Table |
Range | range | Range |
GeometryPoint and the other geometry classes | geometry | The same class |
File | file | File |
datetime.timedelta, re.Pattern | Not sent: the SDK raises TypeError |
The SurrealDB 4.0 types, such as sized numbers and dates, are in SurrealDB 4.0 types. The SDK speaks one of two CBOR formats: cbor.2 with SurrealDB 4.0 servers, and cbor with earlier servers. db.wire_format tells you which one a connection uses, and the two formats differ only for the SurrealDB 4.0 types.
Record IDs and tables
A plain str given as a target is always a table name, even when it holds a :. db.select("person:tobie") selects from a table that is named person:tobie, and db.create("person:tobie", data) creates a record in such a table. To target one record, use a RecordID, or a StringRecordId for a record ID that you have as SurrealQL text. In the 2.x SDK such a string named a record, so the migration guide lists the code to change.
from surrealdb import RecordID, StringRecordId, Surreal, Table
with Surreal("ws://localhost:8000") as db:
db.signin({"username": "root", "password": "secret"})
db.use("surrealdb", "docs")
db.create(RecordID("person", "tobie"), {"name": "Tobie"})
print(db.select(RecordID("person", "tobie")).execute())
print(db.select(StringRecordId("person:tobie")).execute())
print(db.select(Table("person")).execute()){'id': RecordID(table_name=person, record_id='tobie'), 'name': 'Tobie'}
{'id': RecordID(table_name=person, record_id='tobie'), 'name': 'Tobie'}
[{'id': RecordID(table_name=person, record_id='tobie'), 'name': 'Tobie'}]The type of the ID is part of the record ID, so RecordID("post", 42) and RecordID("post", "42") name two different records. The RecordID constructor checks its arguments and raises TypeError for an ID that SurrealDB does not accept, such as a float or None. To have the server generate an ID, pass the table instead. The rest of the examples on this page use the synchronous form. On an asynchronous connection, await each call, which makes .execute() optional.
NONE and NULL
SurrealDB has two empty values. NONE means that a field is not there, and NULL means that the field holds a null value. The SDK sends None as NONE and the Null object as NULL, and reads each one back the same way.
from surrealdb import Null, RecordID, Surreal
with Surreal("ws://localhost:8000") as db:
db.signin({"username": "root", "password": "secret"})
db.use("surrealdb", "docs")
tobie = RecordID("person", "tobie")
db.create(tobie, {"name": "Tobie", "nickname": Null})
person = db.select(tobie).execute()
print(person["nickname"], person["nickname"] is None)
person["name"] = "Tobie Morgan"
print(db.update(tobie, person)["nickname"])Null False
NullBecause a NULL field reads as Null, a record that you read and write back keeps its NULL fields. A field that you set to None is removed. Null is falsy, like None, but it is not equal to None, so a test with is None does not find a NULL field. Where a field can hold either value, test for both. In a schemafull table, a field of type option<string> accepts NONE and refuses NULL, and a field of type string | null accepts NULL.
Datetimes
Send a datetime.datetime. An aware value is converted to UTC, and a naive value is read as UTC rather than as the local time of the machine. Every datetime reads back as a PreciseDatetime, a datetime subclass that also keeps the nanoseconds below one microsecond in its nanosecond attribute. A datetime that you read and write back therefore keeps all nine fractional digits. To send ISO 8601 text as a datetime, wrap it in a Datetime.
import datetime as dt
from surrealdb import Datetime, Surreal
with Surreal("ws://localhost:8000") as db:
db.signin({"username": "root", "password": "secret"})
db.use("surrealdb", "docs")
stamp = db.query("RETURN d'2024-02-29T12:00:00.123456789Z'").first()
print(type(stamp).__name__, stamp.nanosecond)
sql = "RETURN <string> $at"
naive = dt.datetime(2025, 6, 1, 14, 0)
text = Datetime("2025-06-01T14:00:00+02:00")
print(db.query(sql, {"at": naive}).first())
print(db.query(sql, {"at": text}).first())PreciseDatetime 789
2025-06-01T14:00:00Z
2025-06-01T12:00:00ZThe result of arithmetic on a PreciseDatetime has nanosecond set to 0, because the calculation is done by datetime. Python's datetime holds the years 1 to 9999, so reading a datetime outside that range raises UnexpectedResponseError.
Durations
A Duration holds a length of time in nanoseconds, and maps to the SurrealDB duration type in both directions. Duration.parse() reads SurrealQL duration text such as "1h30m", or a whole number of seconds. Its unit properties, such as hours and minutes, give whole units as an int, rounded down. The SDK does not send a datetime.timedelta and raises TypeError for one, so convert it first.
import datetime as dt
from surrealdb import Duration
length = Duration.parse("1h30m")
print(length.hours, length.minutes, length)
timeout = dt.timedelta(seconds=90)
print(Duration((timeout // dt.timedelta(microseconds=1)) * 1000))1 90 1h30m
1m30sSets
SurrealDB 3.0 and later have a set type, which holds each value once, in an order that the server chooses. A Python set or frozenset is sent as a set, and every set reads back as a SurrealSet, a list subclass that the SDK sends back as a set.
from surrealdb import RecordID, Surreal
with Surreal("ws://localhost:8000") as db:
db.signin({"username": "root", "password": "secret"})
db.use("surrealdb", "docs")
post = db.create(RecordID("post", 1), {"tags": {"news", "intro"}})
print(post["tags"], post["tags"] == ["intro", "news"])SurrealSet(['intro', 'news']) TrueA list is always sent as an array. On a schemaless table, writing a list over a set field turns the field into an array, which keeps duplicates from then on. SurrealDB 2.x has no set type and refuses one, so send a list to a 2.x server.
Numbers and decimals
SurrealDB stores an int as a signed 64-bit integer, and the SDK raises ValueError before it sends an int outside that range. A decimal.Decimal maps to the decimal type, which holds up to 28 digits after the decimal point. The server rounds a value with more digits, and SurrealDB 3.0 and later drop trailing zeros, so Decimal("19.90") reads back as Decimal('19.9'). For more digits, or to keep the scale, SurrealDB 4.0 has bigdecimal. The Sized numbers and BigDecimal page covers these limits, the BigDecimal class and the sized number classes.
Ranges
A Range holds a span between two bounds. Each bound is a BoundIncluded or a BoundExcluded, or None for an open end, and all three classes are exported from surrealdb. As the ID of a RecordID, a range targets every record whose ID is in it. As a query variable, it works with IN.
from surrealdb import BoundExcluded, BoundIncluded, Range, RecordID, Surreal
with Surreal("ws://localhost:8000") as db:
db.signin({"username": "root", "password": "secret"})
db.use("surrealdb", "docs")
for n in range(1, 5):
db.create(RecordID("person", n), {"name": f"Person {n}"})
span = Range(BoundIncluded(1), BoundExcluded(3))
people = db.select(RecordID("person", span)).execute()
print([person["name"] for person in people])['Person 1', 'Person 2']Geometry
The seven geometry classes, from GeometryPoint to GeometryCollection, are exported from surrealdb and map to the geometry type. Their constructors take their parts as separate arguments, as in GeometryLine(start, end), not as a list. A point takes its longitude first, as in GeoJSON.
from surrealdb import GeometryPoint, Surreal
with Surreal("ws://localhost:8000") as db:
db.signin({"username": "root", "password": "secret"})
db.use("surrealdb", "docs")
london = GeometryPoint(-0.1278, 51.5074)
edinburgh = GeometryPoint(-3.1883, 55.9533)
sql = "RETURN geo::distance($a, $b)"
print(db.query(sql, {"a": london, "b": edinburgh}).first())533652.9374508183Files
A File is a reference to a file in a bucket: a bucket name and a key. It holds no contents, and it maps to the file type, so a record can hold one in a field. To read and write the contents, use the db.files helpers described in Files.
SurrealDB 4.0 types
SurrealDB 4.0 adds sized numbers such as u8 and f32, bigdecimal, and the calendar types date, time, timestamp and interval, as well as regex. The SDK has a class for each. Over cbor.2, each class is sent as its own type and reads back as the same class. Earlier servers have none of these types, so over plain cbor the SDK sends the nearest type that the server has, and refuses a regex.
| Python | Over cbor.2 (SurrealDB 4.0) | Over cbor (earlier servers) |
|---|---|---|
I8, U8, I16, U16, I32, U32 | i8 to u32, read back as the same class | int, read back as int |
U64 | u64, read back as U64 | int, or decimal above 9223372036854775807, read back as int or Decimal |
F16, F32 | f16, f32, read back as the same class | float, read back as float |
BigDecimal | bigdecimal, read back as BigDecimal | decimal when the value fits one, read back as Decimal; otherwise text, read back as str |
PlainDate, datetime.date | date, read back as PlainDate | A datetime at midnight UTC, read back as PreciseDatetime |
PlainTime, naive datetime.time | time, read back as PlainTime | A duration since midnight, read back as Duration |
Timestamp | timestamp, read back as Timestamp | A datetime, with the wall-clock time read as UTC, read back as PreciseDatetime |
Interval | interval, read back as Interval | Its ISO 8601 text, read back as str |
Regex | regex, read back as Regex | Not sent: the SDK raises ValueError |
A datetime.date is sent as a date and reads back as a PlainDate, whose to_date() method gives a datetime.date again. A datetime.datetime is still sent as a datetime.
from surrealdb import F32, U8, Surreal
with Surreal("ws://localhost:8000") as db:
db.signin({"username": "root", "password": "secret"})
db.use("surrealdb", "docs")
print(db.wire_format)
values = {"age": U8(30), "score": F32(0.1)}
print(db.query("RETURN [$age, $score]", values).first())cbor.2
[U8(30), F32(0.10000000149011612)]Against an earlier server, the same code prints cbor and [30, 0.10000000149011612].
Pydantic models
RecordID and StringRecordId can be field types in a pydantic model when pydantic is installed, for example with pip install "surrealdb[pydantic]". A field accepts an instance or table:id text, and dumps as text in JSON mode. Pass into= to a CRUD method to read records straight into a model.
from pydantic import BaseModel
from surrealdb import RecordID, Surreal
class Person(BaseModel):
id: RecordID
name: str
with Surreal("ws://localhost:8000") as db:
db.signin({"username": "root", "password": "secret"})
db.use("surrealdb", "docs")
db.create(RecordID("person", "tobie"), {"name": "Tobie"})
person = db.select(RecordID("person", "tobie"), into=Person).execute()
print(person.name, person.model_dump(mode="json"))Tobie {'id': 'person:tobie', 'name': 'Tobie'}Most other SDK classes, such as NullType, SurrealSet, Table and File, have no pydantic schema, so a model that uses one as a field type needs arbitrary_types_allowed=True. Two details change the data that you write back from a model:
A field typed
str | NonerefusesNull, so addNullTypeto the type of a field that can holdNULL.model_dump()turns aSurrealSetinto a plainlist, which is sent as an array. Wrap the value inSurrealSetagain before you write it.
Learn more
Python values for every value class and the full type mapping
Python types for the
Valueunion,RecordIdTypeand other type aliasesData manipulation for CRUD methods that take these values
Data types in SurrealQL