Skip to main content

Surreal ORM Lite

Python SurrealDB SDK License codecov

Surreal ORM Lite is a lightweight, Django-style ORM for SurrealDB that uses the official SurrealDB Python SDK. It provides a simple and intuitive interface for database operations with full async support and Pydantic validation.

Why This Project?

This ORM is designed to:

  • Use the official SurrealDB SDK (surrealdb[pydantic]>=2.0.0,<3.0.0) for maximum compatibility
  • Stay lightweight with minimal dependencies
  • Keep up-to-date with SurrealDB and SDK releases
  • Provide Django-style query syntax that developers love

Requirements

Dependency Version
Python 3.11+
SurrealDB 2.6.x or 3.2.x
Official SDK surrealdb[pydantic]>=2.0.0,<3.0.0
Pydantic >=2.13.4

Note: As of v0.7.0, Surreal ORM Lite targets the SurrealDB Python SDK 2.x (surrealdb[pydantic]>=2.0.0,<3.0.0), which supports the SurrealDB 3.x protocol. It is tested against SurrealDB v2.6.5 and v3.2.4. SurrealDB 3.1.x is no longer a supported line as of v0.14.0 — the suite is still run against 3.1.5 as a backward-compatibility check, but a regression there does not block a release.


Installation

pip install surreal-orm-lite

Or with uv:

uv add surreal-orm-lite

Quick Start

1. Configure the Connection

from surreal_orm_lite import SurrealDBConnectionManager

SurrealDBConnectionManager.set_connection(
    url="http://localhost:8000",
    user="root",
    password="root",
    namespace="my_namespace",
    database="my_database",
)

2. Define a Model

from surreal_orm_lite import BaseSurrealModel
from pydantic import Field

class User(BaseSurrealModel):
    id: str | None = None
    name: str = Field(..., max_length=100)
    email: str
    age: int = Field(..., ge=0)

3. CRUD Operations

# Create
user = User(name="Alice", email="alice@example.com", age=30)
await user.save()

# Read
user = await User.objects().get("alice_id")
users = await User.objects().filter(age__gte=18).exec()

# Update
user.age = 31
await user.update()

# Or partial update
await user.merge(age=31)

# Delete
await user.delete()

4. QuerySet Methods

# Filter with Django-style lookups
users = await User.objects().filter(
    age__gte=18,
    name__startswith="A"
).exec()

# Ordering (with -field shorthand for DESC)
users = await User.objects().order_by("name").exec()
users = await User.objects().order_by("-age", "name").exec()

# Pagination
users = await User.objects().limit(10).offset(20).exec()

# Select specific fields
results = await User.objects().select("name", "email").exec()

# Get first result
user = await User.objects().filter(name="Alice").first()

# Get all records
all_users = await User.objects().all()

# Custom query
results = await User.objects().query(
    "SELECT * FROM User WHERE age > $min_age",
    {"min_age": 21}
)

Features

Feature Status
Async/await support ✅
Pydantic validation ✅
CRUD operations ✅
QuerySet with filters ✅
Django-style lookups ✅
Custom primary keys ✅
HTTP connections ✅
WebSocket connections ✅
Aggregations ✅
GROUP BY ✅
Model Signals ✅
Raw SurrealQL queries ✅
Q Objects (OR/AND/NOT) ✅
Parameterized filters ✅
Bulk operations ✅
-field ordering ✅
Relations & Graph ✅
FETCH clause ✅
Transactions (tx=) ✅
upsert / get_or_create ✅
patch / atomic ops ✅
Retry on conflict ✅
Server-side functions ✅
Computed fields ✅
Stored fn:: calls ✅
JWT / record auth ✅

Supported Filter Lookups

  • exact (default)
  • gt, gte, lt, lte
  • in, not_in
  • contains, not_contains
  • containsall, containsany
  • startswith, endswith
  • like, ilike
  • match, regex
  • isnull

Filtering on the record id

The id column holds a native RecordID, not a string, so the ORM coerces an id lookup before binding it. All of these address the same record:

await User.objects().filter(id="alice").exec()                        # bare identifier
await User.objects().filter(id="User:alice").exec()                   # full table:id form
await User.objects().filter(id=RecordID("User", "alice")).exec()      # explicit
await User.objects().get("alice")                                     # same rule
await User.objects().filter(id__in=["alice", "bob"]).exec()

The value's Python type decides which record is addressed, exactly as it does when a record is saved: filter(id=5) is the integer record id User:5, filter(id="5") is the string one. They are two different records — a model declared id: int needs the former.

A text or collection lookup (contains, startswith, regex, like, containsall, …) on id raises ValueError rather than silently matching nothing, since a record id is not a string:

await User.objects().filter(id__startswith="al").exec()
# ValueError: 'startswith' cannot be applied to the record id column 'id' …

A model that aliases its identity through primary_key keeps that alias as an ordinary column, so filter(code="abc") is a normal string comparison and is unaffected.

Filter values that start with $

A filter value beginning with $ is read as a reference to a query variable, not as data. Use Var(...) to say so explicitly, and $$ to escape a value that genuinely starts with a dollar sign:

from surreal_orm_lite import Var

await User.objects().filter(age__gte=Var("min_age")).exec()   # … WHERE age >= $min_age
await User.objects().filter(name="$$admin").exec()            # matches the literal "$admin"
await User.objects().filter(name="$admin").exec()             # deprecated: reads as $admin

The bare "$admin" form still works for backward compatibility but emits a DeprecationWarning: it silently matches nothing when the value is really meant as data, which is a trap for user-supplied strings. Prefer Var(...) in new code.

5. Q Objects (Complex Queries)

from surreal_orm_lite import Q

