Skip to content
Sign In

Concepts & guides

Transactions

How SurrealDB transactions work: the default per-statement scope, manual BEGIN, COMMIT and CANCEL, and the snapshot isolation guarantees that apply under concurrency.

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.

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;      -- commits

Both 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 aborted

Three statements control a manual transaction:

Transaction statements
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.

A transfer between two accounts
-- 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.

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;
Output when $transfer_amount set to 150
'An error occurred: Insufficient funds, would have $-50 after transfer'
Output when $transfer_amount set to 50
[
	{
		dollars: 50,
		id: account:one
	},
	{
		dollars: 150,
		id: account:two
	}
]

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.

Snapshot isolation with conflict detection on commit protects against the anomalies most application developers plan for:

AnomalyProtected?Notes
Dirty readsYesA transaction never reads uncommitted data from another transaction.
Non-repeatable readsYesRe-reading the same data inside a transaction returns the same values.
Lost updates (same key)YesConcurrent writes to the same key cannot both commit; one transaction must retry.
Write skewOpt inCovered 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.

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 READSurrealDB
Snapshot takenAt the first statement in the transactionAt the start of the transaction
Competing writerBlocks on a row lock until the first transaction resolvesNever blocks; proceeds and commits
Conflict surfacesAt the conflicting statement, as could not serialize access due to concurrent updateAt COMMIT, as a transaction conflict error
SELECT ... FOR UPDATETakes a row lock, and raises straight away if the row moved since the snapshotRegisters the record, and raises at COMMIT if it moved
Write skewNeeds explicit locking, or the SERIALIZABLE levelNeeds FOR UPDATE
Blanket serialisable levelSERIALIZABLE covers predicate-based anomalies as wellNo 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.

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.

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.

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 COMMIT then fails and must be retried. Readers coming from a database where SELECT ... FOR UPDATE holds 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 UPDATE safe for get-or-create paths.

  • The enclosing statement becomes write-classified. A FOR UPDATE read nested inside an expression promotes its statement to a write transaction, because the registration can only be validated at commit time.

AspectBehaviour
TargetsRecord ids only, written literally or passed through a parameter. Tables, record ranges, subqueries and parameters holding a table are rejected.
Concurrency modelOptimistic. No lock is taken and no transaction waits; a conflict surfaces at COMMIT on the transaction that read the record.
GranularityOne 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 typeRequires a transaction that commits. A read-only context rejects the clause, since the registration would never be validated.
Storage backendsSupported 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 conflictsCannot be combined with VERSION, GROUP BY or SPLIT, or with LIMIT across multiple targets.
Note

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.

PropertyIn SurrealDB
AtomicityA transaction's statements commit together or roll back together.
ConsistencySchema, permissions, and statement semantics apply on every commit; you define business invariants in SurrealQL and application code.
IsolationSnapshot isolation on all storage backends; write conflicts abort on commit, and FOR UPDATE extends that check to records the transaction only reads.
DurabilityCommitted 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.

Was this page helpful?