Skip to main content

SurrealDB-ORM (V2 LTS - SurrealDB 2.X)

Python CI codecov GitHub License

V2 LTS Branch - Maintenance-only for SurrealDB 2.X compatibility. For SurrealDB 3.X, see the main branch.

SurrealDB-ORM is a Django-style ORM for SurrealDB with async support, Pydantic validation, and JWT authentication.

Includes a custom SDK (surreal_sdk) - Zero dependency on the official surrealdb package!


Branch Strategy

Branch SurrealDB Version Status Version
main 3.X Active development 0.3y.x
v2 (this branch) 2.X Deprecated (LTS)¹ 0.2y.x

¹ Deprecated since 0.21.0 — see the notice above. Superseded by SurrealDB-ORM-lite.

V2 LTS Policy

  • No new features are developed on this branch
  • Security vulnerabilities and bug fixes are actively maintained
  • SurrealDB 2.X compatibility updates are automatically monitored and applied
  • A daily workflow checks for new SurrealDB 2.X releases (< 3.0), runs integration tests, and auto-creates PRs
  • Security patches trigger automatic version bumps and PyPI releases

SurrealDB Compatibility

ORM version Supported SurrealDB Tested against
0.21.8+ 2.6.x – 2.7.x (< 3.0) v2.7.0
0.21.0 – 0.21.7 2.6.x v2.6.5

For SurrealDB 3.x, use the main branch (0.3y.x).


What's New in 0.21.22

