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
- Perform all statements before commit()/rollback() — the handle dies as soon as the transaction ends
- Return early or restructure control flow so no code path reaches tx.* after a commit or rollback
- Check the public tx.closed getter before late operations
- 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
- Keep all statements before commit()/rollback() and return immediately after ending the transaction
- Never store the tx handle beyond the transaction scope (no class fields, no background queues)
- Check the public tx.closed getter when control flow makes completion state unclear
- In catch blocks that roll back, ensure no subsequent code path uses tx again
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
- Database is closed
- CLIENT_CLOSED
- The database connection is not open
- The transaction has already completed
- Cannot operate on a closed cursor
AI-assisted analysis of tursodatabase/turso@bad083fafb (2026-08-16).
Data as JSON: /api/errors/93aa9c04aa2c59f5.
Report an issue: GitHub.