# OR queries
users = await User.objects().filter(Q(name="Alice") | Q(name="Bob")).exec()

# NOT queries
active = await User.objects().filter(~Q(status="banned")).exec()

# Complex combinations
results = await User.objects().filter(
    Q(age__gte=18) & (Q(role="admin") | Q(role="mod"))
).exec()

# Mix Q objects with keyword filters
results = await User.objects().filter(
    Q(role="admin") | Q(role="mod"),
    age__gte=25
).exec()

6. Bulk Operations

# Bulk create
users = [User(name="Alice", age=30), User(name="Bob", age=25)]
created = await User.objects().bulk_create(users)

# Bulk update (returns count of updated records)
count = await User.objects().filter(status="pending").bulk_update(status="active")

# Bulk delete (returns count of deleted records)
count = await User.objects().filter(status="inactive").bulk_delete()

7. Relations & Graph

# Create a relation
await user.relate("follows", other_user)

# With data on the edge
await user.relate("purchased", product, data={"quantity": 2, "price": 29.99})

# Get related records (outgoing)
following = await user.get_related("follows", direction="out", model_class=User)

# Get related records (incoming)
followers = await user.get_related("follows", direction="in", model_class=User)

# A target can also be a "table:id" string. Its id is read as a *string* record id — the
# same rule the ORM applies to a model's own id — so these two name the same record:
await user.relate("follows", "User:1")        # -> User:`1`, i.e. User(id="1")
# Pass a RecordID to target an *integer* record id (what a model with `id: int` stores):
from surrealdb import RecordID
await user.relate("follows", RecordID("User", 1))   # -> User:1

# Remove a specific relation
await user.remove_relation("follows", other_user)

# Remove all outgoing relations of a type
await user.remove_all_relations("follows", direction="out")

# Graph traversal
friends_of_friends = await user.traverse("->follows->User->follows->User")

8. FETCH Clause

# Resolve record links inline (prevents N+1 queries)
posts = await Post.objects().fetch("author", "tags").exec()
# Generates: SELECT * FROM Post FETCH author, tags;

9. Aggregations

from surreal_orm_lite import Count, Sum, Avg, Min, Max

# Simple aggregations
count = await User.objects().count()
total = await Order.objects().sum("amount")
avg_age = await User.objects().avg("age")
max_price = await Product.objects().max("price")
min_price = await Product.objects().min("price")

# Check existence
has_admins = await User.objects().filter(role="admin").exists()

# GROUP BY with annotations
results = await User.objects().values("status").annotate(count=Count()).exec()
# [{"status": "active", "count": 42}, {"status": "inactive", "count": 8}]

# Raw SurrealQL queries
results = await User.raw_query(
    "SELECT * FROM User WHERE age > $min_age",
    variables={"min_age": 18}
)

10. Model Signals

from surreal_orm_lite import pre_save, post_save, pre_delete, post_delete

@post_save.connect(User)
async def on_user_saved(sender, instance, created, **kwargs):
    """Called after every User save."""
    if created:
        await send_welcome_email(instance.email)
    await invalidate_cache(f"user:{instance.id}")

@pre_delete.connect(User)
async def on_user_deleting(sender, instance, **kwargs):
    """Called before User deletion."""
    await archive_user_data(instance.id)

Available signals:

Signal When Extra kwargs
pre_save Before save()
post_save After save() created
pre_update Before update()/merge() update_fields
post_update After update()/merge() update_fields
pre_delete Before delete()
post_delete After delete()
around_save Wraps save()
around_update Wraps update()/merge() update_fields
around_delete Wraps delete()

Around signals use async generators to wrap operations:

from surreal_orm_lite import around_save

@around_save.connect(User)
async def time_user_save(sender, instance, **kwargs):
    import time
    start = time.time()
    yield  # save() executes here
    duration = time.time() - start
    print(f"Save took {duration:.3f}s")

11. Transactions (atomic, all-or-nothing)

from surreal_orm_lite import SurrealDBConnectionManager

# All operations commit together, or none do.
async with SurrealDBConnectionManager.transaction() as tx:
    await User(id="alice", name="Alice").save(tx=tx)
    await Order(id="o1", user="User:alice", total=100).save(tx=tx)

    # v0.9.0: QuerySet reads & bulk ops participate in the transaction.
    actives = await User.objects(tx=tx).filter(status="active").exec()
    await User.objects(tx=tx).filter(role="guest").bulk_update(role="member")
    # Auto-commit on success; auto-rollback if the block raises.

transaction() picks the strategy automatically based on the connection:

  • WebSocket + SurrealDB 3.x → InteractiveTransaction (native begin()/commit()/cancel()). Reads inside the tx see uncommitted writes; save(tx=) supports auto-generated ids; refresh(tx=) and QuerySet.objects(tx=) reads work.
  • HTTP, or WebSocket on SurrealDB 2.6.x → BufferedTransaction. Writes are buffered and flushed as one BEGIN TRANSACTION; …; COMMIT TRANSACTION; query at commit; reads inside the tx raise; save(tx=) requires an explicit id. bulk_update/bulk_delete return 0 (the row count is not knowable before commit).

12. Upsert & get_or_create / update_or_create

from surreal_orm_lite import BaseSurrealModel, SurrealConfigDict


class User(BaseSurrealModel):
    model_config = SurrealConfigDict(primary_key="id")
    id: str | None = None
    name: str
    email: str


# Insert-or-replace by explicit id (full REPLACE — omitted fields are dropped).
# Use merge() instead if you only want a partial update.
await User(id="alice", name="Alice", email="alice@example.com").upsert()

