SurrealDB-ORM
⚠️ 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+ (3.2.x – 3.3.x) pip install surrealdb-orm(0.32.x and later; 3.3 tested from 0.34.0)3.0 – 3.1 pip install "surrealdb-orm<0.32"2.x pip install "surrealdb-orm<0.30"— or thev2branch (0.21.x, security + critical fixes only)
Subqueryalso emits different SQL in 0.32.0 (aLETprelude). 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.3.4 | 0.34.x | Active development |
v2 |
2.7.0 | 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.34.16
Fix release. SDK HTTPConnection only. If you call authenticate() or signup() on an HTTP connection that was
signed in with signin() first, see the behaviour change below and CHANGELOG.md.
- A rejected token is no longer answered with an earlier
signin()(#330). The transient-401recovery replayed the credentials of the lastsignin()even afterauthenticate()orsignup()had installed a different token, so once that token expired the request went through under the earlier identity. The retained sign-in arguments are now dropped byauthenticate(),signup(), a failedsignin()andclose(). - New
HTTPConnection.invalidate(): sends theinvalidateRPC and clears the token and the retained sign-in arguments, even when the RPC fails. - Behaviour change: on a connection whose token came from
authenticate()orsignup(), a401is now raised asQueryError(code 401). Callsignin()again or supply a fresh token. Transient-401recovery aftersignin()is unchanged.
What's New in 0.34.15
Fix release. QuerySet filters only. If you reference query variables from filter(), see the behaviour change
below and CHANGELOG.md.
$-prefixed filter values are compared as data (#327). A filter value starting with$(an email, a search term such as"$x OR true") was written into the query as a variable reference and changed the condition. It is now a reference only when it is exactly$nameandnamewas declared with.variables()on the same QuerySet (or on the inner QuerySet of aSubquery); every other value is bound as a parameter. Covers keyword filters,Q/~Q, subqueries,annotate()subqueries andlive().- Behaviour change: an undeclared
$nameis now matched as text (on aForeignKey, as a bare ID). Declare the variable —filter(age__gte="$min_age").variables(min_age=18)— and useraw_query()for built-in parameters such as$auth. Subquerynow sends its inner QuerySet's declared variables; they used to reach the server unbound.
What's New in 0.34.14
Fix release. SDK transactions only; nothing to change for ORM users. Direct SDK callers: see
CHANGELOG.md for the references that now raise ValueError.
HTTPTransactionworks with record IDs that are not plain identifiers (#306). An ID with a space,-,;, a backtick, a backslash or a leading digit produced a statement that failed to parse (or, forusers:a-b, wrote nothing). Such IDs are now bound as parameters; theformat_thing()backtick form keeps working.HTTPTransaction.merge()applies its changes again. It renderedMERGE { k = $k }, which the server rejects, so a merge queued in an HTTP transaction, including ORMsave()/merge()withtx=over HTTP, silently did nothing.
What's New in 0.34.13
Fix release. Nothing to change on upgrade for single-connection apps.
QuerySet.cache()no longer shares results between connections (#297). The same query run underusing("a")andusing("b")against two databases returned the first database's cached rows. The cache key now includes the effective connection (name, URL, namespace and database, never credentials), and a write only invalidates the cached entries of the database it went to.QueryCache.make_key()/set()accept an optionalconnectionkeyword.
What's New in 0.34.12
Fix release. One small SDK helper added; change-feed errors now raise: see CHANGELOG.md before upgrading if you consume change feeds.
- Change feeds deliver changes again
(#281).
QuerySet.changes(),stream()andstream_batch()waited forever: theSINCEclause was rendered as a quoted string, which SurrealDB rejects, and the error was swallowed.sincenow accepts a versionstamp, the streamcursor, adatetimeor an ISO 8601 string; each entry is delivered once, a stream no longer stalls behind other tables' entries, and errors raiseChangeFeedError. - Record IDs containing a backtick or a backslash round-trip
(#296) through
merge()with aSurrealFunc, the atomic array helpers,get_related(),remove_relation(),remove_all_relations()and graph traversals.escape_record_id()writes SurrealDB's\`and\\sequences; newsurreal_sdk.utils.unescape_record_id().
What's New in 0.34.11
Fix release. No new public API and nothing to change on upgrade.
- The
surreal-ormCLI works on Python 3.14 and 3.15 (#280). Every command that reaches the database (makemigrations,migrate,upgrade,rollback,status,sqlmigrate,inspectdb,schemadiff,shell) failed withRuntimeError: There is no current event loop. Commands now run on a fresh event loop withasyncio.run(), and on Python 3.12 – 3.13 they no longer emit an event-loopDeprecationWarning.
What's New in 0.34.10
Fix release. No new public API, but optional foreign keys change behaviour: see CHANGELOG.md before upgrading.
- An optional
ForeignKeyis now written and filtered as a record link (#268).ForeignKey("X") | NoneandOptional[ForeignKey("X")]got none of the coercion added in 0.32.7:save()/update()/merge()and the bulk and upsert paths sent a string, which anoption<record<...>>column rejects, andfilter()(exact/in/not_in, including insideQ) silently returned no rows for records that exist. They now behave like a requiredForeignKey: a model instance, a full"table:id"or a bare id is accepted. - Rows written before this release into a column that accepted a string (a SCHEMALESS table, or a field typed
any) hold'table:id'or the bare id, and a filter no longer matches them. Convert them once:UPDATE <table> SET <field> = IF !type::is_string(<field>) THEN <field> ELSE IF string::contains(<field>, ':') THEN type::record(<field>) ELSE type::record('<target_table>', <field>) END WHERE type::is_string(<field>);(keep the firstIF: SurrealDB 3.2.6 needs it). A field declaredoption<string>now rejects the write: retype itoption<record<target_table>>, then convert. - Known limitation:
is_foreign_key(),get_relation_info()and the other exported detection helpers still reportForeignKey("X") | Noneas not a relation, as in earlier releases.
What's New in 0.34.9
Maintenance release. Python 3.15 support; no library code changes and no new public API.
- Python 3.15 is supported (#235). CI runs the
unit suite on 3.12, 3.13, 3.14 and 3.15, and the package and the SDK declare the 3.15 classifier.
requires-python(>=3.12) and every dependency floor are unchanged. - On Python 3.15, use pydantic >= 2.14.0 and cbor2 >= 6.1.5, the first stable releases with cp315 wheels. Older versions that the floors still allow are built from source there, and pydantic-core and cbor2 both need a Rust toolchain for that.
- The development lock moves to pydantic 2.14.1 and cbor2 6.1.5. The lock does not ship in the package.
What's New in 0.34.8
Fix release. No new public API, but naive datetimes change behaviour: see CHANGELOG.md before upgrading.
QuerySet.live()keeps receiving events after a WebSocket reconnect (#255). Withauto_resubscribe=True(the default) the live query was re-created on the server, but the stream was not attached to it and went silent. It now moves to the re-created query during the resubscribe,live_idreports the new id,on_reconnectstill fires with(old_id, new_id), andstop()ends the re-created query instead of leaving it running.- A naive
datetimesent over CBOR is now read as UTC, with aRuntimeWarning. It used to make the request fail before it was sent (CBOREncodeError, orConnectionErrorover WebSocket): query variables, model fields onsave()/merge()/update(),filter()andlive()values. Aware datetimes are unchanged. On a host whose clock is not on UTC,datetime.now()is stored as local wall-clock time read as UTC: usedatetime.now(UTC). datetimesubclasses (pandas.Timestamp,pendulum.DateTime, …) are now sent like adatetime; SurrealDB used to reject them.- Known limitation: after a reconnect,
LiveSelectManager.stop()still expects the idwatch()returned, not the new one. The 0.34.5 notes below are corrected (who the #204 fix concerned, which connections usecertifi).
What's New in 0.34.7
Fix release. No new public API, but reconnect() changes behaviour: see CHANGELOG.md before
upgrading.
reconnect()no longer breaks requests already running (#232). It closed the shared HTTP client before the new one existed, so a request overlapping the call failed withConnectionError: Request failed, and an HTTP transaction opened before it lost every write at commit (Not connected). The new client is now connected and signed in first, then swapped in; the previous one is closed once its in-flight RPC requests and open transactions have finished, or after 30 s at most. If the new client cannot connect,reconnect()raisesSurrealDbConnectionErrorand keeps the current clients.- A failed close no longer leaves a dead client registered.
close_connection()/remove_connection()let an HTTP close error (e.g.RuntimeError: Event loop is closed) escape before the registry was cleared, soget_client()kept returning the dead client. The error is now logged and the entry always removed. - Token renewal does not need
reconnect(): an expired token gets HTTP 401 and the HTTP client signs in again and retries. Callget_client()again rather than keeping a client across anawait.
What's New in 0.34.6
Fix release. No new public API, but both fixes change behaviour: see CHANGELOG.md before upgrading.
- Migrations keep field permissions set outside the ORM
(#195).
AlterField(forwards and rollback) andModel.define_table()redefined fields withDEFINE FIELD OVERWRITE, so a field-levelPERMISSIONSclause the ORM does not model fell back toPERMISSIONS FULLon the next alteration. Fields are now altered in place withALTER FIELD, which sets or drops each modelled clause and leaves the others alone.AlterFieldon a field that does not exist now fails the migration instead of creating it: a migration chain replayed onto a fresh database needs anAddFieldfor each field it alters. schema_diff()/makemigrationsno longer emit a spuriousAlterFieldfor a field with permissions.parse_define_field()read a permission condition such asWHERE type = 'public'as aTYPEclause; it now stops at thePERMISSIONSclause.atomic_set_add()is set-like (#233). It used+=, which appends on an array, so two calls with the same value left two copies. It now usesarray::add(), and aset<T>field stays a set.- The atomic array helpers raise on a rejected statement.
atomic_append(),atomic_remove()andatomic_set_add()returned[]when SurrealDB rejected the write (a conflict, a failedASSERT), the same value as an update that matched nothing, so the write was lost silently and@retry_on_conflictnever retried. They now raiseTransactionConflictError(retried by@retry_on_conflict) orQueryError.atomic_append()on a record without the field now creates[value].
What's New in 0.34.5
Fix and SurrealDB 3.3.4 release. No public API change.
- Every
QuerySet.live()filter value is now bound as a query parameter (#205), asexec()already does. Some filters never matched, for example on a dict or aDecimal: the values were written into theLIVE SELECTtext, a dict as a Python repr and aDecimalas a quoted string. This holds on auto-resubscribe after a reconnect too. Code that asserts on the emittedLIVE SELECTtext now sees$_fNreferences instead of literals. Decimalanddatetime.datevalues can be bound to any query. Both made SurrealDB 3.x reject the whole request (cbor2 tags 4 and 1004). ADecimalis now sent as SurrealDB's decimal with every digit kept, and adateas the datetime at midnight UTC (d'YYYY-MM-DD'); a field typeddatereads back the same date.- HTTP transactions no longer rename parameters a statement did not bind
(#204).
commit()namespaced each bound key with a plain text replace, so a key that prefixed a longer$namerenamed it too and the server readNONE: with a variablea,$auth.idbecame$tx_0_auth.id. A key that is a prefix oftx_<i>_<an earlier key>(t,tx,tx_,tx_<i>,tx_<i>_, …;tx_1also hits positions 10–19) dropped the fields bound before it: through a model field with such a name onsave(tx=)of a new record andbulk_create(atomic=True), and through an edge-data keyword such astortx_<i>onrelate(tx=). Only whole$keyreferences to the statement's own keys are renamed now. WebSocket transactions were not affected. - Known limitation:
tx.merge()over HTTP is still broken (#251), for a reason this release does not touch. It emitsMERGE { k = $k, ... }, which SurrealDB 3.x rejects per statement, andcommit()returns without an error while the server cancels the whole transaction, its other statements included. This coversupdate(tx=),merge(tx=)without aSurrealFunc, andsave(tx=)of an already-saved record whose data holds noSurrealFuncand no nested object/array, on an HTTP connection. WebSocket transactions are not affected. certifi2026.10.23 in the lock file (GHSA-6q48-6c7w-9gw2).certifiis a transitive dependency, so upgrading the ORM does not upgrade it: runpip install -U certifito get>= 2026.10.19. The bundle is used byhttps://connections made through the HTTP transport, the ORM's default (httpx trustscertifi.where()unlessSSL_CERT_FILEorSSL_CERT_DIRis set); the WebSocket transport does not usecertifi.- Tested against SurrealDB 3.3.4; supported range unchanged (3.2.x – 3.3.x). The integration suite
passes against 3.3.4 and against 3.2.6 on the 3.2 floor line. The upstream defect behind
#147 still reproduces on 3.3.4, so
Subquerykeeps itsLETprelude. - The SurrealDB version monitors now pick the newest stable release by version (#234). They took the first release the API listed, which is ordered by creation date: the v3.2.6 backport, cut from a newer commit than v3.3.4, was listed first and hid it, and the pin stayed at 3.3.0 while every daily run ended green. An API failure now fails the run instead.
Maintenance releases 0.34.3 – 0.34.4
| Version | Date | Content |
|---|---|---|
| 0.34.4 | 2026-10-20 | multidict 6.9.1 in the lock file (one advisory; transitive, via aiohttp). No library changes. |
| 0.34.3 | 2026-10-20 | aiohttp 3.14.4 and yarl 1.25.1 in the lock file (three advisories). aiohttp is a runtime dependency whose floor is unchanged: upgrade it in your environment. No library changes. |
What's New in 0.34.2
CI maintenance release. No library code changes vs 0.34.0.
- 0.34.2 — Dependabot security updates on
main, grouped ones included, are now synced to thev2LTS branch automatically. A grouped update had been silently skipped, leavingv2on six advisories until a manual audit (fixed in 0.21.9). Packagesv2's constraints hold back (such ascbor2 <6) now raise a warning instead of passing as "up to date". - 0.34.1 —
tornado6.5.9 andurllib32.8.0 in the lock file (six advisories). Both are development-only dependencies and are not part of the installed package.
What's New in 0.34.0
SurrealDB 3.3 compatibility release. No library code changes vs 0.33.5.
- Tested against SurrealDB 3.3.0; supported range 3.2.x – 3.3.x. The full suite passes unchanged against a live 3.3.0 and a live 3.2.4 — 2,128 unit tests, 469 integration tests per server, no failures. The minimum stays 3.2, so this release does not force a server upgrade.
- Upgrading the server is still worth it: 3.3.0 carries upstream security fixes (computed-field
and field-permission bypasses, tenant-boundary checks on
USE, record-auth scope) absent from 3.2.4. Subquerykeeps itsLETprelude. The upstream sub-SELECTORDER BY+LIMITdefect behind #147 still reproduces on 3.3.0, so the 0.32.0 workaround stays in place.
Maintenance releases 0.33.4 – 0.33.5
| Version | Date | Content |
|---|---|---|
| 0.33.5 | 2026-09-28 | SurrealDB pin moved to 3.3.0 by the version monitor (#211). No library changes. |
| 0.33.4 | 2026-09-18 | anyio 4.14.2 in the lock file (transitive, via httpx). No library changes. |
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 tore.subas a replacement string, whichre.subreads as a mini-pattern:\1is a backreference,\g<0>a group reference, an unknown escape an error.json.dumpsemits\uXXXXfor every non-ASCII character, soraw_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.dumpsdoubles 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_staticpassed a rendered value tore.subthe same way._format_valuedoubles backslashes on purpose and the parsing undid it, so SurrealQL read\tas a tab and aQuerySet.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
$binside a string value of$awas 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=Truedefault emits a UTF-16 surrogate pair, which SurrealDB 3.x refuses at parse time. Invisible to ajson.loadsround-trip assertion — surrogate pairs are valid JSON — so the test asserts on the emitted text.ensure_ascii=Falsesends the character raw. -
An unreferenced complex variable was silently dropped, and a lone surrogate raised a
UnicodeEncodeErrorfrom inside the CBOR encoder instead of the documentedValueError. 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()andfind_param_references(), the shared primitive both call sites use. It lives insurreal_sdkbecause the dependency only runs one way. -
Known follow-ups — #204 (
HTTPTransaction.commit()renames$authand every other built-in whose name a bound key prefixes, on everysave(tx=)over HTTP) and #205 (onmain,LIVE SELECTbinds 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
PERMISSIONSclause was emitted as a secondDEFINE TABLE, which SurrealDB rejects once the table exists, so a model declaringpermissions={...}either failed loudly (0.33.1) or silently storedPERMISSIONS NONE. -
FULLis no longer written as a silent deny. Re-emitting a table defined withFOR select FULLproducedFOR select WHERE FULL, which SurrealDB accepts and then evaluates as a field reference — it resolves toNONE, 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 isREMOVE TABLE, so a migration that changed one permission had a rollback that destroyed every row — and reported itself reversible. Redefinition is nowAlterTable, which carries the definition it replaced and restores it. -
A rollback renders a field exactly as the statement it reverses does.
backwards()quoted everyDEFAULT, sotime::now()came back as the string'time::now()',TrueasTruerather thantrue, and an apostrophe produced a parse error. It also dropped theVALUEclause entirely, which stopped anEncryptedcolumn hashing and stored plaintext from then on. -
Materialized views,
TYPE USERtables,COMMENTs and relation lists survive a redefinition instead of being flattened, rejected as a parse error, silently dropped, or rendered asOUT ['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 throughDatabaseIntrospector— the same pathschemadiffalready 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 noPERMISSIONSclause; comparing that against a model configuring nothing made every table look changed, emitting a redundantCreateTable. Both sides are normalised now.
Behaviour change.
makemigrationscontacts 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-dbfor 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.
-
ForeignKeyandReferencesFieldintrospected asany(#170) —_introspect_fieldnever unwrappedAnnotated, so the record link was lost entirely.FieldState, the operations and the DB-side parser all already carriedreference/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/Relationemitted 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/AlterFieldhad nonullableparameter, so onlydefine_table()preserved optionality and generated migrations silently lost it.AlterFieldalso gainedprevious_nullable,previous_referenceandprevious_on_delete, without which a rollback quietly dropped theREFERENCEclause. -
schema_diff()never converged for non-CASCADEstrategies — the model stored Django'sSET_NULLwhile the database reported SurrealDB'sUNSET, and the states compare raw strings.FieldStatenow stores the keyword the database reports back. -
inspectdbturned a scalar foreign key into an array — everyreference=Truefield was mapped toReferencesField(array<record<T>>), so a generated model re-introspected into a destructive scalar→arrayAlterField. Arity now decides: scalarrecord<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_deleteused to be decorative — the column landed asanywith noREFERENCEclause, so nothing was applied. SurrealDB enforces it now, so aForeignKeyleft at the defaulton_delete="CASCADE"means deleting the referenced record deletes the referencing one. Useon_delete="IGNORE"for a link that should stay decorative.On upgrading, the first
schema_diff/makemigrationsemits one-timeREMOVE FIELDstatements for theManyToMany/Relationcolumns existing databases physically carry, plusany -> 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.
-
ForeignKeyvalues are coerced toRecordId(#169, #174). AForeignKeyholds a"table:id"string in Python, but nothing converted it at the wire boundary — so arecord<>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 insideQ) 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$varreferences 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
ForeignKeywas 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_deletemaps to SurrealDB's vocabulary. Django's literals passed through verbatim into DDL, which SurrealDB rejects — it takesCASCADE | 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 raisescannot 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 (theModel.pkanalog), returning aRecordIdready to bind against arecord<>column inraw_query(). Plus theto_record_id(),record_link_to_str()andget_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-versionanddevops/docker-compose.ymldon'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 adatetime: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
datetimeon 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 adatetimeand will break.Sub-second precision is preserved to the microsecond, which is all a Python
datetimecan 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.
-
AlterFieldnever altered anything — it emitted a plainDEFINE FIELD, which on SurrealDB 3.x does not update an existing field: the server answersThe field 'x' already existsand leaves the definition untouched.backwards()had the same defect, so rollbacks were no-ops too. Both now emitDEFINE FIELD OVERWRITE.AddFielddeliberately 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 asstatus: ERRper statement. That is why the brokenAlterFieldreported success, and it hid failures insideDataMigrationandRawSQLbodies just as effectively. Every statement is now checked and a failure raisesMigrationStatementError: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.xas 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 filtersprerelease/draft, matching what the v2 monitor always did.sort -Vmade this worse rather than catching it: it ranks3.3.0-beta.3above3.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 manualworkflow_dispatchinput, 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.pycovers 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-versionanddevops/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.0while the pin already read3.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.0sorts below2.6.5as a string, so the v2 monitor would have skipped a real upgrade). Both monitors now compare withsort -Vand only act on a strictly newer version; the pin is restored to 3.2.3.tests/test_surrealdb_monitor.pyextracts the workflow's own shell block and runs it against a table of version pairs. v2branch protection restored (#153) — theV2 LTS Protectionruleset targetedrefs/heads/V2while the branch isv2, so it matched nothing andv2ran unprotected. That also brokegh pr merge --autofor every v2 version-bump PR, stalling the LTS release chain for a week.- Documentation —
0.31.14was released but never documented (backfilled here), the0.32.0date is corrected to its actual release date, the compatibility table is refreshed to 3.2.x, anddocs/roadmap.mdno 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
- SurrealDB 3.2+ is required. 3.1.x and earlier are no longer supported or tested. The pin moves from
3.1.5to3.2.3(.surrealdb-version,devops/docker-compose.yml). Upgrade your database first, or pinsurrealdb-orm<0.32.Subqueryemits different SQL — aLETprelude 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.
-
Subqueryreturned wrong results on SurrealDB 3.2.x (#147) — 3.2.x evaluates an inline uncorrelated sub-SELECT once per outer row while sharing itsLIMITbudget across those evaluations, so a subquery combiningORDER BYandLIMITproduced a value for only some rows and[]for the rest, makingfilter(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
LETbinding (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 aLIVE SELECTWHERE clause cannot carry a prelude. -
SurrealDB pin moves to 3.2.3 —
.surrealdb-versionanddevops/docker-compose.yml. SurrealDB 2.x remains served by thev2branch. -
The version monitors never filed their failure issue (#146) —
create-failure-issueruns only whentest-new-versionfails, but itsif:had no status-check function, and GitHub implicitly ANDssuccess()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
aiohttp3.14.3,cbor26.1.3,mypy2.3.0,pytest9.1.1,ruff0.16.0.
What's New in 0.31.14
Automated security patch — no library code changes.
aiohttp3.14.1 → 3.14.3 (#149, Dependabot auto-merge), followed by the automated version bump (#150).aiohttpis the WebSocket transport forsurreal_sdk. The same bump was synced to thev2branch (#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
UPDATEandmerge()now raise instead of silently no-opping (#135) — on a table with row-levelPERMISSIONS, 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 andmerge()previously discarded that and returnedself, so a denied update looked identical to success. All non-deferred write paths now raiseSurrealDbError; a newBaseTransaction.defers_resultskeeps HTTP-transaction behaviour unchanged. istartswith/iendswithare now actually case-insensitive (#134) — they previously compiled to the same SurrealQL asstartswith/endswith, soname__istartswith="ali"did not match"Alice". Both sides are now lowercased (mirroringicontains).
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 & Tagjob waited ongh 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 ongh pr merge --auto. - Dependency bumps —
dependabot/fetch-metadata2 → 3 (#115),codecov/codecov-action5 → 7 (#116). - Dependabot now ignores
cbor2major bumps onv2— that LTS branch is pinned<6on 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 with401 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 targetingmainsurrealdb-v2-security.yml— Monitors SurrealDB 2.X releases, checks outv2code, creates PRs targetingv2
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 (withWITH REFRESH) or{token}dict (without). NewAuthResponse.refresh_tokenfield 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.MTREEindex removed — OnlyHNSWvector 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 EXISTSafter signin. - Nullable type format — Schema introspection handles
none | T(SurrealDB 3.0) alongsideoption<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
Typed get_related() via @overload
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
Noneis now correctly encoded as SurrealDBNONE(absent field) instead ofNULL. Fixesoption<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. Newvalidate_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, soevent.started_at = "2026-02-13T10:00:00Z"is auto-coerced todatetime. -
flexible_fieldsConfig — Discoverable way to mark fields asFLEXIBLE TYPEin 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 SELECTclass 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 Search & Full-Text Search
-
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
DatabaseIntrospectorparsesINFO FOR DB/INFO FOR TABLEintoSchemaStateModelCodeGeneratorconvertsSchemaStateto 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 inspectdbandsurreal-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()
ConnectionConfigfrozen dataclass for immutable connection settingsusing()async context manager withcontextvarsfor async safety- Full backward compatibility:
set_connection()delegates toadd_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 typedinstance,action,record_id,changed_fields- Full QuerySet filter integration (WHERE clause + parameterized variables)
auto_resubscribe=Truefor seamless WebSocket reconnect recoverydiff=Truefor 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_intervalandbatch_size - No WebSocket required (works over HTTP)
-
post_live_changesignal - 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(), andauthenticate_token()no longer corrupt the singleton connection. They use isolated ephemeral connections. -
Configurable Access Name - Access name is configurable via
access_nameinSurrealConfigDict(was hardcoded to{table}_auth) -
signup()Returns Token - Now returnstuple[Self, str](user + JWT token), matchingsignin()user, token = await User.signup(email="alice@example.com", password="secret", name="Alice")
-
authenticate_token()Fixed +validate_token()- Fixed token validation with newvalidate_token()lightweight methodresult = 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 toNone(server computes the value)- Auto-excluded from
save()/merge()viaget_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 updatesawait user.merge(last_seen=SurrealFunc("time::now()"), refresh=False)
-
call_function()- Invoke custom SurrealDB stored functions from the ORMresult = await SurrealDBConnectionManager.call_function( "acquire_game_lock", params={"table_id": tid, "pod_id": pid}, ) result = await GameTable.call_function("release_game_lock", params={...})
-
extra_varsonsave()- Bind additional query variables for SurrealFunc expressionsawait 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 queriesposts = 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 callawait 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
$variablereferences 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 supportawait table.remove_all_relations("has_player", direction="out") await user.remove_all_relations("follows", direction="both")
-
Django-style
-fieldOrdering - Shorthand for descending orderusers = await User.objects().order_by("-created_at").exec()
-
Bug Fix:
isnullLookup -filter(field__isnull=True)now generatesIS NULLinstead ofIS 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 + jitterTransactionConflictErrorexception 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 -
reverseparameter onrelate()andremove_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 operationspre_delete,post_delete- Before/after delete operationspre_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_idare 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 queriesget_related()direction="in" - Fixed to return actual related records instead of empty resultsupdate()table name - Fixed bug where customtable_namewas ignored
v0.5.5 - CBOR Protocol & Field Aliases
- CBOR Protocol (Default) - Binary protocol for WebSocket connections
cbor2is 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
- Use
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 instancesraw_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 viaexclude_unset=True
v0.5.3.2 - Critical Bug Fix
- QuerySet table name fix - Fixed critical bug where QuerySet used class name instead of
table_namefrom config QuerySet.get()signature - Now acceptsid=keyword argument in addition to positionalid_item
v0.5.3.1 - Bug Fixes
- Partial updates for persisted records -
save()now usesmerge()for already-persisted records, only sending modified fields - datetime parsing -
_update_from_db()now parses ISO 8601 strings todatetimeobjects automatically _db_persistedflag - Internal tracking to distinguish new vs persisted records
v0.5.3 - ORM Improvements
- Upsert save behavior -
save()now usesupsertfor new records with ID (idempotent, Django-like) server_fieldsconfig - Exclude server-generated fields (created_at, updated_at) from savesmerge()returns self - Now returns the updated model instance instead of Nonesave()updates self - Updates original instance attributes instead of returning new object- NULL values fix -
exclude_unset=Truenow works correctly after loading from DB
v0.5.2 - Bug Fixes & FieldType Improvements
- FieldType enum - Enhanced migration type system with
generic()andfrom_python_type()methods - datetime serialization - Proper JSON encoding for datetime, date, time, Decimal, UUID
- Fluent API -
connect()now returnsselffor method chaining - Session cleanup - WebSocket callback tasks properly tracked and cancelled
- Optional fields -
exclude_unset=Trueprevents None from overriding DB defaults - Parameter alias -
usernameparameter alias foruserin 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:LiveChangedataclass withrecord_id,action,result,changed_fields- WHERE clause support with parameterized queries
- Auto-Resubscribe - Automatic reconnection after WebSocket disconnect
auto_resubscribe=Trueparameter for seamless K8s pod restart recoveryon_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,Relationfield 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+ (tested on 3.12 – 3.15) | SurrealDB 3.2.x – 3.3.x, tested against 3.3.4 (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.34.x | 3.2.x – 3.3.x | main |
Active development |
| 0.32.x – 0.33.x | >= 3.2 | — | Superseded |
| 0.30.x – 0.31.x | 3.0 – 3.1 | — | Superseded |
| 0.21.x | 2.6.x – 2.7.x | v2 |
Security fixes only |
- SurrealDB 3.2.x – 3.3.x — Use
surrealdb-orm >= 0.34.0(this branch). Tested against SurrealDB 3.3.4, and against 3.2.6 on the 3.2 floor line. - 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 – 2.7.x — Use the
v2branch (surrealdb-orm 0.21.x; 2.7.x from 0.21.8, tested against 2.7.0). This branch receives security patches but no new features.
Quick Start
Using the SDK (Recommended)
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 SurrealQLtx.create(thing, data)- Create recordtx.update(thing, data)- Replace recordtx.delete(thing)- Delete recordtx.relate(from, edge, to)- Create graph edgetx.commit()- Explicit committx.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
Related Projects
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.34.16
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| surrealdb_orm-0.34.16.tar.gz | 277.0 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| surrealdb_orm-0.34.16-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 558.5 kB
Release files / surrealdb_orm-0.34.16.tar.gz
| Download URL | surrealdb_orm-0.34.16.tar.gz |
|---|---|
| Size | 277.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
30681c88ba20ca230591e42524d470cf3b1e342a875a209781ba87b872ab1546
|
|
BLAKE2b-256 checksum How to use checksums |
1bb455df60aa192f1a35b1c36cb117da0c60dc7451dd0295cf5ea208969afd0b
|
| 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 logRelease files / surrealdb_orm-0.34.16-py3-none-any.whl
| Download URL | surrealdb_orm-0.34.16-py3-none-any.whl |
|---|---|
| Size | 281.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a29d8e24884937f2c1da35c400818811dc123c70e4d8ff03ec816f133e51229b
|
|
BLAKE2b-256 checksum How to use checksums |
8842a95d7243ea874e6d366e9145c395be652ed71a5c1a25d8806376d01580fb
|
| 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 logSecurity for surrealdb-orm 0.34.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.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for surrealdb_orm-0.34.16.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 logA 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.