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
- 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
- Open a fresh transaction with client.transaction() for each new unit of work
- Check the tx.closed getter on the Transaction object before issuing statements
- 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
- Terminate each transaction exactly once: commit in try, rollback in catch, nothing in finally
- Check tx.closed before issuing statements on stored transaction handles
- Open a new transaction per unit of work - never reuse a committed/rolled-back handle
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
- CLIENT_CLOSED
- Expected first argument to be an array of statements
- UNSUPPORTED_CONFIG
- batch mode must be one of
- batch response does not have one result and one error per…
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)