# Criteria-based, Django-style; returns (instance, created).
# update_or_create: on create, writes criteria + defaults; on update, MERGEs them (a partial
# update — fields outside the criteria/defaults are preserved). Lifecycle signals fire on both
# paths, and the primary key anchors the record identity.
user, created = await User.objects().update_or_create(
    email="alice@example.com", defaults={"name": "Alice"}
)

# get_or_create writes the defaults ONLY when creating; an existing match is returned as-is:
user, created = await User.objects().get_or_create(
    email="bob@example.com", defaults={"name": "Bob"}
)

# Both participate in a transaction via objects(tx=) (interactive on SurrealDB 3.x):
async with SurrealDBConnectionManager.transaction() as tx:
    user, created = await User.objects(tx=tx).get_or_create(email="z@x.io", defaults={"name": "Z"})

If the lookup criteria match more than one record, both methods raise SurrealDbError (the criteria are not unique). Non-exact lookups (e.g. name__contains) drive the lookup but are not written to the record. Without a transaction the behaviour is identical on SurrealDB 2.6.x and 3.x; under objects(tx=) they participate in the transaction on 3.x, while a buffered 2.6.x transaction raises on the lookup (see the behaviour table).

13. Patch & atomic field/array operations

Mutate a record granularly — server-side — without reading and rewriting the whole document.

No signals. patch() and the atomic helpers are low-level primitives and emit no pre_*/post_*/around_* lifecycle signals. If you rely on signals (audit, cache invalidation, …), use merge() / save() instead.

# JSON Patch (RFC 6902) on a single record (native SDK patch()). Requires an explicit id.
await user.patch([
    {"op": "replace", "path": "/age", "value": 26},
    {"op": "add", "path": "/tags/-", "value": "premium"},
    {"op": "remove", "path": "/settings/notifications"},
])

# Ergonomic atomic helpers — each is one atomic UPDATE … SET, safe under concurrency:
await post.atomic_append("tags", "python")     # array::append — duplicates allowed
await post.atomic_set_add("editors", "alice")  # array::add     — added only if absent (set)
await post.atomic_remove("tags", "spam")       # array::complement — removes ALL "spam"
await counter.atomic_increment("views")        # += 1 (default); pass a negative to decrement
await counter.atomic_increment("score", 5)     # += 5

# List-valued variants — apply many in ONE round-trip instead of N:
await post.atomic_append_many("tags", ["python", "orm"])   # array::concat — all, dups allowed
await post.atomic_set_add_many("editors", ["alice", "bob"])  # array::add — only those absent
await post.atomic_remove_many("tags", ["spam", "draft"])     # array::complement — all matches

# Patch a filtered set (or the whole table if unfiltered); returns the affected count.
n = await User.objects().filter(status="trial").patch(
    [{"op": "replace", "path": "/plan", "value": "free"}]
)

# All of the above accept tx= and participate in a transaction:
async with SurrealDBConnectionManager.transaction() as tx:
    await counter.atomic_increment("views", tx=tx)
    await user.patch([{"op": "replace", "path": "/age", "value": 27}], tx=tx)

# atomic_increment accepts a Decimal for exact arithmetic (e.g. money):
from decimal import Decimal

await account.atomic_increment("balance", Decimal("2.25"))

# Optimistic concurrency with a JSON Patch `test` op: if the test fails, the WHOLE patch is
# aborted server-side (no op applies) and a ServerError is raised — RFC 6902 semantics.
await order.patch([
    {"op": "test", "path": "/version", "value": 7},  # only proceed if version is still 7
    {"op": "replace", "path": "/status", "value": "shipped"},
    {"op": "replace", "path": "/version", "value": 8},
])

These atomic ops behave identically on SurrealDB 2.6.x and 3.x by design: they use the version-portable functions array::append / array::add / array::complement (and numeric +=) rather than the bare += / -= array operators, whose semantics differ between server lines. patch() and the atomic helpers emit no signals (use merge() / save() if you need lifecycle hooks). On a non-transactional or interactive (3.x) call the instance is synced with the server's returned row; in a buffered 2.6.x transaction the result is unknown until commit, so refresh() the instance if you need it (same caveat as merge(tx=)).

A failed JSON Patch test op aborts the entire patch and raises the SDK's ServerError (message: Given test operation failed…) — none of the other ops in the list are applied. This gives you compare-and-set / optimistic-concurrency without a transaction.

14. Optimistic concurrency: retry_on_conflict

Under SurrealDB's optimistic concurrency, a transaction is rolled back with a retryable conflict when a concurrent writer changed the same data. retry_on_conflict re-runs the whole function (a fresh transaction per attempt) with exponential backoff + jitter, but only on a real conflict — any other error propagates immediately.

from surreal_orm_lite import retry_on_conflict, SurrealDBConnectionManager

@retry_on_conflict(max_retries=3, base_delay=0.05, max_delay=2.0, backoff_factor=2.0)
async def transfer(src_id, dst_id, amount):
    async with SurrealDBConnectionManager.transaction() as tx:
        src = await Account.objects(tx=tx).get(src_id)
        dst = await Account.objects(tx=tx).get(dst_id)
        await src.merge(tx=tx, balance=src.balance - amount)
        await dst.merge(tx=tx, balance=dst.balance + amount)

await transfer("acc:a", "acc:b", 100)  # retries automatically on a version conflict

A conflict is exposed as SurrealDbConflictError (a subclass of SurrealDbError) on both SurrealDB lines, so you can catch it yourself or test any exception with is_conflict_error():

from surreal_orm_lite import SurrealDbConflictError, is_conflict_error

try:
    await transfer("acc:a", "acc:b", 100)
