Fix Drizzle Rollback: let the exception escape
Prove whether a swallowed manual rollback commits, then check the persisted rows after changing one catch handler.
TL;DR#
With Drizzle's PostgreSQL adapter, tx.rollback() raises an exception; it does not directly send a SQL rollback command. If your callback catches that exception and returns normally, Drizzle can commit the successful writes. Add throw error to the inner catch, or remove that catch and handle rejection outside the awaited transaction. This diagnosis applies to a suppressed manual rollback, not every SQL error caught inside a transaction.
The exception can look like this:
inner catch: TransactionRollbackError [DrizzleError]: RollbackThat line alone does not prove whether anything was rolled back. The important questions are whether the exception reached the transaction wrapper and what another database connection sees afterwards.
The starting point is Drizzle issue #1723. Its original report describes Vercel Postgres and an earlier update remaining after a later operation failed. A PostgreSQL follow-up identifies a specific mistake: suppressing the exception from manual rollback. Other comments discuss SQLite drivers. Those are separate cases; this article verifies the PostgreSQL catch pattern rather than claiming to explain every report in that thread.
Why it happens#
There are two different operations behind the word rollback. The application calls a JavaScript method, while the transaction wrapper later sends a SQL command. Drizzle's PostgreSQL transaction implementation makes the first operation explicit: the method throws TransactionRollbackError. The transaction documentation also shows manual rollback as an exception raised when business logic rejects an operation.
For the node-postgres adapter, the wrapper implementation begins the transaction and awaits your callback. A fulfilled callback leads to an attempted commit. A rejected callback enters the catch branch, which sends rollback and throws the error again. That branch is the bridge between the JavaScript exception and the database command.
An inner catch can break that bridge. Logging an error and reaching the end of an async function fulfils its promise with undefined. Returning an object describing failure also fulfils it. Neither tells the wrapper to enter its rejection branch. For a manual rollback exception, no SQL statement has failed, so PostgreSQL still has a usable transaction containing the earlier successful write. The subsequent commit can therefore persist it.
This behaviour was reproduced on 9 October 2026 using Drizzle ORM 0.45.4, pg 8.23.1, Node 24.21.0 and PostgreSQL 18.6 in isolated official Docker images. The Drizzle npm metadata identified the latest stable package for the test. The installed package's source was checked as well as the current repository source. The test is local with the node-postgres adapter; it does not reproduce a hosted Vercel deployment.
Fix#
- Locate the catch that prevents rejection from leaving the callback. Start with the code around manual rollback rather than changing the isolation level or database schema. This complete helper demonstrates the failing pattern; the test table is created below.
import { sql } from 'drizzle-orm';
export async function swallowedRollback(db) {
await db.transaction(async (tx) => {
try {
await tx.execute(sql`insert into rollback_items (id, note) values (1, 'manual rollback')`);
tx.rollback();
} catch (error) {
console.log(error);
}
});
}Every write in this helper uses the supplied transaction object. That matters: fixing exception handling cannot bring a query issued through a different connection into this transaction. The node-postgres transaction guide requires all statements in a transaction to use the same client. Drizzle's wrapper obtains that client for the transaction object; keep your application queries on that object.
- Rethrow the exception inside that catch. The only change in this helper is the added throw. Logging may stay where it is, but the callback must reject when the operation should abort.
import { sql } from 'drizzle-orm';
export async function propagatedRollback(db) {
await db.transaction(async (tx) => {
try {
await tx.execute(sql`insert into rollback_items (id, note) values (1, 'manual rollback')`);
tx.rollback();
} catch (error) {
console.log(error);
throw error;
}
});
}The rejected promise is expected. A successful rollback should not turn an operation that was deliberately cancelled into a successful application result. If your caller needs to display a validation message, it can convert this rejection into the application's response after the transaction has finished.
- Handle that rejection outside the transaction. This complete caller works with the corrected helper saved as propagated-rollback.mjs. Keep the boundary around the awaited operation so the result is not reported before the transaction settles.
import { propagatedRollback } from './propagated-rollback.mjs';
export async function handleRollbackRequest(db) {
try {
await propagatedRollback(db);
return { ok: true };
} catch (error) {
console.error(error);
return { ok: false, message: 'The operation was cancelled.' };
}
}For this deliberately cancelled helper, the failure result is the expected route. Real business logic can place manual rollback behind its validation condition, as the Drizzle documentation demonstrates. There is no need to invoke manual rollback again in the outer catch: the wrapper has already sent the command before rejecting in this reproduced case.
Verify the fix#
Use a disposable PostgreSQL database and an independent client for the final read. A select made inside the transaction can see its own uncommitted writes; that is insufficient evidence of persistence. PostgreSQL's transaction tutorial explains when other sessions can see committed changes.
Install the versions used in the reproduction in a separate test directory:
npm install drizzle-orm@0.45.4 pg@8.23.1Save both helpers under the filenames used below. Set the PostgreSQL connection environment for your disposable database using the documented node-postgres connection settings. Then save this runner as verify-rollback.mjs. It creates the exercise table, runs the failing helper, resets only that table, and runs the corrected helper with the same insert.
import assert from 'node:assert/strict';
import pg from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { swallowedRollback } from './swallowed-rollback.mjs';
import { propagatedRollback } from './propagated-rollback.mjs';
const pool = new pg.Pool();
const observer = new pg.Client();
const db = drizzle(pool, { logger: true });
try {
await observer.connect();
await observer.query('create table rollback_items (id integer primary key, note text not null)');
await swallowedRollback(db);
const before = (await observer.query('select * from rollback_items')).rows;
assert.equal(before.length, 1);
await observer.query('truncate rollback_items');
await assert.rejects(() => propagatedRollback(db));
const after = (await observer.query('select * from rollback_items')).rows;
assert.equal(after.length, 0);
console.log({ before, after });
} finally {
await observer.end();
await pool.end();
}Run the file as an ES module:
node verify-rollback.mjsThe isolated reproduction used a custom query logger and printed these lines for the failing case:
CASE before: rollback exception swallowed
SQL: begin params=[]
SQL: insert into rollback_items (id, note) values (1, 'manual rollback') params=[]
inner catch: TransactionRollbackError [DrizzleError]: Rollback
SQL: commit params=[]
outside rows: [{"id":1,"note":"manual rollback"}]With the single added throw, the database, table, insert and adapter stayed the same:
CASE after: same catch rethrows
SQL: begin params=[]
SQL: insert into rollback_items (id, note) values (1, 'manual rollback') params=[]
inner catch: TransactionRollbackError [DrizzleError]: Rollback
SQL: rollback params=[]
outer catch: TransactionRollbackError [DrizzleError]: Rollback
outside rows: []The separate observer finding no row after rejection is the useful verification. Seeing a rollback exception in the console without checking its propagation is exactly how the failing pattern can appear to work.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
A caught SQL error can leave PostgreSQL aborted#
Do not generalise the manual rollback result to every caught database error. The negative control inserted a row successfully, attempted a second insert with the same primary key, caught the resulting SQL error inside the callback, and let the callback finish. Its relevant output was:
SQL cause: 23505: duplicate key value violates unique constraint "rollback_items_pkey"
SQL: commit params=[]
outside rows: []The logger records the command Drizzle attempted, not proof that PostgreSQL committed the writes. The duplicate-key failure had put the transaction in an aborted state. The PostgreSQL tutorial documents that recovery from this state requires rollback to a savepoint or a full rollback and restart. In this control, no inserted row persisted despite the swallowed error. A second control allowed that SQL exception to escape and also found no rows after the wrapper rolled back.
Keep propagating unexpected SQL errors so the caller receives an honest failure. If the underlying problem is a duplicate key, use the PostgreSQL conflict-handling article to assess whether an atomic insert with conflict handling matches your intended operation. Exception propagation preserves the transaction boundary; it does not resolve invalid input or change the uniqueness rule.
Nested rollback stops at its savepoint#
There is another legitimate reason that some work can remain after a rollback exception: a nested transaction. Drizzle documents nested transactions as savepoints, and its node-postgres adapter rolls a rejected nested callback back to that savepoint. If the parent catches the nested rejection and then succeeds, the parent's earlier writes may still commit.
The nested control inserted a parent row, inserted a child row inside a nested callback, and called manual rollback there. The parent caught the rejection after the nested wrapper had handled it. The captured ending was:
SQL: rollback to savepoint sp1 params=[]
parent catch: TransactionRollbackError [DrizzleError]: Rollback
SQL: commit params=[]
outside rows: [{"id":1,"note":"parent"}]That is a successful savepoint rollback followed by a parent commit. If your business rule requires cancelling the whole operation, propagate the nested rejection through the parent callback as well. If partial recovery is intentional, verify both the preserved parent work and the removed child work rather than treating the presence of any row as a failure.
Once exception propagation works, inspect the real database error that caused the cancellation. If it changes to a foreign-key violation, continue with the PostgreSQL foreign-key checks for missing parents or blocked deletes. If concurrent transactions instead raise a serialization failure, the complete-transaction retry pattern addresses that separate problem. The rollback fix makes failure handling truthful; the subsequent remedy should follow the specific database error and the operation you intended.
Further reading: Drizzle "table name specified more than once" in nested with.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix Postgres 'Could Not Serialize Access' (40001)
Postgres aborts one transaction with 40001 to prevent a serialization anomaly. The docs are explicit: apps using REPEATABLE READ or SERIALIZABLE must be prepared to retry. Here's the correct retry loop and how to reduce conflicts.
Drizzle "table name specified more than once" in nested with
Get a deep findMany({ with }) tree running again: how the 63-byte alias limit makes two Drizzle aliases identical, a pre-flight check, and two tested fixes.
How to Drop All Tables in PostgreSQL Safely (2026)
Dropping tables one by one is painful. Here is the one-command reset, the PostgreSQL 15 permission gotcha that breaks it, and the safe way to do it on Supabase.


