SurrealDB is ACID-compliant, so the work inside a transaction either commits as a whole or rolls back with no partial effect. By default each SurrealQL statement runs in its own transaction, including side effects such as defined events. A single CREATE, UPDATE or SELECT is therefore atomic on its own.
This page covers manual transactions, which group statements that must succeed or fail together, and the isolation guarantees that apply when many clients read and write at the same time.
Implicit transactions
BEGIN is not the only thing that groups statements. Blocks and function bodies each run as one transaction, despite the lack of BEGIN or COMMIT, which is why they are described as implicit. Each behaves like any other transaction: it commits when it finishes, and rolls back if anything inside it fails.
Implicit transactions commit on normal completion, including an early RETURN. An early return is control flow rather than a failure, so the work already done is kept:
{
CREATE shipment:shp_5501 SET order = order:ord_8412, carrier = 'DHL';
-- the caller only wants the id
RETURN shipment:shp_5501;
-- never runs
UPDATE order:ord_8412 SET status = 'shipped';
};Implicit transactions also roll back back on errors, whether that is a THROW or a statement that simply fails, such as a schema violation. Nothing inside survives:
{
CREATE order:ord_8412 SET item = product:keyboard, quantity = 2; -- rolled back
THROW "Insufficient stock";
UPDATE product:keyboard SET stock -= 2; -- never runs
};Without the braces the order would persist while the stock it reserved never moved, which is the inconsistency a transaction exists to prevent.
In a bare sequence of statements, each one is its own transaction. Here the THROW reports an error, and both records still exist afterwards, because the statements either side of it committed independently:
CREATE product:keyboard SET stock = 40; -- commits
THROW "Stop the import"; -- errors, and stops nothing
CREATE product:mouse SET stock = 25; -- commitsBoth products exist afterwards. A bare THROW reports an error without halting the statements around it, so it cannot be used as a guard clause outside a block.
So wrapping statements in braces is enough to make them atomic.
A block does not, however, give a failure somewhere to be contained. There is one transaction per query rather than a stack of them, so a block inside a manual transaction is part of that transaction rather than a nested one. A THROW inside the block aborts the whole thing, including the statements that ran before it:
BEGIN;
UPDATE account:one SET balance -= 100; -- rolled back as well
{
CREATE ledger_entry:le_9001 SET amount = 100, account = account:one;
THROW "Ledger rejected the entry";
};
UPDATE account:two SET balance += 100; -- never runs
COMMIT; -- fails: the transaction was already abortedManual transactions
Three statements control a manual transaction:
BEGIN [ TRANSACTION ];
COMMIT [ TRANSACTION ];
CANCEL [ TRANSACTION ];BEGIN opens the transaction. COMMIT makes every change inside it a permanent part of the database. CANCEL rolls those changes back instead. If any statement inside the transaction fails, the whole transaction is rolled back and no change survives.
-- Create two accounts for bank customers
CREATE account:one SET balance = 135605.16;
CREATE account:two SET balance = 91031.31;
-- Start a manual database transaction
BEGIN TRANSACTION;
-- Update the balances of each customer involved in the wire transfer
UPDATE account:one SET balance += 300.00;
UPDATE account:two SET balance -= 300.00;
-- Apply both updates together. Had either statement failed, the database
-- would remain in its initial state.
COMMIT TRANSACTION;Replacing COMMIT TRANSACTION with CANCEL TRANSACTION in the example above leaves both balances untouched.
Client SDKs expose the same model through transaction handles. See the transactions guide for your language under SDKs.
THROW to conditionally cancel a transaction
A transaction rolls back on its own when a statement errors. THROW breaks out of one deliberately, at any point. THROW can be followed by any value, which serves as the error message, and is usually a string.
CREATE account:one SET dollars = 100;
CREATE account:two SET dollars = 100;
LET $transfer_amount = 150;
BEGIN TRANSACTION;
UPDATE account:one SET dollars -= $transfer_amount;
UPDATE account:two SET dollars += $transfer_amount;
IF account:one.dollars < 0 {
THROW "Insufficient funds, would have $" + <string>account:one.dollars + " after transfer"
};
COMMIT TRANSACTION;
SELECT * FROM account;'An error occurred: Insufficient funds, would have $-50 after transfer'[
{
dollars: 50,
id: account:one
},
{
dollars: 150,
id: account:two
}
]Snapshot isolation
Every SurrealDB transaction runs under snapshot isolation. When a transaction starts, it sees a consistent point-in-time view of the database. That snapshot stays stable for the lifetime of the transaction, so reads inside the transaction do not observe concurrent writes from other transactions until commit.
SurrealDB does not offer weaker isolation levels. You cannot downgrade to read committed or read uncommitted.
On commit, the engine checks for write conflicts. If two concurrent transactions modified the same key, the later commit fails with a transaction conflict error and must be retried. There is no silent last-writer-wins merge at the storage layer.
Records that a transaction only reads are outside that check by default. The FOR UPDATE clause brings them into it.
These semantics apply across deployment models and storage backends: embedded and server, single-node and distributed, RocksDB, SurrealKV, SurrealMX, and browser IndexedDB. The query layer enforces the same isolation contract regardless of which engine persists the keys underneath.
What you get in practice
Snapshot isolation with conflict detection on commit protects against the anomalies most application developers plan for:
| Anomaly | Protected? | Notes |
|---|---|---|
| Dirty reads | Yes | A transaction never reads uncommitted data from another transaction. |
| Non-repeatable reads | Yes | Re-reading the same data inside a transaction returns the same values. |
| Lost updates (same key) | Yes | Concurrent writes to the same key cannot both commit; one transaction must retry. |
| Write skew | Opt in | Covered per record with FOR UPDATE; see write skew below. |
In database terms, snapshot isolation sits between read committed and serialisable. It matches the default isolation level in MySQL (InnoDB REPEATABLE READ) and PostgreSQL's optional REPEATABLE READ level, with the important caveat that SurrealDB does not provide serialisable isolation. For the specific records a transaction names, FOR UPDATE closes the remaining gap.
Compared with PostgreSQL REPEATABLE READ
PostgreSQL's REPEATABLE READ is snapshot isolation too, so the two protect against the same anomalies. The mechanisms differ in ways that decide where an application handles a conflict.
PostgreSQL REPEATABLE READ | SurrealDB | |
|---|---|---|
| Snapshot taken | At the first statement in the transaction | At the start of the transaction |
| Competing writer | Blocks on a row lock until the first transaction resolves | Never blocks; proceeds and commits |
| Conflict surfaces | At the conflicting statement, as could not serialize access due to concurrent update | At COMMIT, as a transaction conflict error |
SELECT ... FOR UPDATE | Takes a row lock, and raises straight away if the row moved since the snapshot | Registers the record, and raises at COMMIT if it moved |
| Write skew | Needs explicit locking, or the SERIALIZABLE level | Needs FOR UPDATE |
| Blanket serialisable level | SERIALIZABLE covers predicate-based anomalies as well | No equivalent; FOR UPDATE covers named records only |
Two consequences follow for application code. Conflicts arrive later in SurrealDB: a FOR UPDATE read never fails on account of a concurrent write, so retry logic wraps the whole transaction rather than guarding individual statements. And because a predicate cannot be registered, an invariant that depends on a set of records rather than on named ones has no direct equivalent of SERIALIZABLE to fall back on. Such an invariant needs a record that stands in for the set, such as a counter or a parent record that every participant reads for update.
Write conflicts and retries
When a commit fails because another transaction wrote the same key first, SurrealDB returns a transaction conflict error. Your application, or your client's retry logic, should run the transaction again.
This is normal under concurrent load, not a sign of data corruption. At scale, rising conflict rates show up in metrics such as surrealdb_transaction_conflicts_total - see Observability for monitoring guidance.
Keep transactions short and touch the fewest keys necessary. Long-running transactions that overlap on hot keys see more conflicts.
Write skew
Snapshot isolation does not prevent write skew. Write skew occurs when two transactions each read overlapping state, make independent decisions, and both commit even though their combined effect breaks an invariant.
An everyday example is one in which two doctors each check a schedule, see that only one shift is booked, and both book themselves. Each transaction read a consistent snapshot, but together they overbooked the day.
Where write skew matters for your workload, read the records the rule depends on with SELECT ... FOR UPDATE. Each doctor's transaction then registers the schedule record it based its decision on, so whichever transaction commits second fails and retries against the booking the first one made.
Writing every record the rule depends on has the same effect, because a write is conflict-checked already. That remains the natural choice where the transaction was going to update the record anyway, and conditional updates work on the same principle.
Locked reads with FOR UPDATE
Available since: v3.3.0
The FOR UPDATE clause on SELECT enrols a record that the transaction only reads into the same commit-time conflict check that already covers writes. The transaction commits only if no other transaction wrote that record after the snapshot was taken. Otherwise COMMIT fails with a transaction conflict error and the transaction can be retried.
BEGIN;
-- Register the schedule for commit-time conflict detection
LET $schedule = SELECT * FROM ONLY schedule:monday FOR UPDATE;
-- The decision below rests on a value that is read but never written,
-- which is exactly the case FOR UPDATE covers
IF $schedule.shifts_booked = 0 {
UPDATE booking SET doctor = $auth.id, day = "monday";
};
COMMIT;Three properties are worth knowing beyond the basic guarantee:
Concurrent writers are not blocked. The clause takes no lock. Another transaction can write a registered record and commit normally, and the cost lands on the reading transaction, whose own
COMMITthen fails and must be retried. Readers coming from a database whereSELECT ... FOR UPDATEholds a row lock should plan for retries rather than for waiting.An absent record is still covered. Reading a record id that does not exist registers that id, so a concurrent transaction creating it also invalidates the commit. This is what makes
FOR UPDATEsafe for get-or-create paths.The enclosing statement becomes write-classified. A
FOR UPDATEread nested inside an expression promotes its statement to a write transaction, because the registration can only be validated at commit time.
| Aspect | Behaviour |
|---|---|
| Targets | Record ids only, written literally or passed through a parameter. Tables, record ranges, subqueries and parameters holding a table are rejected. |
| Concurrency model | Optimistic. No lock is taken and no transaction waits; a conflict surfaces at COMMIT on the transaction that read the record. |
| Granularity | One record at a time. Tables, ranges and query predicates are not locked, so a concurrent transaction can still create a new record that matches a predicate. |
| Transaction type | Requires a transaction that commits. A read-only context rejects the clause, since the registration would never be validated. |
| Storage backends | Supported on every storage backend. Where an engine has no conflict-tracked read path the clause is refused outright, so the guarantee is never quietly downgraded. |
| Clause conflicts | Cannot be combined with VERSION, GROUP BY or SPLIT, or with LIMIT across multiple targets. |
FOR UPDATE is used for a record the transaction reads but never writes. A record the transaction also writes is covered already by the write conflict check, so adding the clause there changes nothing.
ACID at a glance
| Property | In SurrealDB |
|---|---|
| Atomicity | A transaction's statements commit together or roll back together. |
| Consistency | Schema, permissions, and statement semantics apply on every commit; you define business invariants in SurrealQL and application code. |
| Isolation | Snapshot isolation on all storage backends; write conflicts abort on commit, and FOR UPDATE extends that check to records the transaction only reads. |
| Durability | Committed data persists according to your storage engine and sync settings - see File-backed storage and Deployment models. |
Some features deliberately step outside the triggering transaction's ACID boundary. ASYNC events, for example, run after commit in a separate transaction. Use them only when that trade-off is acceptable.