except SurrealDbConflictError:
    ...  # still conflicting after every retry
  • Total attempts = max_retries + 1; after they are exhausted the conflict is re-raised as SurrealDbConflictError. The numeric parameters are validated at decoration time.
  • Detection anchors on SurrealDB's own retryable marker ("This transaction can be retried"), so a non-retryable failure (e.g. a duplicate-key error) is not retried.
  • The exception type is identical on 2.6.x and 3.x; conflicts simply arise more often on 3.x (optimistic MVCC) than on 2.6.x (see the behaviour table).

15. Server-side values: SurrealFunc + server_values=

Some values belong to the server, not to your process: a creation timestamp should come from the DB clock, a password hash from the DB's own crypto. Pass them as server_values= on save() or merge() and the ORM compiles a SET clause where the expression is evaluated by SurrealDB:

from surreal_orm_lite import SurrealFunc

await player.save(server_values={"joined_at": SurrealFunc("time::now()")})
# CREATE $rid SET seat = $_sv_seat, joined_at = time::now();

The instance is synced with the row the server returns, so player.joined_at is a real datetime right after the call — no extra refresh().

User input never goes into the expression. Reference a bound parameter and supply it through extra_vars=, which is bound like any other value:

from surreal_orm_lite import SurrealFunc, SurrealCryptoFunction

await user.save(
    server_values={"password_hash": SurrealFunc.call(SurrealCryptoFunction.ARGON2_GENERATE, "$password")},
    extra_vars={"password": raw_password},   # bound — never interpolated
)

merge() takes the same two arguments and stays a partial update (unlisted fields are untouched); a server_values entry overrides a keyword of the same name:

from surreal_orm_lite import SurrealTimeFunction

await user.merge(plan="pro", server_values={"updated_at": SurrealFunc.call(SurrealTimeFunction.NOW)})

SurrealFunc.call(fn, *args) builds fn(arg, …) from a function name — a plain string or a member of the shipped enums, which give you autocompletion over a curated catalog whose every member is tested against SurrealDB 2.6.5 and 3.2.4:

Enum Covers
SurrealTimeFunction time::now, time::floor, time::unix, time::year, …
SurrealMathFunction math::abs, math::pow, math::mean, math::fixed, …
SurrealStringFunction string::concat, string::slug, string::replace, …
SurrealArrayFunction array::append, array::add, array::distinct, …
SurrealCryptoFunction crypto::argon2::generate / ::compare, crypto::bcrypt::*, …
SurrealRandFunction rand, rand::uuid::v7, rand::ulid, rand::enum, …

Functions whose name differs between the two server lines are deliberately excluded from the catalog (rand::guid is 2.6-only; type::is::* became type::is_* in 3.x) — pass those as a plain string if you target one line. The enums are convenience, not a gate: SurrealFunc accepts any expression.

Security: the SurrealFunc expression is inserted verbatim into the query, so build it only from developer-controlled text. Field values and extra_vars are always bound parameters — that is the injection boundary. SurrealFunc rejects ; as a guard against accidental statement chaining, but that is not a sanitizer.

Both calls behave identically on SurrealDB 2.6.x and 3.x. Inside a transaction the usual v0.9.0 rules apply: on a buffered transaction (HTTP / 2.6.x) the computed value is unknown until commit, so the instance keeps its previous value for that field until you refresh().


16. Computed fields: Computed[...] → DEFINE FIELD … VALUE

server_values= computes a value for one write. A Computed field attaches the expression to the schema instead, so SurrealDB recomputes it on every write to the table — including writes that never go through the ORM:

from surreal_orm_lite import BaseSurrealModel, Computed, computed

class Player(BaseSurrealModel):
    id: str
    first_name: str
    last_name: str
    full_name: Computed[str] = computed("string::concat(first_name, ' ', last_name)")
    initials: Computed[str] = computed("string::uppercase(string::slice(first_name, 0, 1))")

await Player.define_computed_fields()   # DEFINE FIELD OVERWRITE full_name ON Player VALUE …

player = await Player(id="ada", first_name="Ada", last_name="Lovelace").save()
player.full_name        # "Ada Lovelace" — computed by the server, not by Python

Computed[T] is the annotation and makes the field T | None defaulting to None, so an instance is constructible before the server has ever computed it; computed("<expr>") is the default and carries the expression, which may be a plain string or a SurrealFunc. The two are split — rather than one dual-use name — so that player.full_name resolves to str | None under mypy and pyright, the same shape as SQLAlchemy's Mapped[T] = mapped_column(...).

Applying the schema. define_computed_fields() is idempotent — call it at start-up:

Player.computed_field_ddl()             # the statements, without touching the DB
await Player.define_computed_fields()                  # DEFINE FIELD OVERWRITE … (default)
await Player.define_computed_fields(overwrite=False)   # DEFINE FIELD IF NOT EXISTS …

The default OVERWRITE treats the model as the source of truth, so editing an expression and redeploying takes effect. overwrite=False never disturbs a definition that already exists.

The field is server-owned. It is dropped from every write payload, and naming it in a write raises rather than being silently discarded:

await player.merge(last_name="Byron")       # ✅ full_name recomputes to "Ada Byron"
await player.merge(full_name="whatever")    # ❌ ValueError: full_name is a computed field …

The same guard applies to save(server_values=), patch(), QuerySet.patch(), QuerySet.bulk_update() and the atomic_* helpers. A JSON Patch is checked on its top-level pointer segment, so /full_name and /full_name/0 are both refused. This is genuinely enforced by SurrealDB, not just by the ORM — a client that bypasses ORM-lite entirely and writes full_name directly still gets the expression's result.

Ordering caveat: SurrealDB evaluates computed fields in alphabetical field-name order, not declaration order. A computed field that reads another must sort after it — subtotal → total works, but z_sub → a_total fails at write time.

