Skip to main content

SurrealDB-ORM

Python CI codecov GitHub License

⚠️ BREAKING CHANGE in 0.32.0 — SurrealDB 3.2+ is now required

SurrealDB 3.1.x and earlier are no longer supported. Upgrade your database to 3.2.4+ before upgrading the ORM, or pin the ORM to the version matching your server:

Your SurrealDB Install
3.2+ pip install surrealdb-orm (0.32.x)
3.0 – 3.1 pip install "surrealdb-orm<0.32"
2.x pip install "surrealdb-orm<0.30" — or the v2 branch (0.21.x, security + critical fixes only)

Subquery also emits different SQL in 0.32.0 (a LET prelude). The Python API and the results are unchanged — only code asserting on the generated SQL string is affected. See What's New in 0.32.0 and CHANGELOG.md.

This is a Beta — core APIs are stabilizing. Feedback welcome!

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 ORM Version Status
main 3.2.4 0.33.x Active development
v2 2.6.5 0.21.x LTS (security & bug fixes only)

Both branches receive automated daily security monitoring from main (GitHub Actions only runs cron workflows from the default branch).


What's New in 0.33.3

Bug fix release — one defect at the boundary where a value is written into a query string instead of being bound, with two call sites (#193).

  • Inlined JSON was parsed as a regex replacement pattern. inline_dict_variables() handed the serialised JSON to re.sub as a replacement string, which re.sub reads as a mini-pattern: \1 is a backreference, \g<0> a group reference, an unknown escape an error. json.dumps emits \uXXXX for every non-ASCII character, so raw_query(inline_dicts=True) raised on any accented value — for most real data, "always", not "in an edge case":

    inline_dict_variables("UPDATE t SET a = $v;", {"v": {"meta": {"k": 1}, "p": "café"}})
    # re.error: bad escape \u at position 28
    

    The backslash half failed silently, which is the worse one: json.dumps doubles every literal backslash and the replacement parsing collapsed it back, so a Windows path reached the database altered. Values are inserted verbatim now, through a callable replacement.

  • Live-query filters shared the defect. LiveSelectStream._inline_params_static passed a rendered value to re.sub the same way. _format_value doubles backslashes on purpose and the parsing undid it, so SurrealQL read \t as a tab and a QuerySet.live() filter containing a backslash silently matched nothing.

  • A later variable could rewrite text inside an earlier one. Substituting key by key re-scanned the JSON just inserted, so a $b inside a string value of $a was replaced too, with the winner depending on dict insertion order. One pass over the original query now.

  • Astral characters were rejected by the server. The ensure_ascii=True default emits a UTF-16 surrogate pair, which SurrealDB 3.x refuses at parse time. Invisible to a json.loads round-trip assertion — surrogate pairs are valid JSON — so the test asserts on the emitted text. ensure_ascii=False sends the character raw.

  • An unreferenced complex variable was silently dropped, and a lone surrogate raised a UnicodeEncodeError from inside the CBOR encoder instead of the documented ValueError. Both fixed.

  • Datetime markers expand in one pass — 5,000 datetimes in an 0.8 MB payload went from 2.80 s to 0.023 s.

  • New public SDK API — substitute_params() and find_param_references(), the shared primitive both call sites use. It lives in surreal_sdk because the dependency only runs one way.

  • Known follow-ups — #204 (HTTPTransaction.commit() renames $auth and every other built-in whose name a bound key prefixes, on every save(tx=) over HTTP) and #205 (on main, LIVE SELECT binds parameters correctly on 3.2.4, so the inline renderers are a 2.x workaround the branch no longer needs).

What's New in 0.33.2

Bug fix release — three migration defects found reviewing what 0.33.1 shipped. Two are the dangerous kind: they produce a wrong answer rather than an error.

  • Table permissions now reach the database. The PERMISSIONS clause was emitted as a second DEFINE TABLE, which SurrealDB rejects once the table exists, so a model declaring permissions={...} either failed loudly (0.33.1) or silently stored PERMISSIONS NONE.

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

    DEFINE TABLE pw PERMISSIONS FOR select WHERE FULL;   -- status OK
    RETURN !!FULL;                                       -- false
    
  • 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. Redefinition is now AlterTable, which carries the definition it replaced and restores it.

  • A rollback renders a field exactly as the statement it reverses does. backwards() quoted every DEFAULT, so time::now() came back as the string 'time::now()', True as True rather than true, and an apostrophe produced a parse error. It also dropped the VALUE clause entirely, which stopped an Encrypted column hashing and stored plaintext from then on.

  • Materialized views, TYPE USER tables, COMMENTs and relation lists survive a redefinition instead of being flattened, rejected as a parse error, silently dropped, or rendered as OUT ['a', 'b'].

Behaviour change

makemigrations no longer writes irreversible removals by default. A database object with no model is not necessarily obsolete — a RELATE edge table, an analyzer, the migration history, or a module that simply was not imported all look identical to a deleted model. Those operations are now 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).

