tursodatabase/turso · error · LibsqlError

TRANSACTION_CLOSED

TRANSACTION_CLOSED

Error message

Transaction is closed

What it means

Inside the transaction handle returned by the compat client's transaction(), every operation calls ensureOpen(), which throws a LibsqlError with code TRANSACTION_CLOSED when the internal txClosed flag is set. txClosed is flipped by closeTx(), which runs on commit(), rollback(), or close(), so the handle is intentionally single-shot: once the transaction finishes, its session is gone and the handle is dead.

Source

Thrown at serverless/javascript/src/compat.ts:386

        return "BEGIN";
    }
  }

  async transaction(mode?: TransactionMode): Promise<Transaction> {
    await this.execLock.acquire();

    if (this._closed) {
      this.execLock.release();
      throw new LibsqlError("Client is closed", "CLIENT_CLOSED");
    }

    const txSession = new Session(this.sessionConfig);
    let txClosed = false;
    let cleanupStarted = false;

    const ensureOpen = () => {
      if (txClosed) {
        throw new LibsqlError("Transaction is closed", "TRANSACTION_CLOSED");
      }
    };

    const closeTx = async () => {
      if (cleanupStarted) return;
      cleanupStarted = true;
      txClosed = true;
      try {
        await txSession.close();
      } finally {
        this.execLock.release();
      }
    };

    const executeInTx = async (stmt: InStatement): Promise<ResultSet> => {
      ensureOpen();
      const normalized = this.normalizeStatement(stmt);
      try {

View on GitHub (pinned to bad083fafb)

Solutions

  1. Perform all statements before commit()/rollback() — the handle dies as soon as the transaction ends
  2. Return early or restructure control flow so no code path reaches tx.* after a commit or rollback
  3. Check the public tx.closed getter before late operations
  4. If you need more work done, open a new transaction: await client.transaction(mode)

Example fix

// before
await tx.execute({ sql: 'INSERT INTO a VALUES (1)' });
await tx.commit();
await tx.execute({ sql: 'INSERT INTO b VALUES (1)' }); // TRANSACTION_CLOSED

// after
await tx.execute({ sql: 'INSERT INTO a VALUES (1)' });
await tx.execute({ sql: 'INSERT INTO b VALUES (1)' });
await tx.commit();
Defensive patterns

Strategy: validation

Validate before calling

async function txStep(tx: Transaction, stmt: InStatement) {
  if (tx.closed) {
    throw new Error('transaction already committed/rolled back — open a new one');
  }
  return tx.execute(stmt);
}

Type guard

const txIsOpen = (tx: Transaction): boolean => !tx.closed;

Try / catch

try {
  await tx.execute(stmt);
} catch (e) {
  if (e instanceof LibsqlError && e.code === 'TRANSACTION_CLOSED') {
    // Do not retry on the dead handle — restart the whole transaction if needed
    return runInNewTransaction(stmt);
  }
  throw e;
}

Prevention

When it happens

Trigger: Using tx.execute/tx.batch after awaiting tx.commit() or tx.rollback(). Double-committing (calling commit twice). Keeping the tx handle in a closure or variable and using it after the transaction body completes. Calling rollback in a catch block and then falling through to more tx calls.

Common situations: Control flow that continues after an early commit; error handlers that roll back and then code paths accidentally run more statements; storing the tx handle for later use (e.g. in a class field) instead of finishing all work inside the transaction scope.

Related errors


AI-assisted analysis of tursodatabase/turso@bad083fafb (2026-08-16). Data as JSON: /api/errors/93aa9c04aa2c59f5. Report an issue: GitHub.