Security: like SurrealFunc, the expression is inlined verbatim into DDL and cannot reference bound parameters. Build it only from developer-controlled text, never user input.

No TYPE clause is emitted — SurrealDB infers an optional type on both server lines. Behaviour is identical on SurrealDB 2.6.x and 3.x.


17. Stored functions: call_function()

Invoke a function declared server-side with DEFINE FUNCTION fn::…. Arguments are bound as query parameters, never formatted into the statement.

from surreal_orm_lite import SurrealDBConnectionManager

# Declare it once (DDL goes through query(); a define_function() helper lands in v0.31.0)
client = await SurrealDBConnectionManager.get_client()
await client.query("""
    DEFINE FUNCTION OVERWRITE fn::acquire_lock($table_id: string, $pod_id: string, $ttl: int) {
        UPSERT type::record("lock:⟨" + $table_id + "⟩") SET holder = $pod_id, ttl = $ttl;
        RETURN { acquired: true, holder: $pod_id };
    };
""", {})

# Positional — SurrealQL's own calling convention
lock = await SurrealDBConnectionManager.call_function(
    "fn::acquire_lock", ["table-1", "pod-a", 30],
)

# The fn:: prefix is optional, and nested namespaces work
total = await SurrealDBConnectionManager.call_function("billing::total", [cart_id])

Named arguments. SurrealQL function arguments are positional, so the ORM reads the function's declared signature (INFO FOR DB, cached) and orders them for you — the mapping's own order is irrelevant:

lock = await SurrealDBConnectionManager.call_function(
    "fn::acquire_lock", params={"pod_id": "pod-a", "ttl": 30, "table_id": "table-1"},
)

Passing both args and params raises ValueError, as does a key that does not match the declared parameters (the error names the ones expected).

Typed results. return_type= accepts anything Pydantic can adapt — a model, a dataclass, a scalar, or a generic like list[Model]:

from pydantic import BaseModel

class LockResult(BaseModel):
    acquired: bool
    holder: str

lock = await SurrealDBConnectionManager.call_function(
    "fn::acquire_lock", ["table-1", "pod-a", 30], return_type=LockResult,
)
lock.acquired  # True

A result that does not fit raises SurrealDbValidationError. Note this is Pydantic validation, not a cast: an int asked to be a str is a mismatch, not a silent str(5).

Inside a transaction. Pass tx= so the function runs inside the transaction — without it the call would execute outside it and silently break atomicity, which matters because stored functions typically mutate state:

async with SurrealDBConnectionManager.transaction() as tx:
    await SurrealDBConnectionManager.call_function("fn::acquire_lock", ["t1", "pod-a", 30], tx=tx)
    await Booking(id="b1", table_id="t1").save(tx=tx)
# both the function's writes and the booking commit together, or neither does

The return value depends on the transaction strategy (the v0.9.0 contract): interactive transactions (WebSocket + SurrealDB 3.x) return the value immediately; buffered ones (SurrealDB 2.6.x or HTTP) queue the call and return None until commit. Combining return_type= with a buffered tx= raises ValueError rather than silently returning None.

From a model. A stored function is not bound to a table, but the shortcut is convenient in model-oriented code:

lock = await GameTable.call_function("fn::acquire_lock", ["t1", "pod-a", 30])

Errors are normalised: a function the server does not know raises SurrealDbNotFoundError, anything else raises SurrealDbError. A result that does not fit return_type raises SurrealDbValidationError — inside an interactive transaction too. The one exception is a buffered transaction, where the call is merely queued: a missing function cannot be detected at call time and surfaces at commit as SurrealDbError.

Portability note — building a record id inside a function is the one fiddly part, and the spelling above is the one verified on both DB lines. The two-argument type::record($table, $id) is 3.x-only (on 2.6.x the second argument means a type, not an id), and type::thing is its 2.6-only inverse. The ⟨…⟩ brackets around the id matter too: without them type::record("lock:" + $id) truncates an id containing a hyphen ("table-1" becomes table) on 3.x and is rejected outright on 2.6.x.

Signature caching is transparent, but two helpers are available if you redefine functions out-of-band: SurrealDBConnectionManager.clear_function_signature_cache() and function_signature_cache_size(). The cache is keyed by URL, namespace and database, so set_url(), set_namespace() and set_database() cannot serve a signature read elsewhere; it is cleared outright by set_connection() and unset_connection(). A signature whose parameter names changed self-heals on its own — but one redefined with the same names in a different order cannot be detected, since params= is a mapping and carries no order to compare against. After such a redefinition, call clear_function_signature_cache(), or params= keeps binding in the stale order.


18. Authentication (signin / signup / info / invalidate)

Authenticate the connection as a SurrealDB record user (a DEFINE ACCESS … TYPE RECORD method) or as a different system user. Declare the access method once — DDL goes through query():

client = await SurrealDBConnectionManager.get_client()
await client.query("""
    DEFINE TABLE OVERWRITE app_user SCHEMALESS
      PERMISSIONS FOR select, update WHERE id = $auth.id;
""", {})
await client.query("""
    DEFINE ACCESS OVERWRITE account ON DATABASE TYPE RECORD
      SIGNUP ( CREATE app_user SET email = $email, pass = crypto::argon2::generate($pass) )
      SIGNIN ( SELECT * FROM app_user
               WHERE email = $email AND crypto::argon2::compare(pass, $pass) )
      DURATION FOR TOKEN 15m, FOR SESSION 12h;
""", {})

Then register and authenticate users:

from surreal_orm_lite import AuthTokens, SurrealDBConnectionManager, SurrealDbAuthenticationError

