> Full SurrealDB documentation index: https://surrealdb.com/docs/llms.txt

# 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](/docs/reference/query-language/statements/define/event.md). 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:

```surql
{
    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 on errors, whether that is a [`THROW`](/docs/reference/query-language/statements/throw.md) or a statement that simply fails, such as a schema violation. Nothing inside survives:

```surql
{
    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:

```surql
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:

```surql
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
```

## Manual transactions

Three statements control a manual transaction:

```surql title="Transaction statements"
BEGIN [ TRANSACTION ];
COMMIT [ TRANSACTION ];
CANCEL [ TRANSACTION ];
```

[`BEGIN`](/docs/reference/query-language/statements/begin.md) opens the transaction. [`COMMIT`](/docs/reference/query-language/statements/commit.md) makes every change inside it a permanent part of the database. [`CANCEL`](/docs/reference/query-language/statements/cancel.md) rolls those changes back instead. If any statement inside the transaction fails, the whole transaction is rolled back and no change survives.

```surql title="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](/docs/languages/javascript.md).

## THROW to conditionally cancel a transaction

A transaction rolls back on its own when a statement errors. [`THROW`](/docs/reference/query-language/statements/throw.md) 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.

```surql
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;
```

```surql title="Output when $transfer_amount set to 150"
'An error occurred: Insufficient funds, would have $-50 after transfer'
```

```surql title="Output when $transfer_amount set to 50"
[
	{
		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`](#locked-reads-with-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](/docs/learn/data-models/architecture.md#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`](#locked-reads-with-for-update); see [write skew](#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`](#locked-reads-with-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`](#locked-reads-with-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](/docs/manage/observability/metrics.md) 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`](#locked-reads-with-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`

_(since v3.3.0)_

The [`FOR UPDATE`](/docs/reference/query-language/statements/select.md#the-for-update-clause) 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.

```surql
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 further properties apply 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.

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

> [!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.

## 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`](#locked-reads-with-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](/docs/running/file-backed.md) and [Deployment models](/docs/manage/self-hosted/deployment-models.md). |

Some features deliberately step outside the triggering transaction's ACID boundary. [`ASYNC` events](/docs/reference/query-language/statements/define/event.md#async-events), for example, run after commit in a separate transaction. Use them only when that trade-off is acceptable.

## See also

* [The `FOR UPDATE` clause](/docs/reference/query-language/statements/select.md#the-for-update-clause)
* [Transaction statements in SurrealQL](/docs/reference/query-language/language-primitives/transactions.md)
* [Using transactions to test code for errors](/docs/learn/querying/concepts-and-guides/testing.md#using-manual-transactions-for-testing)
* [Architecture](/docs/learn/data-models/architecture.md)