Bug fix release (V2 LTS). One ORM fix, the v2 port of #301 from main; one small SDK helper added.

  • Record IDs containing a backtick or a backslash now round-trip (#310). merge() with a SurrealFunc, the atomic array helpers, relate(), get_related(), remove_relation(), remove_all_relations(), graph traversals and delete() failed with a parse error on SurrealDB 2.x, or named a different record, for such an ID (including one ending in a backslash). escape_record_id() now writes SurrealDB's \` and \\ sequences instead of doubling the backtick.
  • Added: surreal_sdk.protocol.cbor.unescape_record_id(), the inverse of escape_record_id().
  • Behaviour change: only IDs containing ` or \ produce different SurrealQL. parse_record_id() and the SDK connections now decode \\ and \` inside a backtick-delimited table:id string; a doubled backtick is still read as one backtick.

What's New in 0.21.21

Bug fix release (V2 LTS). One CLI fix, the v2 port of #298 from main; no new public API.

  • CLI database commands work on Python 3.14 (#303). makemigrations (reading the schema), migrate, upgrade, rollback, status, sqlmigrate, inspectdb, schemadiff and shell failed with "There is no current event loop" on Python 3.14 and emitted a DeprecationWarning on 3.12 – 3.13. The CLI now runs its coroutines with asyncio.run(); surreal-orm shell keeps one asyncio.Runner per session and runs the prompt outside the event loop, so Ctrl+C at the prompt is no longer turned into the cancellation of a running coroutine.
  • Behaviour change: each CLI command runs on a fresh event loop that is closed when it returns.

What's New in 0.21.20

Bug fix release (V2 LTS). One ORM fix, the v2 port of #286 from main; no new public API.

  • An optional ForeignKey is now converted to a record link (#294). With ForeignKey("X") | None or Optional[ForeignKey("X")], save(), update(), merge() and bulk_update() sent a string, so an option<record<...>> column rejected the write, and filter() (exact / in / not_in, including inside Q) silently returned no rows for records that exist. Optional foreign keys now behave like required ones.
  • Behaviour change: rows previously written as strings through an optional ForeignKey (SCHEMALESS table, or a field typed any) are no longer matched by a filter on that field, and a field declared option<string> now rejects the write. The one-time conversion statement is in the CHANGELOG.

What's New in 0.21.19

Security release (V2 LTS). No library code change; the declared aiohttp floor moves to >=3.14.4.

  • Upgrading surrealdb-orm now brings the patched aiohttp (#245). The package declared aiohttp>=3.14.0, so pip install -U surrealdb-orm and existing downstream lockfiles could keep an aiohttp 3.14.0 – 3.14.3. 3.14.4 is the first release patching eight advisories affecting aiohttp <= 3.14.3; the new floor also drops 3.14.0 – 3.14.2 and the 11 more advisories fixed in 3.14.1 – 3.14.3. The full list is in the CHANGELOG.
  • Behaviour change: an environment pinning aiohttp < 3.14.4 now fails at resolution time instead of installing alongside a vulnerable aiohttp. This replaces the 0.21.10 advice to run pip install -U aiohttp by hand.

What's New in 0.21.18

Bug fix release (V2 LTS). One SDK fix, the v2 port of the live-stream half of #276 from main; no new public API, but live_id changes after a reconnect.

  • QuerySet.live() keeps receiving events after a WebSocket reconnect (#277). With auto_resubscribe=True, the default, the live query was re-created on the server but the stream's handler was not registered under the new id, so every LiveSelectStream went silent after its first reconnect. The stream now moves to the re-created live query during the resubscribe itself; on_reconnect still fires with (old_id, new_id).
  • stop() after a reconnect ends the re-created live query. It used to kill the old, dead id, which left the re-created query running and re-created it again on every later reconnect. A stop() that starts while the query is being re-created now ends it too.
  • Behaviour change: after a reconnect, LiveSelectStream.live_id / LiveModelStream.live_id report the re-created query's id. Code that compares a stored live_id should take the new id from on_reconnect. LiveSelectManager still keys streams by the id watch() returned: after a reconnect, stop a stream with that original id, or use stop_all(). See the CHANGELOG.

What's New in 0.21.17

Bug fix release (V2 LTS). One migrations fix, the v2 port of #248 from main; a behaviour change in what generated migrations emit.

  • A generated AlterField no longer resets a field's permissions to FULL (#259). It redefines the field with DEFINE FIELD OVERWRITE, which replaces the whole definition, and field-level PERMISSIONS were not carried, so a clause set by hand, by another tool or by a RawSQL migration fell back to PERMISSIONS FULL on the next generated alteration and on its rollback. The diff now reads the clause from the database (FieldState.permissions) and AlterField re-emits it in both directions through the new permissions / previous_permissions arguments.
  • parse_define_field() no longer misreads a field's PERMISSIONS clause. A WHERE condition naming a field such as type or default was read as that clause (schema_diff() raised ValueError on type), and a preceding ASSERT $value > 0, or a regex literal holding an apostrophe, folded the clause into the assertion.
  • Behaviour change / upgrading: both arguments are a snapshot of the clause when the migration is generated; regenerate (or edit both) if the field's permissions change before you apply it. Migration files already on disk carry neither argument and still reset the permissions: add them, or regenerate an unapplied migration. See the CHANGELOG.

What's New in 0.21.16

Bug fix release (V2 LTS). One ORM fix, the v2 port of #257 from main; no new public API, but a behaviour change.

  • atomic_set_add() no longer adds duplicates (#261). It ran SET field += $value, and on SurrealDB 2.x += on an array appends, so two calls with the same value left two copies. It now uses array::add(), which adds only the values the field does not already hold; a record without the field, or holding NULL, gets [value].
  • The atomic array helpers no longer lose writes silently. atomic_append(), atomic_remove() and atomic_set_add() returned [] when SurrealDB rejected the statement (a write conflict, a failed ASSERT, an argument of the wrong type), so the write was lost and @retry_on_conflict never retried the conflict. They now raise TransactionConflictError for a conflict and QueryError otherwise. atomic_append() on a record without the field (or holding NULL) now writes [value] instead of being rejected.
  • Behaviour change: catch QueryError / TransactionConflictError where you treated [] as "not written"; atomic_set_add() with a list value adds only the missing elements, and on a number or string field it now raises. See the CHANGELOG.

What's New in 0.21.15

Bug fix release (V2 LTS). One SDK fix, the v2 port of #166 from main; no public API change.

  • Datetimes read from SurrealDB 2.x decode to datetime (#231). Over CBOR, the default protocol, SurrealDB 2.x sends every datetime as a compact [seconds, nanoseconds] pair, and the SDK only decoded the ISO string form. A datetime-annotated model field was converted by the ORM, but raw_query() / query() results, datetimes nested in object or dict fields, and live-query payloads came back as a list of two ints. A date field could not load a datetime stored at midnight UTC, and a dict field loaded and saved back stored those lists on the server in place of its datetimes. They now decode to timezone-aware UTC datetime objects, to the microsecond.
  • One undecodable datetime no longer loses the whole message. An ISO datetime string Python cannot parse used to fail the request (ValueError over HTTP; over WebSocket the message was dropped and the request timed out). It is now returned as sent, and the rest of the message decodes. A datetime outside Python's years 1-9999 stays the raw [seconds, nanoseconds] list.
  • Behaviour change: code that read the raw list now receives a datetime; remove any workaround that converted it yourself. See the CHANGELOG.

What's New in 0.21.14

Bug fix release (V2 LTS). One SDK fix, the v2 port of #239 from main; no ORM API change.

  • live() filters match on every supported value type (#246). SurrealDB 2.x does not evaluate bound parameters in a LIVE SELECT, so v2 writes filter values into the query text, and some never matched: a dict (written as a quoted Python repr), a Decimal (a quoted string), a record link whose table or id is not a plain identifier (the string id "123" read as the number 123), and a string holding a backslash or the $name of another filter parameter. A naive datetime made the subscription fail. Each value is now written as the literal of what it binds to as a parameter, a naive datetime is read as UTC, and auto-resubscribe after a reconnect sends the same filter.
  • Binding a Decimal or a datetime.date works on SurrealDB 2.x. Both used to make the server reject the whole request (unknown CBOR tag), in save(), exec() filters and query(). A Decimal is sent as SurrealDB's decimal, a date as the datetime at midnight UTC. A Decimal above 79228162514264337593543950335 in magnitude raises ValueError before the request is sent.
  • Behaviour change: a live() filter value with no exact SurrealQL literal (set, bytes, Duration, an arbitrary object, a non-str/int Enum, a NaN or infinite Decimal, a dict with non-str keys) raises TypeError instead of being turned into a quoted string; so does a NaN or infinite float, which used to be written bare (nan, inf) and is not a number to SurrealDB. Convert it first: a set to a list, an Enum to its .value.
  • Known limitation, unchanged: live(diff=True) emits DIFF after the WHERE clause, which SurrealDB 2.7.0 rejects as a parse error. See the CHANGELOG for the full list.

What's New in 0.21.13

Bug fix release (V2 LTS). One SDK fix, backported from main; no public API change.

  • HTTP transactions no longer rename parameters a statement did not bind (#247). HTTPTransaction.commit() namespaces each statement's keys ($k → $tx_<i>_k) with a plain text replace, so a key also rewrote every longer $name it was a prefix of: binding a turned $auth.id into $tx_0_auth.id, binding s turned $session.ns into $tx_0_session.ns, and a model field whose name is a prefix of tx_<i>_<an earlier field> (t, tx, tx_, tx_<i>, ...) broke the references of the fields bound before it. On SurrealDB 2.x the renamed parameter evaluates to NONE without an error, so a WHERE silently matched nothing and a SET silently wrote nothing. Exposed over HTTP: tx.query() variables, extra_vars on save(tx=) / merge(tx=), QuerySet.variables() in atomic bulk_update() / bulk_delete(), and save(tx=) of a new record / relate(tx=) / bulk_create(atomic=True) for a model with such a field. Only whole bound $key references are renamed now. See the CHANGELOG.
  • Known limitation: tx.merge() over HTTP is still broken, for an unrelated reason (#251): its MERGE { k = $k, ... } body is not an object to SurrealDB 2.x, so with two or more fields the commit fails with a parse error, and with one field commit() returns without an error while the server rolls the whole transaction back. That covers update(tx=), merge(tx=) without a SurrealFunc, and save(tx=) of an already-saved record, on an HTTP connection (WebSocket transactions are not affected).

What's New in 0.21.12

Security maintenance release (V2 LTS). No library code changes vs 0.21.11.

  • certifi 2026.10.23 in the lock file, clearing GHSA-6q48-6c7w-9gw2 / PYSEC-2026-4320: the previous bundle (2026.5.20) still trusted emSign ECC Root CA - G3, under which eight intermediate CA keys were compromised (and revoked); 2026.10.23 no longer carries that root. certifi is a transitive dependency (through httpx) and this package does not declare it, so upgrading surrealdb-orm keeps the certifi you already have: run pip install -U certifi to get >= 2026.10.19.
  • Backfilled: 0.21.10 and 0.21.11 (2026-10-20) were auto-released with version files only. 0.21.10 — aiohttp 3.14.5 (with yarl 1.25.1) in the lock file, GHSA-2g87-pp6c-29x5, GHSA-494j-54xm-rwp3, GHSA-5wr8-gcqf-gqf3. aiohttp is a runtime dependency whose declared floor stays >=3.14.0: run pip install -U aiohttp. 0.21.11 — multidict 7.1.0 in the lock file, GHSA-54p9-h82j-f925 (transitive, through aiohttp). See the CHANGELOG.

What's New in 0.21.9

Security maintenance release (V2 LTS). No library code changes vs 0.21.8.

  • tornado 6.5.9 and urllib3 2.8.0 in the lock file, clearing 6 advisories (GHSA-chx6-46f5-w4vp, GHSA-c2m8-h5v5-343r, GHSA-3hv7-mjh2-fv65; PYSEC-2026-4175/4176/4177). Both are development-only dependencies (Jupyter tooling, the Docker client used by the test fixtures): they are not part of the installed package, so users are not exposed. See the CHANGELOG.

What's New in 0.21.8

Compatibility maintenance release (V2 LTS). No library code changes vs 0.21.7.

  • Tested against SurrealDB v2.7.0. The pin (.surrealdb-version, devops/docker-compose.yml) moves from v2.6.5 to v2.7.0, and the full unit and integration suites pass unchanged. 2.7.0 is 2.6.5 plus background reclamation of removed namespace/database data — nothing on the query, CBOR or RPC surface the ORM depends on.
  • Supported range is now SurrealDB 2.6.x – 2.7.x. The minimum is unchanged; there is no need to upgrade the server to take this release.

Maintenance releases 0.21.2 – 0.21.7

Version Date Content
0.21.7 2026-09-18 anyio 4.14.2 in the lock file (transitive, via httpx). No library changes.
0.21.6 2026-09-07 Migration fixes — see below.
0.21.5 2026-09-05 AlterField, statement errors, ForeignKey coercion — see below.
0.21.4 2026-09-01 tornado 6.5.8 in the lock file (dev-only). No library changes.
0.21.3 2026-08-11 aiohttp 3.14.3 in the lock file; tag-release CI fix. No library changes.
0.21.2 2026-07-20 Denied/missing UPDATE and merge() raise instead of silently no-opping; istartswith / iendswith are case-insensitive.

Full details in the CHANGELOG.


What's New in 0.21.6

Bug-fix maintenance release (V2 LTS) — three migration fixes ported from the 3.x main line, plus the gaps left by 0.21.5's own ForeignKey backport. Each was reproduced against a live SurrealDB 2.6.5 on this branch before being ported.

  • Table permissions now reach the database. The clause was emitted as a second DEFINE TABLE, which 2.6.x rejects once the table exists.

  • FULL is no longer written as a silent deny. Re-emitting a table defined with FOR select FULL produced FOR select WHERE FULL, which the server accepts and then evaluates as a field reference — it resolves to NONE, and a world-readable table becomes unreadable with no error anywhere.

  • Rolling back a table change no longer drops the table. The diff reused CreateTable, whose rollback is REMOVE TABLE, so a migration that changed one permission had a rollback that destroyed every row — and reported itself reversible.

  • A rollback renders a field exactly as the statement it reverses does. backwards() quoted every DEFAULT and dropped the VALUE clause entirely, which stopped an Encrypted column hashing and stored plaintext from then on.

  • The JSON protocol no longer loses a record id's type. str(RecordId) renders t:123, the numeric record, while CBOR wrote the string one — two different rows, no error, on the very protocol 0.21.5's ForeignKey backport set out to repair.

Behaviour change

makemigrations no longer writes irreversible removals by default. A RELATE edge table, an analyzer, the migration history, or a module that simply was not imported all look identical to a deleted model, so those operations are reported and held back:

$ surreal-orm makemigrations
Skipped 2 irreversible operation(s) with no model to justify them:
  - Drop table has_player
  - Remove analyzer english
Pass --drop-missing to apply them (irreversible).

Known limitation

Migration files generated before this release carry no previous_value, so their rollback still drops the VALUE clause and nothing warns. Regenerate them before relying on it.


What's New in 0.21.5

Bug-fix maintenance release (V2 LTS). Four correctness fixes backported from the 3.x main line, each reproduced against a live SurrealDB 2.6.5 on this branch before porting.

  • AlterField never altered anything. It emitted a plain DEFINE FIELD, which 2.6.x rejects for an existing field (The field 'a' already exists), leaving the definition untouched — every alter and every rollback was a silent no-op. Both emit DEFINE FIELD OVERWRITE now.

  • The executor treated a rejected statement as success. client.query() raises only on RPC-level failures; a rejected statement rides back inside a successful RPC as status: ERR. migrate(), rollback() and upgrade() now raise MigrationStatementError.

  • ManyToMany / Relation were emitted as any columns, and a str | None field reached the database as a required column. Graph relations are virtual and now define no column; nullable travels into AddField / AlterField.

  • ForeignKey values never became record links. Writes to a record<> column were rejected, and whole-record filters returned [] for rows that existed — a wrong answer, not an error:

    await Article.objects().filter(author="authors:alice").exec()  # was []
    

    Every write path and whole-record lookup converts now; the model instance, "table:id", a bare ID and a RecordId are interchangeable.

  • makemigrations diffed against an empty schema, re-emitting every table on every run. It reads the current schema from the database now.

Behaviour change. makemigrations contacts the database by default and fails with a clear error when it cannot read the schema. Use --no-from-db for a genuine first migration or when working offline.

New: to_record_id(), record_link_to_str(), instance.record_id, get_foreign_key_targets(), get_foreign_key_columns(), MigrationStatementError, AddField.from_field_state() / AlterField.from_field_states().

The SurrealDB 3.0 REFERENCE / ON DELETE work is not backported — record references are a 3.0 feature.

What's New in 0.21.1

Security maintenance release. No library code changes vs 0.21.0.

  • cbor2 stays pinned <6 - Rejected the Dependabot proposal to widen the requirement to >=5.9.0,<7 (PR #117), which would re-allow cbor2 6.x. 6.x changed the custom-tag encoding and breaks SurrealDB 2.x CBOR; the 6.x compatibility fix only landed on main (3.x) and was not backported here. The security floor (>=5.9.0) is already in place. Dependabot now ignores cbor2 major bumps on v2.
  • CI: v2 LTS releases are no longer marked GitHub "Latest" (#113) and the retirement threshold is clarified as SurrealDB-ORM-lite v0.40.0 (beta) (#114).

What's New in 0.21.0

Deprecation release. This branch is now deprecated in favor of SurrealDB-ORM-lite. It remains under security/bug-fix maintenance until SurrealDB-ORM-lite reaches v0.40.0 (beta).

  • Deprecation notice - README banner + a runtime DeprecationWarning on import surreal_orm, pointing to SurrealDB-ORM-lite (which supports SurrealDB 2.x via the official SurrealDB Python SDK v2).
  • Security: dependency upgrades - Cleared 21 known CVEs across 7 packages. Runtime floor raised to aiohttp>=3.14.0; cbor2 constrained to >=5.9.0,<6 so installs stay on a non-vulnerable 5.x and never pull the breaking 6.0 on SurrealDB 2.x.
  • Fix: 401 clock-skew retry (issue #101) - The SDK HTTP layer now re-mints the JWT and retries on a transient 401 caused by a freshly-minted token whose nbf claim is briefly in the future under host clock skew (e.g. WSL2 backward clock jumps). Backported from main.
  • Fix: transaction rollback test on SurrealDB 2.6.x - SELECT on a non-existent table returns an ERR ("table does not exist") on 2.6.x; the rollback assertion now treats that as proof no records were created.
  • Validated against SurrealDB v2.6.5 - Full unit + integration suite (362 integration tests) green.

What's New in 0.20.0

Version 0.20.0 is the first release on the V2 LTS branch, establishing the maintenance baseline for SurrealDB 2.X compatibility.

  • LTS branch setup - Automated security monitoring for SurrealDB 2.X releases
  • Branch protection - CI/CD pipeline targeting V2 branch with auto-merge for dependency updates
  • Version bump - From 0.14.4 to 0.20.0 to distinguish the LTS maintenance line

What's New in 0.14.4

Fix: Datetime Serialization Round-Trip

Python datetime objects now survive save() / merge() round-trips as native SurrealDB datetime values. Previously, datetimes were serialized as plain ISO strings, causing silent type mismatches with TYPE datetime schema fields.

from datetime import UTC, datetime

class Event(BaseSurrealModel):
    model_config = SurrealConfigDict(table_name="events")
    occurred_at: datetime | None = None

event = Event(occurred_at=datetime.now(UTC))
await event.save()  # datetime now correctly encoded via CBOR datetime tag

loaded = await Event.objects().get(event.id)
assert isinstance(loaded.occurred_at, datetime)  # True — no more plain strings

Generic QuerySet[T] — Full Type Inference

QuerySet is now generic. All terminal methods return properly typed model instances:

# Before (v0.14.3): user is Any — no type inference
user = await User.objects().get("user:alice")

# After (v0.14.4): user is User — full IDE autocomplete and mypy checking
user = await User.objects().get("user:alice")
user.name  # IDE knows this is a str

Return type is now inferred from the model_class parameter:

# Returns list[Book] — fully typed
books = await author.get_related("wrote", direction="out", model_class=Book)

# Returns list[dict[str, Any]] — raw dicts when no model_class
raw = await author.get_related("wrote", direction="out")

What's New in 0.14.3

Fix: Large Nested Dict Parameter Binding (Issue #55)

SurrealDB v2.6's CBOR parameter binding silently drops complex nested structures — dicts with nested dicts/lists arrive as {} on the server. Two fixes:

  • save() auto-routing — Complex nested data is now automatically routed through a SET-clause query path where each field is bound as a separate variable, avoiding the problematic single-object CBOR binding.

    class GameSession(BaseSurrealModel):
        model_config = SurrealConfigDict(table_name="game_sessions")
        game_state: dict | None = None  # Large nested dict (~20KB+)
    
    session = GameSession(game_state={"players": [...], "deck": [...], "nested": {...}})
    await session.save()  # Automatically uses SET-clause path
    
  • raw_query(inline_dicts=True) — New parameter that inlines complex dict/list variables as JSON in the query string, bypassing CBOR parameter binding entirely.

    large_state = {"players": [...], "deck": [...], "melds": {...}}
    results = await GameSession.raw_query(
        "UPSERT game_sessions:test SET game_state = $state",
        variables={"state": large_state},
        inline_dicts=True,  # Inlines $state as JSON in the query
    )
    

What's New in 0.14.2

Production Fixes

Five improvements from real production usage (FastAPI + SurrealDB, multi-pod K8s):

  • CBOR None → NONE Encoding — Python None is now correctly encoded as SurrealDB NONE (absent field) instead of NULL. Fixes option<T> rejection on SCHEMAFULL tables and large nested dict parameter binding failures.

  • Token Validation Cache — validate_token() now uses an in-memory TTL cache (default 300s) to avoid ephemeral HTTP connections on every call. New validate_token_local() decodes JWT locally without any network call.

    # Cached validation — no network call on cache hit
    record_id = await User.validate_token(token)
    
    # Local JWT decode — zero network calls (trusted backend only)
    record_id = User.validate_token_local(token)
    
    # Cache management
    User.configure_token_cache(ttl=600)
    User.invalidate_token_cache()
    
  • validate_assignment=True — Pydantic now auto-validates field assignments, so event.started_at = "2026-02-13T10:00:00Z" is auto-coerced to datetime.

  • flexible_fields Config — Discoverable way to mark fields as FLEXIBLE TYPE in migrations:

    class GameSession(BaseSurrealModel):
        model_config = SurrealConfigDict(
            table_name="game_sessions",
            schema_mode="SCHEMAFULL",
            flexible_fields=["game_state", "metadata"],
        )
        game_state: dict | None = None   # → DEFINE FIELD FLEXIBLE TYPE option<object>
    

What's New in 0.14.1

Typed Functions API Documentation

  • Typed Functions API in Notebook 08 — Added comprehensive db.fn.* examples covering math, string, time, crypto, and array functions, plus dynamic namespace resolution and SQL inspection. Notebook reordered from simple to complex.

    db = await SurrealDBConnectionManager.get_client()
    
    sqrt = await db.fn.math.sqrt(144)             # 12.0
    upper = await db.fn.string.uppercase("hello")  # "HELLO"
    now = await db.fn.time.now()                    # server timestamp
    sha = await db.fn.crypto.sha256("data")         # hash string
    arr = await db.fn.array.distinct([1, 2, 2, 3])  # [1, 2, 3]
    

What's New in 0.14.0

Testing & Developer Experience (Alpha → Beta)

This release transitions the ORM from Alpha to Beta and adds first-class testing and debugging utilities.

  • Test Fixtures — Declarative test data with automatic cleanup

    from surreal_orm.testing import SurrealFixture, fixture
    
    @fixture
    class UserFixtures(SurrealFixture):
        alice = User(name="Alice", role="admin")
        bob = User(name="Bob", role="player")
    
    async with UserFixtures.load() as fixtures:
        assert fixtures.alice.get_id() is not None
    # Automatic cleanup on exit
    
  • Model Factories — Factory Boy-style data generation (zero dependencies)

    from surreal_orm.testing import ModelFactory, Faker
    
    class UserFactory(ModelFactory):
        class Meta:
            model = User
    
        name = Faker("name")
        email = Faker("email")
        age = Faker("random_int", min=18, max=80)
        role = "player"
    
    user = UserFactory.build()            # In-memory (unit tests)
    user = await UserFactory.create()     # Saved to DB (integration tests)
    users = await UserFactory.create_batch(50)
    
  • QueryLogger — Profile and debug ORM queries

    from surreal_orm.debug import QueryLogger
    
    async with QueryLogger() as logger:
        users = await User.objects().filter(role="admin").exec()
        await user.save()
    
    for q in logger.queries:
        print(f"{q.sql} — {q.duration_ms:.1f}ms")
    print(f"Total: {logger.total_queries} queries, {logger.total_ms:.1f}ms")
    
  • 15 Jupyter Notebooks — Comprehensive examples covering all ORM features, from setup to testing


What's New in 0.13.0

Events, Geospatial, Materialized Views & TYPE RELATION

  • DEFINE EVENT — Server-side triggers in migrations

    from surreal_orm import DefineEvent
    
    DefineEvent(
        name="email_audit", table="users",
        when="$before.email != $after.email",
        then="CREATE audit_log SET table = 'user', record = $value.id, action = $event",
    )
    
  • Geospatial Fields — Typed geometry fields and proximity queries

    from surreal_orm.fields import PointField, PolygonField
    from surreal_orm.geo import GeoDistance
    
    class Store(BaseSurrealModel):
        name: str
        location: PointField          # geometry<point>
        delivery_area: PolygonField   # geometry<polygon>
    
    # Proximity search: stores within 5km
    nearby = await Store.objects().nearby(
        "location", (-73.98, 40.74), max_distance=5000
    ).exec()
    
    # Distance annotation
    stores = await Store.objects().annotate(
        dist=GeoDistance("location", (-73.98, 40.74)),
    ).order_by("dist").limit(10).exec()
    
  • Materialized Views — Read-only models backed by DEFINE TABLE ... AS SELECT

    class OrderStats(BaseSurrealModel):
        model_config = SurrealConfigDict(
            table_name="order_stats",
            view_query="SELECT status, count() AS total, math::sum(amount) AS revenue FROM orders GROUP BY status",
        )
        status: str
        total: int
        revenue: float
    
    # Auto-maintained by SurrealDB — read-only queries only
    stats = await OrderStats.objects().all()
    await stats[0].save()  # TypeError: Cannot modify materialized view
    
  • TYPE RELATION — Enforce graph edge constraints in migrations

    class Likes(BaseSurrealModel):
        model_config = SurrealConfigDict(
            table_type=TableType.RELATION,
            relation_in="person",
            relation_out=["blog_post", "book"],
            enforced=True,
        )
    

What's New in 0.12.0

  • Vector Similarity Search — KNN search with HNSW indexes for AI/RAG pipelines

    from surreal_orm.fields import VectorField
    
    class Document(BaseSurrealModel):
        title: str
        embedding: VectorField[1536]
    
    # KNN similarity search (top 10 nearest neighbours)
    docs = await Document.objects().similar_to(
        "embedding", query_vector, limit=10
    ).exec()
    
    # Combined with filters
    docs = await Document.objects().filter(
        category="science"
    ).similar_to("embedding", query_vector, limit=5).exec()
    
  • Full-Text Search — BM25 scoring, highlighting, and multi-field search

    from surreal_orm import SearchScore, SearchHighlight
    
    results = await Post.objects().search(title="quantum").annotate(
        relevance=SearchScore(0),
        snippet=SearchHighlight("<b>", "</b>", 0),
    ).exec()
    
  • Hybrid Search — Reciprocal Rank Fusion combining vector + FTS

    results = await Document.objects().hybrid_search(
        vector_field="embedding", vector=query_vec, vector_limit=20,
        text_field="content", text_query="machine learning", text_limit=20,
    )
    
  • Analyzer & Index Operations — DefineAnalyzer, HNSW and BM25 index support in migrations


What's New in 0.11.0

Advanced Queries & Caching

  • Subqueries — Embed a QuerySet as a filter value in another QuerySet
  • Query Cache — TTL-based caching with automatic invalidation on writes
  • Prefetch Objects — Fine-grained control over related data prefetching

What's New in 0.10.0

Schema Introspection & Multi-Database Support

  • Schema Introspection - Generate Python model code from an existing SurrealDB database

    from surreal_orm import generate_models_from_db, schema_diff
    
    # Generate Python model code from existing database
    code = await generate_models_from_db(output_path="models.py")
    
    # Compare Python models against live database schema
    operations = await schema_diff(models=[User, Order, Product])
    for op in operations:
        print(op)  # Migration operations needed to sync
    
    • DatabaseIntrospector parses INFO FOR DB / INFO FOR TABLE into SchemaState
    • ModelCodeGenerator converts SchemaState to fully-typed Python model source code
    • Handles generic types (array<string>, option<int>, record<users>), VALUE/ASSERT expressions, encrypted fields, FLEXIBLE, READONLY
    • CLI: surreal-orm inspectdb and surreal-orm schemadiff
  • Multi-Database Support - Named connection registry for routing models to different databases

    from surreal_orm import SurrealDBConnectionManager
    
    # Register named connections
    SurrealDBConnectionManager.add_connection("default", url=..., ns=..., db=...)
    SurrealDBConnectionManager.add_connection("analytics", url=..., ns=..., db=...)
    
    # Model-level routing
    class AnalyticsEvent(BaseSurrealModel):
        model_config = SurrealConfigDict(connection="analytics")
    
    # Context manager override (async-safe)
    async with SurrealDBConnectionManager.using("analytics"):
        events = await AnalyticsEvent.objects().all()
    
    • ConnectionConfig frozen dataclass for immutable connection settings
    • using() async context manager with contextvars for async safety
    • Full backward compatibility: set_connection() delegates to add_connection("default", ...)
    • list_connections(), get_config(), remove_connection() registry management

What's New in 0.9.0

ORM Real-time Features: Live Models + Change Feed

  • Live Models - Real-time subscriptions at the ORM level yielding typed Pydantic model instances

    from surreal_orm import LiveAction
    
    async with User.objects().filter(role="admin").live() as stream:
        async for event in stream:
            match event.action:
                case LiveAction.CREATE:
                    print(f"New admin: {event.instance.name}")
                case LiveAction.UPDATE:
                    print(f"Updated: {event.instance.email}")
                case LiveAction.DELETE:
                    print(f"Removed: {event.record_id}")
    
    • ModelChangeEvent[T] with typed instance, action, record_id, changed_fields
    • Full QuerySet filter integration (WHERE clause + parameterized variables)
    • auto_resubscribe=True for seamless WebSocket reconnect recovery
    • diff=True for receiving only changed fields
  • Change Feed Integration - HTTP-based CDC for event-driven microservices

    async for event in User.objects().changes(since="2026-01-01"):
        await publish_to_queue({
            "type": f"user.{event.action.value.lower()}",
            "data": event.raw,
        })
    
    • Stateless, resumable with cursor tracking
    • Configurable poll_interval and batch_size
    • No WebSocket required (works over HTTP)
  • post_live_change signal - Fires for external database changes (separate from local CRUD signals)

    from surreal_orm import post_live_change, LiveAction
    
    @post_live_change.connect(Player)
    async def on_player_change(sender, instance, action, **kwargs):
        if action == LiveAction.CREATE:
            await ws_manager.broadcast({"type": "player_joined", "name": instance.name})
    
  • WebSocket Connection Manager - get_ws_client() creates a lazy WebSocket connection alongside HTTP


What's New in 0.8.0

Auth Module Fixes + Computed Fields

  • Ephemeral Auth Connections (Critical) - signup(), signin(), and authenticate_token() no longer corrupt the singleton connection. They use isolated ephemeral connections.

  • Configurable Access Name - Access name is configurable via access_name in SurrealConfigDict (was hardcoded to {table}_auth)

  • signup() Returns Token - Now returns tuple[Self, str] (user + JWT token), matching signin()

    user, token = await User.signup(email="alice@example.com", password="secret", name="Alice")
    
  • authenticate_token() Fixed + validate_token() - Fixed token validation with new validate_token() lightweight method

    result = await User.authenticate_token(token)  # Full: (user, record_id)
    record_id = await User.validate_token(token)    # Lightweight: just record_id
    
  • Computed Fields - Server-side computed fields using SurrealDB's DEFINE FIELD ... VALUE <expression>

    from surreal_orm import Computed
    
    class User(BaseSurrealModel):
        first_name: str
        last_name: str
        full_name: Computed[str] = Computed("string::concat(first_name, ' ', last_name)")
    
    class Order(BaseSurrealModel):
        items: list[dict]
        discount: float = 0.0
        subtotal: Computed[float] = Computed("math::sum(items.*.price * items.*.qty)")
        total: Computed[float] = Computed("subtotal * (1 - discount)")
    
    • Computed[T] defaults to None (server computes the value)
    • Auto-excluded from save()/merge() via get_server_fields()
    • Migration introspector auto-generates DEFINE FIELD ... VALUE <expression>

What's New in 0.7.0

Performance & Developer Experience

  • merge(refresh=False) - Skip the extra SELECT round-trip for fire-and-forget updates

    await user.merge(last_seen=SurrealFunc("time::now()"), refresh=False)
    
  • call_function() - Invoke custom SurrealDB stored functions from the ORM

    result = await SurrealDBConnectionManager.call_function(
        "acquire_game_lock", params={"table_id": tid, "pod_id": pid},
    )
    result = await GameTable.call_function("release_game_lock", params={...})
    
  • extra_vars on save() - Bind additional query variables for SurrealFunc expressions

    await user.save(
        server_values={"password_hash": SurrealFunc("crypto::argon2::generate($password)")},
        extra_vars={"password": raw_password},
    )
    
  • fetch() / FETCH clause - Resolve record links inline to prevent N+1 queries

    posts = await Post.objects().fetch("author", "tags").exec()
    # Generates: SELECT * FROM posts FETCH author, tags;
    
  • remove_all_relations() list support - Remove multiple relation types in one call

    await table.remove_all_relations(["has_player", "has_action"], direction="out")
    

What's New in 0.6.0

Query Power, Security & Server-Side Functions

  • Q Objects for Complex Queries - Django-style composable query expressions with OR/AND/NOT

    from surreal_orm import Q
    
    # OR query
    users = await User.objects().filter(
        Q(name__contains="alice") | Q(email__contains="alice"),
    ).exec()
    
    # NOT + mixed with regular kwargs
    users = await User.objects().filter(
        ~Q(status="banned"), role="admin",
    ).order_by("-created_at").exec()
    
  • Parameterized Filters (Security) - All filter values are now query variables ($_fN)

    • Prevents SQL injection by never embedding values in query strings
    • Existing $variable references via .variables() still work
  • SurrealFunc for Server-Side Functions - Embed SurrealQL expressions in save/update

    from surreal_orm import SurrealFunc
    
    await player.save(server_values={"joined_at": SurrealFunc("time::now()")})
    await player.merge(last_ping=SurrealFunc("time::now()"))
    
  • remove_all_relations() - Bulk relation deletion with direction support

    await table.remove_all_relations("has_player", direction="out")
    await user.remove_all_relations("follows", direction="both")
    
  • Django-style -field Ordering - Shorthand for descending order

    users = await User.objects().order_by("-created_at").exec()
    
  • Bug Fix: isnull Lookup - filter(field__isnull=True) now generates IS NULL instead of IS True


What's New in 0.5.x

v0.5.9 - Concurrent Safety, Relation Direction & Array Filtering

  • Atomic Array Operations - Server-side array mutations avoiding read-modify-write conflicts

    • atomic_append(), atomic_remove(), atomic_set_add() class methods
    • Ideal for multi-pod K8s deployments with concurrent workers
    # No more transaction conflicts on concurrent array updates:
    await Event.atomic_set_add(event_id, "processed_by", pod_id)
    
  • Transaction Conflict Retry - retry_on_conflict() decorator with exponential backoff + jitter

    • TransactionConflictError exception for conflict detection
    from surreal_orm import retry_on_conflict
    
    @retry_on_conflict(max_retries=5)
    async def process_event(event_id, pod_id):
        await Event.atomic_set_add(event_id, "processed_by", pod_id)
    
  • Relation Direction Control - reverse parameter on relate() and remove_relation()

    # Reverse: users:xyz -> created -> game_tables:abc
    await table.relate("created", creator, reverse=True)
    
  • New Query Lookup Operators - Server-side array filtering

    • not_contains (CONTAINSNOT), containsall (CONTAINSALL), containsany (CONTAINSANY), not_in (NOT IN)
    events = await Event.objects().filter(processed_by__not_contains=pod_id).exec()
    

v0.5.8 - Around Signals (Generator-based middleware)

  • Around Signals - Generator-based middleware pattern for wrapping DB operations

    • around_save, around_delete, around_update
    • Shared state between before/after phases (local variables)
    • Guaranteed cleanup with try/finally
    from surreal_orm import around_save
    
    @around_save.connect(Player)
    async def time_save(sender, instance, created, **kwargs):
        start = time.time()
        yield  # save happens here
        print(f"Saved {instance.id} in {time.time() - start:.3f}s")
    
    @around_delete.connect(Player)
    async def delete_with_lock(sender, instance, **kwargs):
        lock = await acquire_lock(instance.id)
        try:
            yield  # delete happens while lock is held
        finally:
            await release_lock(lock)  # Always runs
    

    Execution order: pre_* → around(before) → DB → around(after) → post_*

v0.5.7 - Model Signals

  • Django-style Model Signals - Event hooks for model lifecycle operations

    • pre_save, post_save - Before/after save operations
    • pre_delete, post_delete - Before/after delete operations
    • pre_update, post_update - Before/after update/merge operations
    from surreal_orm import post_save, Player
    
    @post_save.connect(Player)
    async def on_player_saved(sender, instance, created, **kwargs):
        if instance.is_ready:
            await ws_manager.broadcast({"type": "player_ready", "id": instance.id})
    

v0.5.6 - Relation Query ID Escaping Fix

  • Fixed ID escaping in relation queries - When using get_related(), RelationQuerySet, or graph traversal with IDs starting with digits, queries now properly escape the IDs with backticks, preventing parse errors.

v0.5.5.3 - RecordId Conversion Fix

  • Fixed RecordId objects in foreign key fields - When using CBOR protocol, fields like user_id, table_id are now properly converted to "table:id" strings instead of raw RecordId objects, preventing Pydantic validation errors.

v0.5.5.2 - Datetime Regression Fix

  • Fixed datetime_type Pydantic validation error - v0.5.5.1 introduced a regression where records with datetime fields failed validation, causing from_db() to return dicts instead of model instances
  • New _preprocess_db_record() method - Properly handles datetime parsing and RecordId conversion before Pydantic validation

v0.5.5.1 - Critical Bug Fixes

  • Record ID escaping - IDs starting with digits (e.g., 7abc123) now properly escaped with backticks
  • CBOR for HTTP connections - HTTP connections now default to CBOR protocol, fixing data: prefix issues
  • get() full ID format - QuerySet.get("table:id") now correctly parses and queries
  • get_related() direction="in" - Fixed to return actual related records instead of empty results
  • update() table name - Fixed bug where custom table_name was ignored

v0.5.5 - CBOR Protocol & Field Aliases

  • CBOR Protocol (Default) - Binary protocol for WebSocket connections
    • cbor2 is now a required dependency
    • CBOR is the default protocol for WebSocket (fixes data: prefix string issues)
    • Aligns with official SurrealDB SDK behavior
  • unset_connection_sync() - Synchronous version for non-async cleanup contexts
  • Field Alias Support - Map Python field names to different DB column names
    • Use Field(alias="db_column") to store under a different name in DB

v0.5.4 - API Improvements

  • Record ID format handling - QuerySet.get() accepts both "abc123" and "table:abc123"
  • remove_relation() accepts string IDs - Pass string IDs instead of model instances
  • raw_query() class method - Execute arbitrary SurrealQL from model class

v0.5.3.3 - Bug Fix

  • from_db() fields_set fix - Fixed bug where DB-loaded fields were incorrectly included in updates via exclude_unset=True

v0.5.3.2 - Critical Bug Fix

  • QuerySet table name fix - Fixed critical bug where QuerySet used class name instead of table_name from config
  • QuerySet.get() signature - Now accepts id= keyword argument in addition to positional id_item

v0.5.3.1 - Bug Fixes

  • Partial updates for persisted records - save() now uses merge() for already-persisted records, only sending modified fields
  • datetime parsing - _update_from_db() now parses ISO 8601 strings to datetime objects automatically
  • _db_persisted flag - Internal tracking to distinguish new vs persisted records

v0.5.3 - ORM Improvements

  • Upsert save behavior - save() now uses upsert for new records with ID (idempotent, Django-like)
  • server_fields config - Exclude server-generated fields (created_at, updated_at) from saves
  • merge() returns self - Now returns the updated model instance instead of None
  • save() updates self - Updates original instance attributes instead of returning new object
  • NULL values fix - exclude_unset=True now works correctly after loading from DB

v0.5.2 - Bug Fixes & FieldType Improvements

  • FieldType enum - Enhanced migration type system with generic() and from_python_type() methods
  • datetime serialization - Proper JSON encoding for datetime, date, time, Decimal, UUID
  • Fluent API - connect() now returns self for method chaining
  • Session cleanup - WebSocket callback tasks properly tracked and cancelled
  • Optional fields - exclude_unset=True prevents None from overriding DB defaults
  • Parameter alias - username parameter alias for user in ConnectionManager

v0.5.1 - Security Workflows

  • Dependabot integration - Automatic dependency security updates
  • Auto-merge - Dependabot PRs merged after CI passes
  • SurrealDB monitoring - Integration tests on new SurrealDB releases

v0.5.0 - Real-time SDK Enhancements

  • Live Select Stream - Async iterator pattern for real-time changes
    • async with db.live_select("table") as stream: async for change in stream:
    • LiveChange dataclass with record_id, action, result, changed_fields
    • WHERE clause support with parameterized queries
  • Auto-Resubscribe - Automatic reconnection after WebSocket disconnect
    • auto_resubscribe=True parameter for seamless K8s pod restart recovery
    • on_reconnect(old_id, new_id) callback for tracking ID changes
  • Typed Function Calls - Pydantic/dataclass return type support
    • await db.call("fn::my_func", params={...}, return_type=MyModel)

v0.4.0 - Relations & Graph

  • Relations & Graph Traversal - Django-style relation definitions with SurrealDB graph support
    • ForeignKey, ManyToMany, Relation field types
    • Relation operations: add(), remove(), set(), clear(), all(), filter(), count()
    • Model methods: relate(), remove_relation(), get_related()
    • QuerySet extensions: select_related(), prefetch_related(), traverse(), graph_query()

Table of Contents


Installation

# Basic installation (includes CBOR support)
pip install surrealdb-orm

# With CLI support
pip install surrealdb-orm[cli]

Requirements: Python 3.12+ | SurrealDB 2.6.x – 2.7.x (< 3.0; tested against v2.7.0)

Included: pydantic, httpx, aiohttp, cbor2 (CBOR is the default protocol for WebSocket)


Quick Start

from surreal_sdk import SurrealDB

async def main():
    # HTTP connection (stateless, ideal for microservices)
    async with SurrealDB.http("http://localhost:8000", "namespace", "database") as db:
        await db.signin("root", "root")

        # CRUD operations
        user = await db.create("users", {"name": "Alice", "age": 30})
        users = await db.query("SELECT * FROM users WHERE age > $min", {"min": 18})

        # Atomic transactions
        async with db.transaction() as tx:
            await tx.create("accounts:alice", {"balance": 1000})
            await tx.create("accounts:bob", {"balance": 500})
            # Auto-commit on success, auto-rollback on exception

        # Built-in functions with typed API
        result = await db.fn.math.sqrt(16)  # Returns 4.0
        now = await db.fn.time.now()        # Current timestamp

Using the ORM

from surreal_orm import BaseSurrealModel, SurrealDBConnectionManager

# 1. Define your model
class User(BaseSurrealModel):
    id: str | None = None
    name: str
    email: str
    age: int = 0

# 2. Configure connection
SurrealDBConnectionManager.set_connection(
    url="http://localhost:8000",
    user="root",
    password="root",
    namespace="myapp",
    database="main",
)

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

users = await User.objects().filter(age__gte=18).order_by("name").limit(10).exec()

SDK Features

Connections

Type Use Case Features
HTTP Microservices, REST APIs Stateless, simple
WebSocket Real-time apps Live queries, persistent
Pool High-throughput Connection reuse
from surreal_sdk import SurrealDB, HTTPConnection, WebSocketConnection

# HTTP (stateless)
async with SurrealDB.http("http://localhost:8000", "ns", "db") as db:
    await db.signin("root", "root")

# WebSocket (stateful, real-time)
async with SurrealDB.ws("ws://localhost:8000", "ns", "db") as db:
    await db.signin("root", "root")
    await db.live("orders", callback=on_order_change)

# Connection Pool
async with SurrealDB.pool("http://localhost:8000", "ns", "db", size=10) as pool:
    await pool.set_credentials("root", "root")
    async with pool.acquire() as conn:
        await conn.query("SELECT * FROM users")

Transactions

Atomic transactions with automatic commit/rollback:

# WebSocket: Immediate execution with server-side transaction
async with db.transaction() as tx:
    await tx.update("players:abc", {"is_ready": True})
    await tx.update("game_tables:xyz", {"ready_count": "+=1"})
    # Statements execute immediately
    # COMMIT on success, CANCEL on exception

# HTTP: Batched execution (all-or-nothing)
async with db.transaction() as tx:
    await tx.create("orders:1", {"total": 100})
    await tx.create("payments:1", {"amount": 100})
    # Statements queued, executed atomically at commit

Transaction Methods:

  • tx.query(sql, vars) - Execute raw SurrealQL
  • tx.create(thing, data) - Create record
  • tx.update(thing, data) - Replace record
  • tx.delete(thing) - Delete record
  • tx.relate(from, edge, to) - Create graph edge
  • tx.commit() - Explicit commit
  • tx.rollback() - Explicit rollback

Typed Functions

Fluent API for SurrealDB functions:

# Built-in functions (namespace::function)
sqrt = await db.fn.math.sqrt(16)           # 4.0
now = await db.fn.time.now()               # datetime
length = await db.fn.string.len("hello")   # 5
sha = await db.fn.crypto.sha256("data")    # hash string

# Custom user-defined functions (fn::function)
result = await db.fn.my_custom_function(arg1, arg2)
# Executes: RETURN fn::my_custom_function($arg0, $arg1)

Available Namespaces: array, crypto, duration, geo, http, math, meta, object, parse, rand, session, string, time, type, vector

Live Queries

Real-time updates via WebSocket:

from surreal_sdk import LiveAction

# Async iterator pattern (recommended)
async with db.live_select(
    "orders",
    where="status = $status",
    params={"status": "pending"},
    auto_resubscribe=True,  # Auto-reconnect on WebSocket drop
) as stream:
    async for change in stream:
        match change.action:
            case LiveAction.CREATE:
                print(f"New order: {change.result}")
            case LiveAction.UPDATE:
                print(f"Updated: {change.record_id}")
            case LiveAction.DELETE:
                print(f"Deleted: {change.record_id}")

# Callback-based pattern
from surreal_sdk import LiveQuery, LiveNotification

async def on_change(notification: LiveNotification):
    print(f"{notification.action}: {notification.result}")

live = LiveQuery(ws_conn, "orders")
await live.subscribe(on_change)
# ... record changes trigger callbacks ...
await live.unsubscribe()

Typed Function Calls:

from pydantic import BaseModel

class VoteResult(BaseModel):
    success: bool
    count: int

# Call SurrealDB function with typed return
result = await db.call(
    "cast_vote",
    params={"user": "alice", "vote": "yes"},
    return_type=VoteResult
)
print(result.success, result.count)  # Typed access

ORM Features

Live Models (Real-time at ORM Level)

from surreal_orm import LiveAction

# Subscribe to model changes with full Pydantic instances
async with User.objects().filter(role="admin").live() as stream:
    async for event in stream:
        print(event.action, event.instance.name, event.record_id)

# Change Feed (HTTP, no WebSocket needed)
async for event in Order.objects().changes(since="2026-01-01"):
    print(event.action, event.instance.total)

QuerySet with Django-style Lookups

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

# Supported lookups
# exact, gt, gte, lt, lte, in, not_in, like, ilike,
# contains, icontains, not_contains, containsall, containsany,
# startswith, istartswith, endswith, iendswith, match, regex, isnull

# Q objects for complex OR/AND/NOT queries
from surreal_orm import Q
users = await User.objects().filter(
    Q(name__contains="alice") | Q(email__contains="alice"),
    role="admin",
).order_by("-created_at").limit(10).exec()

ORM Transactions

from surreal_orm import SurrealDBConnectionManager

# Via ConnectionManager
async with SurrealDBConnectionManager.transaction() as tx:
    user = User(name="Alice", balance=1000)
    await user.save(tx=tx)

    order = Order(user_id=user.id, total=100)
    await order.save(tx=tx)
    # Auto-commit on success, auto-rollback on exception

# Via Model class method
async with User.transaction() as tx:
    await user1.save(tx=tx)
    await user2.delete(tx=tx)

Aggregations

# Simple aggregations
total = await User.objects().count()
total = await User.objects().filter(active=True).count()

# Field aggregations
avg_age = await User.objects().avg("age")
total = await Order.objects().filter(status="paid").sum("amount")
min_val = await Product.objects().min("price")
max_val = await Product.objects().max("price")

GROUP BY with Aggregations

from surreal_orm import Count, Sum, Avg

# Group by single field
stats = await Order.objects().values("status").annotate(
    count=Count(),
    total=Sum("amount"),
).exec()
# Result: [{"status": "paid", "count": 42, "total": 5000}, ...]

# Group by multiple fields
monthly = await Order.objects().values("status", "month").annotate(
    count=Count(),
).exec()

Bulk Operations

# Bulk create
users = [User(name=f"User{i}") for i in range(100)]
created = await User.objects().bulk_create(users)

# Atomic bulk create (all-or-nothing)
created = await User.objects().bulk_create(users, atomic=True)

# Bulk update
updated = await User.objects().filter(status="pending").bulk_update(
    {"status": "active"}
)

# Bulk delete
deleted = await User.objects().filter(status="deleted").bulk_delete()

Table Types

Type Description
NORMAL Standard table (default)
USER Auth table with JWT support
STREAM Real-time with CHANGEFEED
HASH Lookup/cache (SCHEMALESS)
from surreal_orm import BaseSurrealModel, SurrealConfigDict
from surreal_orm.types import TableType

class User(BaseSurrealModel):
    model_config = SurrealConfigDict(
        table_type=TableType.USER,
        permissions={"select": "$auth.id = id"},
    )

JWT Authentication

from surreal_orm.auth import AuthenticatedUserMixin
from surreal_orm.fields import Encrypted

class User(AuthenticatedUserMixin, BaseSurrealModel):
    model_config = SurrealConfigDict(table_type=TableType.USER)
    email: str
    password: Encrypted  # Auto-hashed with argon2
    name: str

# Signup
user = await User.signup(email="alice@example.com", password="secret", name="Alice")

# Signin
user, token = await User.signin(email="alice@example.com", password="secret")

# Validate token
user = await User.authenticate_token(token)

CLI Commands

Requires pip install surrealdb-orm[cli]

Command Description
makemigrations Generate migration files
migrate Apply schema migrations
rollback <target> Rollback to migration
status Show migration status
shell Interactive SurrealQL shell
inspectdb Generate models from existing database
schemadiff Compare models against live schema
# Generate and apply migrations
surreal-orm makemigrations --name initial
surreal-orm migrate -u http://localhost:8000 -n myns -d mydb

# Environment variables supported
export SURREAL_URL=http://localhost:8000
export SURREAL_NAMESPACE=myns
export SURREAL_DATABASE=mydb
surreal-orm migrate

Documentation

Document Description
SDK Guide Full SDK documentation
Migration System Django-style migrations
Authentication JWT authentication guide
Roadmap Future features planning
CHANGELOG Version history

Contributing

# Clone and install
git clone https://github.com/EulogySnowfall/SurrealDB-ORM.git
cd SurrealDB-ORM
uv sync

# Run tests (SurrealDB container managed automatically)
make test              # Unit tests only
make test-integration  # With integration tests

# Start SurrealDB manually
make db-up             # Test instance (port 8001)
make db-dev            # Dev instance (port 8000)

# Lint
make ci-lint           # Run all linters

SurrealDB-ORM-lite

A lightweight Django-style ORM built on the official SurrealDB Python SDK.

Feature SurrealDB-ORM SurrealDB-ORM-lite
SDK Custom (surreal_sdk) Official surrealdb
Live Queries Full support Limited
CBOR Protocol Default SDK-dependent
Transactions Full support Basic
Typed Functions Yes No

Choose SurrealDB-ORM-lite if you prefer to use the official SDK with basic ORM features.

pip install surreal-orm-lite

License

MIT License - See LICENSE file.


Author: Yannick Croteau | GitHub: EulogySnowfall

Metadata

Release files for surrealdb-orm 0.21.22

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

Source distribution (sdist)

Source distribution for surrealdb-orm 0.21.22
File Size Uploaded Provenance
surrealdb_orm-0.21.22.tar.gz 225.0 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for surrealdb-orm 0.21.22
File Interpreter ABI Platform
surrealdb_orm-0.21.22-py3-none-any.whl Python 3 none any Details

Total release size: 463.6 kB

Release files / surrealdb_orm-0.21.22.tar.gz

Download URL surrealdb_orm-0.21.22.tar.gz
Size 225.0 kB
Tags Source
SHA-256 checksum
How to use checksums
e06ab9d239afefc37f290261e402f456b4b7edb513e688877da0a05a4918012f
BLAKE2b-256 checksum
How to use checksums
b7dd0ce55a5f8fec17b7a59b661ffc32cda7a2096f1a6c920c3321b5b112179e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

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 Oct 28, 2026.

Transparency log

Release files / surrealdb_orm-0.21.22-py3-none-any.whl

Download URL surrealdb_orm-0.21.22-py3-none-any.whl
Size 238.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fa83f30051539832cc83b844afaff683e33881739a88347c5e5847490d4fe309
BLAKE2b-256 checksum
How to use checksums
4fd57e25813f2be045d85000c71a028874993b2a6e6fc8200127e1a893114afc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

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 Oct 28, 2026.

Transparency log

Security for surrealdb-orm 0.21.22

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.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for surrealdb_orm-0.21.22.tar.gz

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 Oct 28, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.21.22 This release

2 release files Full provenance

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