tokens = await SurrealDBConnectionManager.signup(
    access="account", variables={"email": "ada@example.com", "pass": "s3cret"},
)
tokens = await SurrealDBConnectionManager.signin(
    access="account", variables={"email": "ada@example.com", "pass": "s3cret"},
)
tokens.access     # the JWT — hand it to a web client
tokens.refresh    # SurrealDB 3.x only (WITH REFRESH); always None on 2.6.x

namespace/database come from set_connection() for record access. A system user gets neither unless you pass them, because a root user is defined at no level:

await SurrealDBConnectionManager.signin(username="root", password="root")            # root
await SurrealDBConnectionManager.signin(username="u", password="p", namespace="ns")  # NS user

Read back who is signed in, optionally hydrated into a model:

me = await SurrealDBConnectionManager.info()                  # dict | None
me = await SurrealDBConnectionManager.info(return_type=User)  # User | None

Restore an identity on a later request, and log out:

await SurrealDBConnectionManager.authenticate(stored_jwt)
await SurrealDBConnectionManager.invalidate()   # back to the credentials of set_connection()

The identity survives reconnects: get_client() replays the stored token, so a dropped connection or a new event loop comes back as the same user rather than silently reverting to the configured root identity.

Every failure — wrong password, unknown access method, malformed or expired token — raises SurrealDbAuthenticationError on both DB lines (it subclasses SurrealDbError). Match on the type, never on the server's wording, which differs per line.

⚠️ Auth changes the identity of the whole connection. The manager caches one client per event loop and every model shares it, so an auth call affects every subsequent ORM operation — not just the caller's. An application serving concurrent users must not route them all through a single connection-manager identity.

⚠️ info() returns None unless the table grants the record user select on itself. The signin succeeded and $auth is set, but the server returns nothing. Grant something like PERMISSIONS FOR select WHERE id = $auth.id.

Refresh tokens (SurrealDB 3.x only). With DEFINE ACCESS … WITH REFRESH, renew without the password:

tokens = await SurrealDBConnectionManager.signin(access="account", refresh=stored_refresh)
persist(tokens.refresh)   # REQUIRED — see below

⚠️ Refresh tokens rotate. A successful exchange kills the token it spent, immediately and permanently. If you keep the old value and drop the new one, the user is logged out for good — and nothing is raised at the moment you make the mistake.


Configuration Options

Custom Primary Key

from surreal_orm_lite import BaseSurrealModel, SurrealConfigDict

class Product(BaseSurrealModel):
    model_config = SurrealConfigDict(primary_key="sku")

    sku: str
    name: str
    price: float

Context Manager

async with SurrealDBConnectionManager():
    users = await User.objects().all()
# Connection automatically closed

Connections and event loops

The manager caches one client per event loop. A SurrealDB WebSocket client is bound to the loop it connected on, so reusing it from another loop is not merely wrong — it fails with got Future attached to a different loop, or hangs. Keying the cache by loop means each one gets its own connection:

# Each asyncio.run() creates and closes its own loop. Both calls work.
asyncio.run(work())
asyncio.run(work())

# Two loops alive at once (threads, multi-loop servers) each keep their own client;
# neither evicts the other.

An entry whose loop has been closed is dropped the next time a client is requested. The stale client is not closed — that would have to be awaited on a loop that is already gone — and its socket is released when the loop is finalised.

  • close_connection() closes the running loop's client only.
  • close_all_connections() tears down every cached client (used by unset_connection()).
  • is_connected() answers for the loop asking; called outside a loop, it reports whether any loop still holds a client.

Long-lived single-loop applications are unaffected: one loop, one connection, as before.


Compatibility

As of v0.7.0, Surreal ORM Lite uses surrealdb[pydantic]>=2.0.0,<3.0.0 (SurrealDB 3.x protocol) and is tested against both major SurrealDB release lines.

Compatibility advantage over the full ORM: ORM-lite runs on both SurrealDB 2.6.x and 3.2.x, while the full SurrealDB-ORM (custom SDK) targets 3.x only. Lite stays usable on existing 2.6.x deployments without forcing a server upgrade.

SurrealDB Version SDK Version Status
3.2.4 2.0 ✅ Tested
2.6.5 2.0 ✅ Tested
3.2.x / 2.6.x 2.0 ✅ Compatible
3.1.5 2.0 ⚠️ Backward-compat only
< 2.6 or > 3.2 — ⚠️ Not guaranteed

ORM behaviour: SurrealDB 2.6.x vs 3.x

Surreal ORM Lite runs on both lines; some capabilities differ because they rely on server features introduced in SurrealDB 3.x. On 2.6.x the ORM degrades gracefully. Capabilities not listed behave the same on both lines.

