Skip to content

Concepts

Value types

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.

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

I8

to

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

Python valueSurrealDB typeRead back as
NoneNONENone
NullNULLNull
bool, int, float, str, bytesbool, int, float, string, bytesThe same type
uuid.UUIDuuiduuid.UUID
decimal.DecimaldecimalDecimal
dict, listobject, arraydict, list
set, frozenset, SurrealSetsetSurrealSet
datetime.datetime, DatetimedatetimePreciseDatetime
DurationdurationDuration
RecordID, StringRecordIdrecordRecordID
TabletableTable
RangerangeRange
GeometryPoint and the other geometry classesgeometryThe same class
FilefileFile
datetime.timedelta, re.PatternNot 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.

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())
Output
{'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.

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"])
Output
Null False
Null

Because 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.

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())
Output
PreciseDatetime 789
2025-06-01T14:00:00Z
2025-06-01T12:00:00Z

The 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.

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))
Output
1 90 1h30m
1m30s

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"])
Output
SurrealSet(['intro', 'news']) True

A 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.

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.

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])
Output
['Person 1', 'Person 2']

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())
Output
533652.9374508183

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 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.

PythonOver cbor.2 (SurrealDB 4.0)Over cbor (earlier servers)
I8, U8, I16, U16, I32, U32i8 to u32, read back as the same classint, read back as int
U64u64, read back as U64int, or decimal above 9223372036854775807, read back as int or Decimal
F16, F32f16, f32, read back as the same classfloat, read back as float
BigDecimalbigdecimal, read back as BigDecimaldecimal when the value fits one, read back as Decimal; otherwise text, read back as str
PlainDate, datetime.datedate, read back as PlainDateA datetime at midnight UTC, read back as PreciseDatetime
PlainTime, naive datetime.timetime, read back as PlainTimeA duration since midnight, read back as Duration
Timestamptimestamp, read back as TimestampA datetime, with the wall-clock time read as UTC, read back as PreciseDatetime
Intervalinterval, read back as IntervalIts ISO 8601 text, read back as str
Regexregex, read back as RegexNot 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())
Output with SurrealDB 4.0
cbor.2
[U8(30), F32(0.10000000149011612)]

Against an earlier server, the same code prints cbor and [30, 0.10000000149011612].

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"))
Output
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 | None refuses Null, so add NullType to the type of a field that can hold NULL.

  • model_dump() turns a SurrealSet into a plain list, which is sent as an array. Wrap the value in SurrealSet again before you write it.

Was this page helpful?