SurrealDB v3 Patterns¶
surql 1.4.0 introduced the SurrealQL forms required by SurrealDB v3. The v3 engine is stricter than v2 about datetime coercion, count aggregates, record-ID construction, transaction batching, and DDL idempotence. This page documents the patterns the library emits and explains the forms you should reach for in any raw SurrealQL you still hand-write.
All examples run cleanly against the v3 integration CI (surrealdb/surrealdb:v3.0.5, see the v3-integration workflow).
Datetime cast on insert¶
v3 no longer coerces bare ISO-8601 strings into datetime values. Any column typed datetime requires an explicit <datetime> cast at the call site.
Before (v2-only):
Now (v3-compatible):
Driven from Python, this is handled automatically by the migration recorder:
await client.execute(
'CREATE _migration_history SET '
'version = $version, applied_at = <datetime> $applied_at',
params={'version': version, 'applied_at': '2026-01-02T12:00:00Z'},
)
count() aggregates require GROUP ALL¶
v3 rejects count(*) and also rejects a bare SELECT count() FROM table without an explicit grouping. Always append GROUP ALL for full-table counts and use count() (no *).
Before:
Now:
The query builder emits the correct form out of the box:
from surql import Query, count_records
# CRUD helper, used by SurrealDB v3
await count_records('user') # -> SELECT count() AS count FROM user GROUP ALL
# Builder, explicit
(
Query()
.select(['count()'])
.from_table('user')
.group_all()
)
count_if(predicate) renders count(<predicate>) for conditional counts (see Query UX helpers).
Record-ID construction: type::thing(table, id)¶
The constructor for a record id from a separate table name and id value is type::thing(table, id) on both SurrealDB v2 and v3.
[!WARNING] Earlier versions of this guide claimed
type::thingwas renamed totype::recordin v3. That was wrong. In v3 the two-arg form oftype::record(value, type)is a type coercion -- it castsvalueintorecord<type>, andtype::record('user', 'alice')is interpreted as "coerce 'user' intorecord<alice>" (which fails). The single-arg formtype::record('user:alice')parses a full record-id string. For table+id construction, usetype::thing(table, id).
Prefer:
from surql import type_record # alias kept for source compatibility
ref = type_record('user', 'alice').to_surql()
# -> type::thing('user', 'alice')
type_thing() is the canonical name and is identical:
from surql import type_thing
type_thing('user', 'alice').to_surql()
# -> type::thing('user', 'alice')
See Query UX helpers for the full helper surface, including composition with RecordID, integers, and nested SurrealFn arguments.
Buffered BEGIN/COMMIT transactions¶
The v3 RPC protocol dispatches each client.execute() call as a separate statement. BEGIN TRANSACTION; ...; COMMIT TRANSACTION; split across three round trips crashes v3 because later statements run outside the transaction scope and the trailing COMMIT has nothing to commit.
DatabaseClient.execute() now batches BEGIN/COMMIT and all statements between them into a single RPC frame. Callers don't have to change anything — the transaction() context manager and the migration executor do the right thing automatically:
from surql import get_client, transaction
async with get_client(config) as client:
async with transaction(client):
await client.execute('UPDATE user:alice SET credits -= 10')
await client.execute('UPDATE user:bob SET credits += 10')
# Emitted as a single BEGIN; ...; COMMIT; RPC.
Embedded engines (mem://, file://, surrealkv://) remain transactional-in-process and skip the wrapper entirely (see CHANGES 1.3.1).
IF NOT EXISTS on DDL¶
v3 treats repeat DDL as an error unless the statement is idempotent. surql emits DEFINE TABLE ... IF NOT EXISTS, DEFINE INDEX ... IF NOT EXISTS, and DEFINE FIELD ... IF NOT EXISTS whenever the generator's if_not_exists=True flag is set (the default for the migration history table and the recommended setting for schema generation).
from surql.schema.table import generate_table_sql
sql = generate_table_sql(user_schema, if_not_exists=True)
# DEFINE TABLE IF NOT EXISTS user SCHEMAFULL;
# DEFINE FIELD IF NOT EXISTS email ON user TYPE string ...;
# DEFINE INDEX IF NOT EXISTS email_idx ON TABLE user COLUMNS email UNIQUE;
The _migration_history bootstrap in ensure_migration_table() uses this form unconditionally so surql migrate up is safe to run repeatedly on a schema-already-bootstrapped database.
Graph-depth literals¶
v3 rejects grouped graph-depth syntax such as ->follows{1..3}->user. Expand to literal hop lists:
from surql import traverse
# surql unrolls depth ranges into literal hop unions that v3 accepts.
await traverse('user:alice', '->follows->user', depth=(1, 3), client=client)
Full-text index renamed SEARCH -> FULLTEXT¶
SurrealDB 3.0 renamed the full-text index keyword. The v1/v2 form is a parse error on v3 (Unexpected token, expected Eof at SEARCH):
v3 spells it FULLTEXT:
surql emits the FULLTEXT keyword from IndexType.SEARCH / search_index / bm25_index and the migration diff; the INFO FOR TABLE index parser recognises both spellings (so live databases created under either version round-trip). COLUMNS and FIELDS are interchangeable in this statement.
The analyzer is defined separately with a DEFINE ANALYZER statement, which must run before the index that references it. Define it in code rather than hand-authoring SurrealQL:
from surql.schema import (
bm25_index,
generate_schema_sql,
standard_analyzer,
string_field,
table_schema,
)
# class tokenizer + lowercase + ascii filters; add .with_filter(snowball('english'))
# for stemming.
analyzer = standard_analyzer('text_en')
memory = table_schema(
'memory',
fields=[string_field('content')],
indexes=[bm25_index('content_bm25', ['content'], 'text_en')],
)
# Analyzers render before the tables that reference them.
sql = generate_schema_sql(tables={'memory': memory}, analyzers={'text_en': analyzer})
# DEFINE ANALYZER text_en TOKENIZERS class FILTERS lowercase,ascii;
#
# DEFINE TABLE memory SCHEMAFULL;
# DEFINE FIELD content ON TABLE memory TYPE string;
# DEFINE INDEX content_bm25 ON TABLE memory COLUMNS content FULLTEXT ANALYZER text_en BM25;
Bare BM25 uses the engine defaults (k1 = 1.2, b = 0.75); a search_index(...) without an analyzer renders the historical ascii default (define + name one explicitly for real lexical recall).
Run the lexical query with the builder or the fulltext_search_query helper:
from surql import fulltext_search_query
# SELECT *, search::score(1) AS score FROM memory
# WHERE content @1@ 'insider buying' LIMIT 100
query = fulltext_search_query('memory', 'content', 1, 'insider buying').limit(100)
search::score and scan ordering¶
The v3 streaming executor's full-text scan yields matching rows already in BM25 relevance order, but in 3.0.x it does not plumb the per-row score through to search::score(<ref>), which returns 0 there. So rank by the scan's natural order rather than ORDER BY search::score(...). This is sufficient for Reciprocal Rank Fusion (RRF), which fuses ranks, not raw scores:
from surql import fulltext_search_query, vector_search_query
# Sparse leg: rows come back in relevance order — take the order, not the score.
sparse = fulltext_search_query('memory', 'content', 1, 'insider buying').limit(100)
# Dense leg: vector KNN.
dense = vector_search_query('memory', 'embedding', query_vector, k=100, distance='COSINE')
# Fuse the two returned orders by rank (RRF) client-side.
search::score(<ref>) is still projected via Query.search_score(ref, alias) / the score_alias argument so the column exists for callers (and for future engine versions that populate it); just don't depend on its magnitude on 3.0.x.
v3 integration CI¶
The v3-integration.yml workflow spins up surrealdb/surrealdb:v3.0.5 and runs the integration suite on every push. The same suite runs nightly against the latest surrealdb/surrealdb:latest image to flag upstream drift early. Opt into the local v3 container via:
export SURQL_PRE_PUSH_INTEGRATION=1
docker run -d -p 8000:8000 --name surrealdb \
surrealdb/surrealdb:v3.0.5 start --user root --pass root memory
Wire the pre-push hook once per clone so the same checks run before every git push:
See CONTRIBUTING.md for the full pre-push setup.
Further reading¶
- Query UX helpers — the typed helper surface that emits v3-correct SurrealQL without raw strings
- Migration notes — upgrading existing v1.3.x code to v1.4.x / v1.5.x
- Migrations — the migration system, including the buffered-transaction wrapper