ORM capability SurrealDB 2.6.x SurrealDB 3.2.x Since
Transaction strategy auto-selected by transaction() buffered batch (BEGIN…COMMIT) native interactive on WebSocket v0.9.0
Reads inside a transaction (objects(tx=)) raise (buffered cannot read) see uncommitted writes v0.9.0
save(tx=) with an auto-generated id raises — explicit id required supported v0.9.0
refresh(tx=) inside a transaction raises works v0.9.0
bulk_update / bulk_delete / QuerySet.patch row count inside a tx returns 0 (not knowable pre-commit) real count v0.9.0
"Already exists" error on create normalised to SurrealDbError normalised to SurrealDbError v0.7.0
Cleanup on a missing target (delete_table, remove_relation) native no-op ORM makes it a silent no-op v0.7.0
Aggregation over an empty set (NaN / ±inf) returns 0.0 / None ORM normalises to 0.0 / None v0.7.0
Namespace/db selection (use() ordering) lenient (auto-creates) strict — ORM signs in before use() v0.7.0
upsert() / update_or_create() / get_or_create() same on both lines same on both lines v0.10.0
patch() / atomic_append / atomic_set_add / atomic_remove / atomic_increment same on both lines (portable array::* fns chosen over divergent +=/-=) same on both lines v0.11.0
retry_on_conflict / SurrealDbConflictError (retryable conflict) same type + decorator; conflicts rarer (engine serialises more) same type + decorator; conflicts are the normal optimistic-MVCC failure v0.12.0
SurrealFunc / server_values= / extra_vars= on save/merge same on both lines (compiled to portable CREATE/UPDATE … SET) same on both lines v0.13.0
Shipped function-name enums (SurrealTimeFunction, SurrealCryptoFunction, …) every catalogued member verified on 2.6.5 every catalogued member verified on 3.2.4 v0.13.0
server_values inside a transaction — when the instance sees the computed value only after commit (buffered; refresh() to read it) immediately (interactive returns the row) v0.13.0
merge(server_values=) on a missing record / never-created table server returns no rows → ORM raises SurrealDbError server raises NotFound for a missing table → ORM raises the same error v0.13.0
Computed fields (Computed[...] → DEFINE FIELD … VALUE) same on both lines (DDL, recompute triggers, precedence over client data) same on both lines v0.14.0
DDL run inside a transaction, then rolled back definition rolled back with the transaction same on both lines v0.14.0
Invalid computed-field expression — raw SDK exception InternalError ValidationError v0.14.0
Invalid computed-field expression — through the ORM SurrealDbError (normalised) SurrealDbError (normalised) v0.14.0
Issue #156 correctness fixes (Var/$$, first(), *_or_create strictness, created on upsert, quoted ids, one-hop get_related) same on both lines (each fix reproduced and verified on 2.6.5) same on both lines (verified on 3.2.4) v0.14.3
Record-id lookups (filter(id=…), id__in, get(…), *_or_create(id=…)) coerced to RecordID same on both lines (int/str typing verified on 2.6.5) same on both lines (verified on 3.2.4) v0.14.4
Per-event-loop client cache (get_client, close_connection, close_all_connections) same on both lines (loop binding is an asyncio/SDK property, not a server one) same on both lines (verified on 3.2.4) v0.14.5
call_function() — the call itself (args, params, return_type, nested fn::a::b) same on both lines (bare call form chosen so it is portable) same on both lines (verified on 3.2.4) v0.15.0
call_function(tx=) — return value None (buffered: queued until commit) the function's value (interactive returns it immediately) v0.15.0
call_function(tx=, return_type=) raises ValueError (no value to coerce yet) coerces the returned value v0.15.0
Missing function called inside a transaction surfaces at COMMIT as SurrealDbError (buffered: the call is only queued) raises SurrealDbNotFoundError at call time v0.15.0
Declared parameter name that collides with a reserved word, as echoed by INFO FOR DB quoted: $`by` — parser accepts it bare: $by v0.15.0
Auth methods (signin, signup, authenticate, invalidate, info) same on both lines (native SDK primitives, verified on 2.6.5) same on both lines (verified on 3.2.4) v0.16.0
Auth failure — raw SDK exception for a wrong password InternalError NotFoundError v0.16.0
Auth failure — through the ORM SurrealDbAuthenticationError (normalised) SurrealDbAuthenticationError (normalised) v0.16.0
DEFINE ACCESS … WITH REFRESH and AuthTokens.refresh not supported — the clause does not parse; refresh is always None supported; refresh populated v0.16.0
signin(access=…, refresh=…) renewal raises SurrealDbAuthenticationError (no such access method is definable) returns a fresh, rotated token pair; the spent one is rejected v0.16.0
Session token replayed on reconnect (get_client, reconnect) same on both lines same on both lines v0.16.0
info() when the record's table denies it select on itself returns None (no error) returns None (no error) v0.16.0
Signing in as a system user while a record session is open permissions change, $auth still points at the record — only invalidate() clears it same on both lines v0.16.0
Duplicate signin identifier in the record table signin fails (No record was returned) signin succeeds, picking one record v0.16.0

Note on record IDs: A record loaded from the database has its id field set to a native surrealdb.RecordID object, not a plain string. Use model.get_raw_id() to obtain the bare identifier string (e.g. "alice"), or compare directly with model.id == RecordID("User", "alice"). In-memory instances you construct yourself retain whatever value you assign.


Contributing

Contributions are welcome! Please:

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m "Add amazing feature")
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

Roadmap

Version Theme Status
v0.2.x – v0.7.0 Core ORM → SDK 2.0 / SurrealDB 3.x migration ✅ Released
v0.8.0 Transactions ORM (tx=) ✅ Released
v0.9.0 Transactions — QuerySet & interactive (3.x) ✅ Released
v0.10.0 upsert / update_or_create / get_or_create ✅ Released
v0.11.0 patch / atomic field & array ops ✅ Released
v0.12.0 retry_on_conflict & optimistic concurrency ✅ Released
v0.13.0 SurrealFunc & server-side values ✅ Released
v0.14.0 Computed fields (DEFINE FIELD … VALUE) ✅ Released
v0.14.3 – v0.14.5 Correctness: $-values, record-id lookups, loops ✅ Released
v0.15.0 call_function() — custom fn:: stored functions ✅ Released
v0.16.0 Connection auth (signin/signup/info) ✅ Released
v0.17.0 – v0.22.0 Tier 1 — Core (model auth, live, relations) 📋 Planned
v0.23.0 – v0.29.0 Tier 2 — Extended (rich types, geo, subqueries) 📋 Planned
v0.30.0 – v0.39.0 Tier 3 — Advanced (search, DDL, migrations, CLI) 📋 Planned
v0.40.0 Beta Phase (API freeze, hardening) 📋 Planned
v2.0.0 Production / GA (aligned with SDK 2.0) 📋 Planned