Use --drop-missing to opt in.

Known limitation

Migration files generated between 0.32.6 and 0.33.1 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.33.1

Bug fix release (#171). makemigrations regenerated the whole schema on every run, by two independent routes.

  • It diffed against an empty schema. The current state was a bare SchemaState(), so every table was re-emitted every time. It now reads the current schema through DatabaseIntrospector — the same path schemadiff already used. This only became viable once 0.33.0 fixed the producer/parser symmetry.
  • A table's default permissions read as a configured value. SurrealDB reports {"select": "NONE", ...} for a table defined with no PERMISSIONS clause; comparing that against a model configuring nothing made every table look changed, emitting a redundant CreateTable. Both sides are normalised now.

Behaviour change. makemigrations contacts the database by default and fails with a clear error when it cannot read the schema, rather than silently falling back to an empty one — that fallback is the bug. Use --no-from-db for a genuine first migration or when working offline:

surreal-orm makemigrations --name initial --no-from-db

Generating migrations offline from the replayed migration history is tracked in #186.

What's New in 0.33.0

Migration introspection release (#170). Relation fields now survive the trip from a model to a DEFINE FIELD statement, and on_delete becomes something SurrealDB enforces rather than a decorative annotation.

  • ForeignKey and ReferencesField introspected as any (#170) — _introspect_field never unwrapped Annotated, so the record link was lost entirely. FieldState, the operations and the DB-side parser all already carried reference / on_delete; this one producer was the only thing that never filled them.

    class Post(BaseSurrealModel):
        author: ForeignKey("User")
        citations: ReferencesField["posts"]
    
    # Before: DEFINE FIELD author ON posts TYPE any;
    # After:  DEFINE FIELD author ON posts TYPE option<record<users>> REFERENCE ON DELETE CASCADE;
    #         DEFINE FIELD citations ON posts TYPE option<array<record<posts>>> REFERENCE;
    
  • ManyToMany / Relation emitted real columns — they are virtual (graph edges live in their own tables) and now define no column at all.

  • Nullability stopped at the diff boundary — AddField / AlterField had no nullable parameter, so only define_table() preserved optionality and generated migrations silently lost it. AlterField also gained previous_nullable, previous_reference and previous_on_delete, without which a rollback quietly dropped the REFERENCE clause.

  • schema_diff() never converged for non-CASCADE strategies — the model stored Django's SET_NULL while the database reported SurrealDB's UNSET, and the states compare raw strings. FieldState now stores the keyword the database reports back.

  • inspectdb turned a scalar foreign key into an array — every reference=True field was mapped to ReferencesField (array<record<T>>), so a generated model re-introspected into a destructive scalar→array AlterField. Arity now decides: scalar record<T> → ForeignKey.

New: OnDelete, a public type alias exported from surreal_orm — ForeignKey accepts SurrealDB's vocabulary (UNSET, REJECT, IGNORE) alongside Django's. Plus AddField.from_field_state() / AlterField.from_field_states(), the single place a FieldState becomes operation arguments.

Behaviour change. on_delete used to be decorative — the column landed as any with no REFERENCE clause, so nothing was applied. SurrealDB enforces it now, so a ForeignKey left at the default on_delete="CASCADE" means deleting the referenced record deletes the referencing one. Use on_delete="IGNORE" for a link that should stay decorative.

On upgrading, the first schema_diff / makemigrations emits one-time REMOVE FIELD statements for the ManyToMany / Relation columns existing databases physically carry, plus any -> option<record<...>> alters. No data is lost — the ORM never read those columns — but review the generated migration rather than applying it blind.

What's New in 0.32.7

Bug fix release — foreign key values never reached the database as record links, and Django's on_delete vocabulary generated invalid DDL. Both were reported in #169.

  • ForeignKey values are coerced to RecordId (#169, #174). A ForeignKey holds a "table:id" string in Python, but nothing converted it at the wire boundary — so a record<> column rejected the write, and a filter compared a string against a record value and quietly returned nothing:

    # Before: rejected — a record<fk_authors> column will not take a string
    await Article(title="Hello", author="fk_authors:alice").save()
    # Before: the row exists, yet the filter matches nothing
    await Article.objects().filter(author="fk_authors:alice").exec()  # []
    

    Every write path (save, merge, update, bulk_update, upsert, bulk_upsert) and every whole-record lookup (exact, in, not_in, including inside Q) now converts the value. Four interchangeable forms are accepted, mixable within one collection:

    Article(author=alice)                                   # model instance
    Article(author="fk_authors:alice")                      # full "table:id"
    Article(author="alice")                                 # bare ID, qualified via the field
    Article(author=RecordId(table="fk_authors", id="alice"))
    
    Article.objects().filter(author__in=[alice, "bob", RecordId(...)])
    

    String lookups (contains, startswith, regex, ...), isnull, and explicit $var references bound via .variables() are deliberately left uncoerced.

  • Aliased foreign keys are filtered correctly. The worst of the three defects, because it returned a wrong answer rather than an error: an aliased ForeignKey was written as a record link but filtered as a plain string, so the query came back empty with nothing to signal why. get_foreign_key_columns() now resolves a foreign key under its database column name too.

  • on_delete maps to SurrealDB's vocabulary. Django's literals passed through verbatim into DDL, which SurrealDB rejects — it takes CASCADE | UNSET | REJECT | IGNORE | THEN. SET_NULL → UNSET, PROTECT → REJECT, native keywords unchanged. The Django literals remain the API; both vocabularies are accepted.

  • Clearer error on an unsaved reference. Assigning an unsaved instance reported Input should be a valid string; it now raises cannot reference an unsaved Author; save it first. The write and filter paths previously bound the model object straight into the query — they raise now too.

  • New: instance.record_id, the reserved record-identity property (the Model.pk analog), returning a RecordId ready to bind against a record<> column in raw_query(). Plus the to_record_id(), record_link_to_str() and get_foreign_key_columns() helpers.


What's New in 0.32.6

Bug fix release — a CBOR decoding bug in the SDK, a migration operation that never did anything, and the CI monitor that pinned pre-release SurrealDB versions.

0.32.3, 0.32.4 and 0.32.5 are automation artifacts. Each was published by the release pipeline off an auto-merged monitor PR that moved the SurrealDB pin to a pre-release (3.3.0-beta.1 → beta.2 → beta.3). The wheels behave identically to 0.32.2 — .surrealdb-version and devops/docker-compose.yml don't ship in the package — but the library was declaring support for, and running CI against, a beta database.

SDK

  • Datetimes came back as a pair of ints outside typed model fields (#165) — SurrealDB 3.x encodes CBOR tag 12 as a compact [seconds, nanoseconds] pair rather than an ISO 8601 string, and the decoder only handled the string form. Anywhere the ORM does not coerce a value from its model annotation — raw_query() results, datetimes nested inside an object field — the value surfaced as (1739422800, 0) instead of a datetime:

    rows = await Event.raw_query("SELECT * FROM events;")
    rows[0]["occurred_at"]          # before: (1739422800, 0)   after: datetime(..., tzinfo=utc)
    rows[0]["payload"]["at"]        # before: (1739422800, 0)   after: datetime(..., tzinfo=utc)
    

    Fields declared datetime on a model were never affected — the ORM already rescued those.

    If you worked around this, remove the workaround before upgrading: code that unpacked the tuple (seconds, _ = row["occurred_at"]) now receives a datetime and will break.

    Sub-second precision is preserved to the microsecond, which is all a Python datetime can hold; the remaining nanoseconds are truncated. A datetime outside Python's year 1–9999 range is returned as the raw pair instead of raising, so one unrepresentable value can no longer abort the decoding of the whole response.

Migrations

Addresses group 1 of #162.

  • AlterField never altered anything — it emitted a plain DEFINE FIELD, which on SurrealDB 3.x does not update an existing field: the server answers The field 'x' already exists and leaves the definition untouched. backwards() had the same defect, so rollbacks were no-ops too. Both now emit DEFINE FIELD OVERWRITE. AddField deliberately keeps failing on an existing field — that is a real conflict, not something to overwrite.

  • The executor hid statement-level failures — client.query() raises only on an RPC-level failure, and a rejected statement rides back inside a successful RPC as status: ERR per statement. That is why the broken AlterField reported success, and it hid failures inside DataMigration and RawSQL bodies just as effectively. Every statement is now checked and a failure raises MigrationStatementError:

    from surreal_orm.migrations import MigrationExecutor, MigrationStatementError
    
    try:
        await MigrationExecutor(Path("migrations")).migrate()
    except MigrationStatementError as exc:
        print(f"Migration aborted: {exc}")
    

    Upgrade note: migrations that used to report success while silently failing now raise. Migrations are not wrapped in a transaction, so a mid-migration failure leaves the earlier operations applied and the migration unrecorded — the next migrate() replays it in full.

CI

  • The v3 monitor pinned pre-releases (#163) — its release query deliberately included betas and RCs, left over from the 3.0 alpha migration. Once 3.3.0 entered beta it started pinning 3.3.0-beta.x as the version the library declares support for and tests against, and because a pin bump auto-merges into a version bump, it burned three PyPI releases. The query now filters prerelease/draft, matching what the v2 monitor always did.

    sort -V made this worse rather than catching it: it ranks 3.3.0-beta.3 above 3.3.0, so the beta pin would have refused every subsequent stable release as a "downgrade" and stayed stuck indefinitely. Both monitors now reject a pre-release candidate outright — which also covers the manual workflow_dispatch input, that bypasses the API query entirely — and treat any stable release as superseding a pre-release pin.

    The pin is restored to 3.2.4. tests/test_surrealdb_monitor.py covers both directions.

    The recurring lesson is the cascade, not the individual bug (third occurrence after #155/#156): an auto-merged monitor PR reaches PyPI with no human in the loop, so what the monitors are allowed to propose matters more than the release gate does.


What's New in 0.32.2

CI fix + documentation release — no library code changes vs 0.32.0.

0.32.1 was an automation artifact. The monitor's downgrade PR (#155) was auto-merged, which made the release pipeline open, merge, tag and publish its own version bump (#156) before the problem was caught. The published wheel behaves identically to 0.32.0 — the only changes were .surrealdb-version and devops/docker-compose.yml, neither of which ships in the package. Upgrade to 0.32.2 for the actual fix.

  • The version monitors downgraded the SurrealDB pin (#155) — both monitors decided "an update is available" with a plain string inequality, and different is not newer. SurrealDB published 3.2.1–3.2.3 as image tags without matching GitHub Release entries, so the newest v3.x release read 3.2.0 while the pin already read 3.2.3 — and the monitor opened, then auto-merged, a PR moving the pin backwards. A lexical compare was also wrong across digit widths (2.10.0 sorts below 2.6.5 as a string, so the v2 monitor would have skipped a real upgrade). Both monitors now compare with sort -V and only act on a strictly newer version; the pin is restored to 3.2.3. tests/test_surrealdb_monitor.py extracts the workflow's own shell block and runs it against a table of version pairs.
  • v2 branch protection restored (#153) — the V2 LTS Protection ruleset targeted refs/heads/V2 while the branch is v2, so it matched nothing and v2 ran unprotected. That also broke gh pr merge --auto for every v2 version-bump PR, stalling the LTS release chain for a week.
  • Documentation — 0.31.14 was released but never documented (backfilled here), the 0.32.0 date is corrected to its actual release date, the compatibility table is refreshed to 3.2.x, and docs/roadmap.md no longer claims 0.32.0 is the "Graph Power" milestone (that work moves to 0.33.0).

What's New in 0.32.0

⚠️ Two breaking changes

  1. SurrealDB 3.2+ is required. 3.1.x and earlier are no longer supported or tested. The pin moves from 3.1.5 to 3.2.3 (.surrealdb-version, devops/docker-compose.yml). Upgrade your database first, or pin surrealdb-orm<0.32.
  2. Subquery emits different SQL — a LET prelude instead of an inline sub-SELECT. The Python API and results are unchanged; only code asserting on the generated SQL string needs updating.

See CHANGELOG.md for the full detail.

  • Subquery returned wrong results on SurrealDB 3.2.x (#147) — 3.2.x evaluates an inline uncorrelated sub-SELECT once per outer row while sharing its LIMIT budget across those evaluations, so a subquery combining ORDER BY and LIMIT produced a value for only some rows and [] for the rest, making filter(field=Subquery(...)) match nothing. The corruption was non-deterministic (~75% of executions). This is an upstream SurrealDB bug, still unfixed in 3.2.3 — the ORM now works around it.

  • Subqueries are hoisted into a LET binding (breaking: generated SQL changed) — evaluated exactly once, which is correct on every version:

    -- before
    SELECT * FROM orders WHERE user_id IN (SELECT VALUE id FROM users WHERE is_active = $_f0);
    
    -- now
    LET $_sq0 = (SELECT VALUE id FROM users WHERE is_active = $_f0);
    SELECT * FROM orders WHERE user_id IN $_sq0;
    

    The Python API is unchanged and results are identical — only code asserting on the generated SQL string is affected. QuerySet.live() keeps the inline form, since a LIVE SELECT WHERE clause cannot carry a prelude.

  • SurrealDB pin moves to 3.2.3 — .surrealdb-version and devops/docker-compose.yml. SurrealDB 2.x remains served by the v2 branch.

  • The version monitors never filed their failure issue (#146) — create-failure-issue runs only when test-new-version fails, but its if: had no status-check function, and GitHub implicitly ANDs success() into such conditions, so the job was skipped precisely when it was meant to run. Both monitors had failed daily since ~2026-07-15 without ever opening an issue. New workflow lint tests (tests/test_workflow_conditions.py) prevent the bug class from returning.

  • Dependency refresh — notably aiohttp 3.14.3, cbor2 6.1.3, mypy 2.3.0, pytest 9.1.1, ruff 0.16.0.

What's New in 0.31.14

Automated security patch — no library code changes.

  • aiohttp 3.14.1 → 3.14.3 (#149, Dependabot auto-merge), followed by the automated version bump (#150). aiohttp is the WebSocket transport for surreal_sdk. The same bump was synced to the v2 branch (#151); its version-bump PR then stalled for a week on the auto-merge bug fixed in 0.32.0 (#153).

What's New in 0.31.13

Bug-fix release — two correctness fixes (thanks @rmortes). See CHANGELOG.md for the full detail.

  • Denied / missing UPDATE and merge() now raise instead of silently no-opping (#135) — on a table with row-level PERMISSIONS, a write the current user isn't allowed to make (or one targeting a row that no longer exists) makes SurrealDB return an empty result, not an error. save() on a persisted instance and merge() previously discarded that and returned self, so a denied update looked identical to success. All non-deferred write paths now raise SurrealDbError; a new BaseTransaction.defers_results keeps HTTP-transaction behaviour unchanged.
  • istartswith / iendswith are now actually case-insensitive (#134) — they previously compiled to the same SurrealQL as startswith / endswith, so name__istartswith="ali" did not match "Alice". Both sides are now lowercased (mirroring icontains).

What's New in 0.31.7 → 0.31.12

Maintenance / dependency releases — no library code changes; runtime behaviour is identical across this range. See CHANGELOG.md for the full detail.

Version Change
0.31.7 Validated against SurrealDB 3.1.4 (test/CI target + Docker image) (#121).
0.31.8 Dependency: aiohttp 3.14.0 → 3.14.1 (WebSocket transport runtime dep) (#123).
0.31.9 Validated against SurrealDB 3.1.5 (test/CI target + Docker image) (#128).
0.31.10 Dependency: tornado 6.5.6 → 6.5.7 (transitive dev-group dep) (#127).
0.31.11 CI: actions/checkout 6 → 7 (#132).
0.31.12 CI: actions/setup-python 6 → 7 (#136).

What's New in 0.31.6

CI / maintenance release — no library code changes vs 0.31.5. Integrates the open Dependabot updates and fixes the release-automation deadlock that kept them from auto-merging. See CHANGELOG.md for the full detail.

  • Fixed the Dependabot auto-merge deadlock — the Auto-merge & Tag job waited on gh pr checks --watch, which includes the job's own check, so it waited on itself until the 6h job timeout and never merged. Tests were green the whole time; this was never a test or version-bump failure. Gating now relies on gh pr merge --auto.
  • Dependency bumps — dependabot/fetch-metadata 2 → 3 (#115), codecov/codecov-action 5 → 7 (#116).
  • Dependabot now ignores cbor2 major bumps on v2 — that LTS branch is pinned <6 on purpose (6.x breaks SurrealDB 2.x CBOR).

What's New in 0.31.5

Documentation & maintenance release — no library code changes vs 0.31.4. It brings the changelog and this README up to date across the whole 0.31.x line and corrects stale dates. See CHANGELOG.md for the full detail.

0.31.x at a glance (0.31.0 → 0.31.4)

Version Highlights
0.31.0 First PyPI release for SurrealDB 3.0 — RebuildIndex, GraphQL config (DefineGraphQLConfig / RemoveGraphQLConfig), bearer access (DefineBearerAccess), and QuerySet.upsert() / bulk_upsert() with ON DUPLICATE KEY UPDATE.
0.31.1 Security: cbor2 5.8.0 → 5.9.0; CI / release-automation fixes.
0.31.2 Critical — fixed a cbor2 6.x incompatibility that broke every CBOR RPC (surfaced as 401 Unauthorized); raised security dependency floors (aiohttp>=3.12, pydantic>=2.11, httpx>=0.28, cbor2>=6.1.2).
0.31.3 Validated against SurrealDB 3.1.3 (test/CI target + Docker image).
0.31.4 Fixed intermittent integration-suite 401s caused by JWT nbf clock skew (issue #101); dependency refresh that also cleared the Pygments ReDoS advisory.

Upgrading from 0.31.0 / 0.31.1? 0.31.2 is an important fix: under cbor2>=6, earlier versions fail all authenticated CBOR operations with 401 Unauthorized. Move to ≥ 0.31.2 (ideally the latest 0.31.x).


What's New in 0.31.0

REBUILD INDEX Migration Operation

from surreal_orm import RebuildIndex

# Rebuild after bulk import or index change
RebuildIndex(table="documents", name="idx_embedding")
RebuildIndex(table="articles", name="idx_fts", if_exists=True)
# Generates: REBUILD INDEX idx_embedding ON documents;

GraphQL Configuration (SurrealDB 3.0)

from surreal_orm import DefineGraphQLConfig, RemoveGraphQLConfig

# Enable GraphQL for all tables and functions
DefineGraphQLConfig(tables_mode="AUTO", functions_mode="AUTO")

# Include specific tables only
DefineGraphQLConfig(tables_mode="INCLUDE", tables_list=["users", "orders"])

# Exclude certain tables
DefineGraphQLConfig(tables_mode="EXCLUDE", tables_list=["audit_log"])

Bearer Access (SurrealDB 3.0)

Machine-to-machine authentication with API keys via DEFINE ACCESS ... TYPE BEARER:

from surreal_orm import DefineBearerAccess, AccessType

# Migration: define bearer access
DefineBearerAccess(name="api_key", duration_grant="30d", duration_session="1h")

# Issue and revoke bearer keys
key_info = await ServiceAccount.grant_bearer_key(user_id="service_accounts:worker1")
await ServiceAccount.revoke_bearer_key("key:abc123")

UPSERT ON DUPLICATE KEY UPDATE

from surreal_orm import SurrealFunc

# Insert or update on conflict
user = await User.objects().upsert(
    defaults={"name": "Alice", "login_count": 1},
    id="user:alice",
    on_conflict={"login_count": SurrealFunc("login_count + 1")},
)

# Bulk upsert with conflict handling
results = await User.objects().bulk_upsert(
    users,
    on_conflict={"login_count": SurrealFunc("login_count + 1")},
    atomic=True,
)

What's New in 0.30.0b1

Refresh Token Flow (SurrealDB 3.0)

signup() and signin() now return AuthResult — a backward-compatible result type that carries the refresh token alongside the access token.

Refresh tokens require the WITH REFRESH clause on your DEFINE ACCESS statement (placed after SIGNIN(...), before DURATION):

DEFINE ACCESS user_auth ON DATABASE TYPE RECORD
    SIGNUP (CREATE users SET email = $email, password = crypto::argon2::generate($password))
    SIGNIN (SELECT * FROM users WHERE email = $email AND crypto::argon2::compare(password, $password))
    WITH REFRESH
    DURATION FOR TOKEN 15m, FOR SESSION 12h, FOR GRANT 30d;
# New (recommended)
result = await User.signup(email="alice@b.com", password="secret", name="Alice")
result.token          # JWT access token
result.refresh_token  # Refresh token (prefixed "surreal-refresh-...")

# Backward-compatible (still works)
user, token = await User.signup(email="alice@b.com", password="secret", name="Alice")

# Exchange refresh token for new access token (token rotation)
result = await User.refresh_access_token(stored_refresh_token)
result.token          # New JWT access token
result.refresh_token  # New refresh token (old one is revoked)

DEFINE API Migration Support (SurrealDB 3.0)

New DefineApi and RemoveApi migration operations for SurrealDB 3.0's REST API endpoints:

from surreal_orm import DefineApi

DefineApi(
    name="/users/list",
    method="GET",
    handler="SELECT * FROM users",
)
# Generates: DEFINE API /users/list METHOD GET THEN (SELECT * FROM users);

Record References Field (SurrealDB 3.0)

New ReferencesField for SurrealDB 3.0's REFERENCE clause on DEFINE FIELD, with ON DELETE strategies:

from surreal_orm import ReferencesField

class Author(BaseSurrealModel):
    name: str
    books: ReferencesField["books"]
    # → DEFINE FIELD books ON author TYPE option<array<record<books>>> REFERENCE;

class License(BaseSurrealModel):
    owner: ReferencesField["person", "CASCADE"]
    # → DEFINE FIELD owner ON license TYPE option<record<person>> REFERENCE ON DELETE CASCADE;

Branch Guard Protection

CI now blocks PRs from v2-related branches (v2, 0.20.*, chore/surrealdb-2x-*) into main.


What's New in 0.30.0a2

Dual-Branch Security Monitoring

main now manages SurrealDB security monitoring for both branches:

  • surrealdb-security.yml — Monitors SurrealDB 3.X releases, creates PRs targeting main
  • surrealdb-v2-security.yml — Monitors SurrealDB 2.X releases, checks out v2 code, creates PRs targeting v2

GitHub Actions only executes scheduled (cron) workflows from the default branch. Since main is the default branch, the V2 monitor must live here. The two workflows run 30 minutes apart to avoid resource contention.


What's New in 0.30.0-alpha

SurrealDB 3.0 Compatibility

This release upgrades the ORM and SDK to target SurrealDB >= 3.0. A v2 branch is maintained for SurrealDB 2.x compatibility.

Breaking changes from SurrealDB 3.0:

  • Auth token format — signin()/signup() now return {access, refresh} dict (with WITH REFRESH) or {token} dict (without). New AuthResponse.refresh_token field added.
  • KNN vector search — similar_to() now always includes the EF parameter: <|K,EF|> (default ef=100). The <|K|> syntax no longer works.
  • SEARCH ANALYZER → FULLTEXT ANALYZER — Migration SQL generation and parsers updated.
  • MTREE index removed — Only HNSW vector indexes supported.
  • Time function renames — time::from::* → time::from_*, time::is::leap_year → time::is_leap_year
  • type::thing() → type::record() — Auth module updated.
  • Non-existent tables return errors — Namespace/database are now auto-created via DEFINE ... IF NOT EXISTS after signin.
  • Nullable type format — Schema introspection handles none | T (SurrealDB 3.0) alongside option<T> (v2.x).
# No code changes needed for most users — the ORM handles the differences.
# Just upgrade SurrealDB to v3.0+ and update to surrealdb-orm 0.30.0.

# Auth now returns refresh token (optional)
from surreal_sdk.types import AuthResponse
# response.token, response.refresh_token

# KNN search — ef parameter now always included (default 100)
docs = await Document.objects().similar_to("embedding", vec, limit=10).exec()
# Generates: WHERE embedding <|10,100|> $_knn_vec

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 3.2+ (see SurrealDB Compatibility for older servers)

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

SurrealDB Compatibility

ORM Version SurrealDB Branch Status
0.32.x >= 3.2 main Active development
0.30.x – 0.31.x 3.0 – 3.1 — Superseded
0.21.x 2.6.x v2 Security fixes only
  • SurrealDB 3.2+ — Use surrealdb-orm >= 0.32.0 (this branch). Tested against SurrealDB 3.2.4.
  • SurrealDB 3.0 – 3.1 — Pin surrealdb-orm<0.32. 0.32.0 requires 3.2+ and is not tested against 3.1.x.
  • SurrealDB 2.6.x — Use the v2 branch (surrealdb-orm 0.21.x). This branch receives security patches but no new features.

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.

The two projects target the same feature set; they differ in how (custom SDK vs official SDK) and in which servers they support. Lite is further along than this table used to suggest — the comparison below reflects lite v0.13.0:

Feature SurrealDB-ORM SurrealDB-ORM-lite
SDK Custom (surreal_sdk) Official surrealdb
Supported SurrealDB 3.x only 2.6.x and 3.1
CBOR protocol Default (own codec) Handled internally by the official SDK
CRUD, QuerySet, aggregations, signals Yes Yes
Relations & graph traversal Yes Yes
Transactions Full support Full — interactive on 3.x, buffered on 2.6.x (v0.9)
upsert / patch / atomic field ops Yes Yes (v0.10 – v0.11)
Retry on conflict Yes Yes (v0.12)
Server-side values (SurrealFunc) Yes Yes (v0.13)
Computed fields Yes Planned (v0.14)
Typed functions API / call_function Yes Planned (v0.15)
JWT / scope authentication Yes Planned (v0.16 – v0.17)
Live queries / CDC Full support Not yet — planned (v0.19 – v0.21)
Full-text & vector search Yes Planned (v0.34 – v0.36)
Migrations & CLI Yes Planned (v0.37 – v0.38)

Everything on lite's roadmap is implementable with the official SDK (native methods, or any SurrealQL — DDL included — through query()); only the custom SDK and its CBOR internals stay exclusive to this project. See lite's roadmap for the full schedule.

Choose SurrealDB-ORM-lite if you want the official SDK, minimal dependencies, or support for SurrealDB 2.6.x as well as 3.1. Choose SurrealDB-ORM if you need the features above that lite has not shipped yet (live queries, auth, search, migrations) or the custom-SDK internals.

pip install surreal-orm-lite

License

MIT License - See LICENSE file.


Author: Yannick Croteau | GitHub: EulogySnowfall

Metadata

Release files for surrealdb-orm 0.33.5

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.33.5
File Size Uploaded
surrealdb_orm-0.33.5.tar.gz 240.2 kB Details

Built distribution (wheel)

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

Total release size: 491.2 kB

Release files / surrealdb_orm-0.33.5.tar.gz

Download URL surrealdb_orm-0.33.5.tar.gz
Size 240.2 kB
Tags Source
SHA-256 checksum
How to use checksums
d280ce80bf41d68e07fbed9866648504fbea46333a5aaef1e9e27afc92d266e4
BLAKE2b-256 checksum
How to use checksums
6ecc2f48f794dc67a4838ba7ac5c66a4c51d4ad272b293c38f5bdb1635e2b719
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 Sep 28, 2026.

Transparency log

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

Download URL surrealdb_orm-0.33.5-py3-none-any.whl
Size 251.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9bf6c5a221fbdc3494b8761450868561d55f649ba539437e56c6a877222f6014
BLAKE2b-256 checksum
How to use checksums
edddf19a19cb615aa63d2514962899e62d30c4712946bd86658a458e893bc5a4
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 Sep 28, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.33.5 This release

2 release files

0.33.4

2 release files

0.32.6

2 release files

0.32.5

2 release files

0.32.4

2 release files

0.32.3

2 release files

0.32.2

2 release files

0.32.1

2 release files

0.32.0

2 release files

0.31.9

2 release files

0.31.8

2 release files

0.31.7

2 release files

0.31.1

2 release files

0.31.0

2 release files

0.21.8

2 release files

0.21.7

2 release files

0.21.3

2 release files

0.21.2

2 release files

0.20.0

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

1 release file

0.12.0

2 release files

0.11.0

2 release files

0.10.0

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.9

2 release files

0.5.8

2 release files

0.5.7

2 release files

0.5.6

2 release files

0.5.5

2 release files

0.5.3

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.1.4

2 release files

0.1.3

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