Skip to content

Concepts

Transactions

Transactions allow you to execute a group of queries atomically, meaning either all changes are applied or none are. This is essential for maintaining data consistency when performing related operations that must not be partially applied.

Method

Description

db.beginTransaction()

Starts a new transaction

txn.commit()

Commits the transaction, applying all changes

txn.cancel()

Cancels the transaction, discarding all changes

Call .beginTransaction() on any Surreal or SurrealSession instance to start a new transaction. The returned SurrealTransaction object provides all the same query methods as a regular session, but every operation is executed within the transaction scope.

const txn = await db.beginTransaction();

Use the transaction object to execute queries just as you would on the Surreal instance. All operations are held in a pending state until you commit or cancel.

const txn = await db.beginTransaction();

await txn.create(new RecordId('users', 'alice'))
    .content({ name: 'Alice', email: 'alice@example.com' });

await txn.create(new RecordId('users', 'bob'))
    .content({ name: 'Bob', email: 'bob@example.com' });

Call .commit() to apply all pending changes to the database. After committing, the transaction cannot be used again.

await txn.commit();

Call .cancel() to discard all pending changes. This is typically done when an error occurs during the transaction. After cancelling, the transaction cannot be used again.

await txn.cancel();

Queries executed within a transaction can fail with a write conflict under concurrent load, just like queries outside a transaction. Since SurrealTransaction exposes the same query methods as a regular session, you can chain .retry() onto any statement inside the transaction to replay it with exponential backoff.

const txn = await db.beginTransaction();

await txn.update(new RecordId('accounts', 'alice'))
    .merge({ balance: from.balance - 100 })
    .retry({ attempts: 3 });

await txn.commit();

See Retrying on write conflict for how to configure a connection-wide default.

Always wrap transaction logic in a try-catch block to ensure the transaction is cancelled if any operation fails. This prevents partial changes from being committed.

const txn = await db.beginTransaction();

try {
    const from = await txn.select(new RecordId('accounts', 'alice'));
    const to = await txn.select(new RecordId('accounts', 'bob'));

    if (from.balance < 100) {
        throw new Error('Insufficient funds');
    }

    await txn.update(new RecordId('accounts', 'alice'))
        .merge({ balance: from.balance - 100 });

    await txn.update(new RecordId('accounts', 'bob'))
        .merge({ balance: to.balance + 100 });

    await txn.commit();
} catch (error) {
    await txn.cancel();
    throw error;
}

A transaction can also be written in SurrealQL with BEGIN and COMMIT inside one .query() call. When one statement fails, every statement in the transaction reports a failure: those before it with The query was not executed due to a failed transaction, the failing one with its own error, and COMMIT with Cannot COMMIT: the transaction was aborted due to a prior error.

Awaiting the query, directly or through .collect(), rejects with the first failure. That is the generic message of the first statement, not the cause. In the example below, user:1 already holds the email a@example.com, and a UNIQUE index named unique_email covers the email field:

const query = `
    BEGIN;
    CREATE user:2 SET email = 'b@example.com';
    CREATE user:3 SET email = 'a@example.com';
    COMMIT;
`;

try {
    await db.query(query);
} catch (error) {
    // The query was not executed due to a failed transaction
    console.log(error.message);
}

To find the cause, use .responses(), which returns every statement's result instead of rejecting:

const responses = await db.query(query).responses();

for (const response of responses) {
    if (!response.success) console.log(response.error.message);
}
Output
The query was not executed due to a failed transaction
Database index `unique_email` already contains 'a@example.com', with record `user:1`
Cannot COMMIT: the transaction was aborted due to a prior error

The first entry is BEGIN, which succeeds and returns nothing.

Execute transactions quickly to avoid holding resources longer than necessary. Perform any validation or external API calls before starting the transaction.

if (!isValidEmail(email)) {
    throw new Error('Invalid email');
}

const txn = await db.beginTransaction();
await txn.create(new RecordId('users', id)).content({ email });
await txn.commit();

Once a transaction is committed or cancelled, create a new one for subsequent operations.

const txn1 = await db.beginTransaction();
await txn1.create(record1).content(data1);
await txn1.commit();

const txn2 = await db.beginTransaction();
await txn2.create(record2).content(data2);
await txn2.commit();

On a remote WebSocket server, each open client-managed transaction counts toward SURREAL_MAX_TRANSACTIONS_PER_CONNECTION or SURREAL_MAX_TRANSACTIONS_PER_SESSION (default 64 each). Exceeding the limit fails with Too many open transactions.

Was this page helpful?