Every roadmap feature is implementable with the official SDK 2.0 (native methods or query() SurrealQL) — no custom SDK. GA is numbered v2.0.0 to mirror SDK 2.0; the 1.x line is intentionally skipped.

See docs/ROADMAP.md for full details.


SurrealDB-ORM-lite vs SurrealDB-ORM

This project prioritizes stability and compatibility with the official SurrealDB Python SDK. The full SurrealDB-ORM uses a custom SDK for advanced features.

Both projects target the same feature set; the difference is how (official SDK vs custom SDK) and server support. Everything below is on the lite roadmap via the official SDK 2.0 — only the custom-SDK internals stay exclusive to the full ORM.

Feature ORM-lite (official SDK) ORM (custom SDK)
Supported SurrealDB 2.6.x + 3.2.x 3.x only
CRUD & QuerySet ✅ ✅
Aggregations & GROUP BY ✅ ✅
Model Signals ✅ ✅
Bulk Operations ✅ ✅
Q Objects (OR/AND/NOT) ✅ ✅
Parameterized Filters ✅ ✅
Relations & Graph ✅ ✅
FETCH clause ✅ ✅
Transactions (tx=) ✅ v0.8 (core), v0.9 QS ✅
Interactive tx (3.x native) ✅ v0.9 ✅
upsert / update_or_create ✅ v0.10.0 ✅
Atomic field/array operations ✅ v0.11.0 ✅
Retry on conflict ✅ v0.12.0 ✅
SurrealFunc & server values ✅ v0.13.0 ✅
Computed fields ✅ v0.14.0 ✅
call_function() (fn::) ✅ v0.15.0 ✅
JWT Authentication ✅ v0.16.0 (connection) ✅
Field Aliases & DX v0.18.0 ✅
Live Models / CDC v0.19 – v0.21 ✅
Native typed relations v0.22.0 ✅
Rich field types v0.23.0 ✅
Geospatial Fields v0.24.0 ✅
Subqueries & Query Cache v0.27 – v0.28 ✅
Multi-database v0.29.0 ✅
Schema Introspection v0.30.0 ✅
DEFINE EVENT v0.31.0 ✅
Materialized views v0.32.0 ✅
Full-Text Search v0.34.0 ✅
Vector Search (KNN/HNSW) v0.35.0 ✅
Hybrid Search (RRF) v0.36.0 ✅
Migrations & CLI v0.37 – v0.38 ✅
Test Fixtures & Factories v0.39.0 ✅
Retry, Logging, Metrics v0.40.0 ✅
Connection Pool post-GA (tentative) ✅
Custom SDK / CBOR Protocol ❌ never ✅

Choose ORM-lite if you want the official SDK, minimal dependencies, support for SurrealDB 2.6.x and 3.2.x, and a full feature roadmap built entirely on the official SDK.

Choose ORM if you need the custom-SDK internals (CBOR protocol, native connection pool) or those features available today rather than on the roadmap.


License

MIT License - see LICENSE for details.


Author

Yannick Croteau GitHub: @EulogySnowfall


Related Projects

Metadata

Release files for surreal-orm-lite 0.16.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for surreal-orm-lite 0.16.0
File Size Uploaded
surreal_orm_lite-0.16.0.tar.gz 120.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for surreal-orm-lite 0.16.0
File Interpreter ABI Platform
surreal_orm_lite-0.16.0-py3-none-any.whl Python 3 none any Details

Total release size: 212.2 kB

Release files / surreal_orm_lite-0.16.0.tar.gz

Download URL surreal_orm_lite-0.16.0.tar.gz
Size 120.4 kB
Tags Source
SHA-256 checksum
How to use checksums
d8857a5f9c5726651359b2c25c86d1d3dc8ddee25aba85c176a66e8cacee277d
BLAKE2b-256 checksum
How to use checksums
215dac06ce4ccf75861bbf4c2a3ff4648a43315c7a1e12e3b7bdd5612c545ab2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 29, 2026.

Transparency log

Release files / surreal_orm_lite-0.16.0-py3-none-any.whl

Download URL surreal_orm_lite-0.16.0-py3-none-any.whl
Size 91.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f07bcc923a371ad80bcec1a3a2df1a0fabaa2a1bf400939d889ad067ed03e693
BLAKE2b-256 checksum
How to use checksums
0fe6c6f3280456d044e16f14fbe509faa505ea8dc433334128cb82d204d488ac
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 29, 2026.

Transparency log

Release history Release notifications | RSS feed

0.19.0

2 release files

0.18.0

2 release files

0.17.0

2 release files

0.16.1

2 release files

This release

0.16.0 This release

2 release files

0.15.0

2 release files

0.14.6

2 release files

0.14.5

2 release files

0.14.4

2 release files

0.14.3

2 release files

0.14.2

2 release files

0.14.1

2 release files

0.14.0

2 release files

0.13.3

2 release files

0.13.0

2 release files

0.12.1

2 release files

0.12.0

2 release files

0.11.9

2 release files

0.11.8

2 release files

0.11.7

2 release files

0.11.6

2 release files

0.11.5

2 release files

0.11.4

2 release files

0.11.3

2 release files

0.11.2

2 release files

0.11.1

2 release files

0.9.0

2 release files

0.7.0

2 release files

0.6.17

2 release files

0.6.16

2 release files

0.6.15

2 release files

0.6.14

2 release files

0.6.13

2 release files

0.6.12

2 release files

0.6.11

2 release files

0.6.9

2 release files

0.6.8

2 release files

0.6.7

2 release files

0.6.6

2 release files

0.6.5

2 release files

0.6.4

2 release files

0.6.2

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.2

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page