tursodatabase/turso · error · LibsqlError

TRANSACTION_CLOSED

TRANSACTION_CLOSED

Error message

Transaction is closed

What it means

Inside a transaction, every operation funnels through ensureOpen(), which throws LibsqlError code TRANSACTION_CLOSED once txClosed is set - which happens as soon as commit(), rollback() or close() has run (closeTx sets it before closing the session). The transaction object deliberately keeps working as an object but refuses further SQL: one transaction equals one unit of work.

Solutions

  1. Call exactly one of commit/rollback/close per transaction, and track it with a flag or use try/commit + catch/rollback without a finally-close
  2. Open a fresh transaction with client.transaction() for each new unit of work
  3. Check the tx.closed getter on the Transaction object before issuing statements
  4. In error paths, let the catch do rollback and make finally a no-op, so a second close cannot throw over the real error

Example fix

// before - double close: rollback in catch AND finally
try {
  await tx.execute(insert);
  await tx.commit();
} catch (e) {
  await tx.rollback();
} finally {
  await tx.rollback(); // throws TRANSACTION_CLOSED, masks the real error
}

// after - exactly one terminator per transaction
try {
  await tx.execute(insert);
  await tx.commit();
} catch (e) {
  if (!tx.closed) await tx.rollback();
}
Defensive patterns

Strategy: type-guard

Validate before calling

if (!tx.closed) {
  await tx.execute(stmt);
}

Type guard

function isTxOpen(tx: Transaction): boolean {
  return !tx.closed;
}

Try / catch

catch (e) { if (e instanceof LibsqlError && e.code === 'TRANSACTION_CLOSED') { const fresh = await client.transaction(mode); return fresh.execute(stmt); // restart the unit of work on a new tx } throw e; }

Prevention

When it happens

Trigger: Calling tx.execute(...) or tx.batch(...) after an earlier await tx.commit() / tx.rollback() / tx.close(); classic double-close shape: rollback in a catch plus commit/rollback again in a finally; storing the tx object and reusing it for the next logical operation.

Common situations: finally { await tx.rollback(); } combined with an explicit rollback in the error path - the second call throws and masks the original error; sequential business operations mistakenly sharing one transaction handle instead of opening a new one.

Related errors


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

Appendix: source

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

        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 6c72522679)