To keep a node-postgres transaction together, acquire one client with pool.connect(), run every statement through that client, and release it after commit or rollback. Pass that client into helpers too. The official Transactions guide requires this pattern.
This tutorial is for a developer using Terminal on a Mac to make several article-row changes succeed or fail together. It includes a complete program with synthetic article rows, a deliberate rollback test, and a diagnostic sequence for queries hidden inside helper functions.
Why does pool.query() break transaction scope?
A pool manages connections; it does not make every query part of one transaction. Each pool.query() call acquires an available client internally. Consecutive calls therefore have no guarantee of using the same connection. The node-postgres Pool API explicitly warns against using this method for transactions.
For example, sending BEGIN through the pool and then sending an insert through the pool does not reliably put that insert inside the transaction you started. PostgreSQL normally executes statements outside an explicit transaction block in their own transactions. A later rollback on another connection cannot undo those independently committed changes. See PostgreSQL’s BEGIN documentation.
The useful boundary is the checked-out client object. A helper’s name, the module containing it, and the fact that its caller is awaiting it do not establish transaction membership. A helper participates when its queries use the client that started the transaction. This follows directly from the same-client requirement in the Transactions guide.
Run a complete commit-and-rollback example on your Mac
You need Node.js with npm, a running PostgreSQL server, and an existing local development database. This walkthrough assumes that database is named article_demo and that your normal local connection settings already work. Database installation is outside this transaction task.
In Terminal, create a new working directory and install the driver:
mkdir pg-transaction-demo
cd pg-transaction-demo
npm init -y
npm install pg
Save the following complete program as transaction-demo.mjs. Its temporary table contains only synthetic article IDs and titles. PostgreSQL temporary tables normally survive commits and are removed when the session ends; those properties let the program inspect a successful batch after a second batch rolls back. See CREATE TABLE.
The transaction helper below follows the documented BEGIN, COMMIT, and ROLLBACK sequence. It adds explicit handling for a rollback that also fails, preserving both errors instead of replacing the original failure. Query values use the text-and-values API documented under Queries.
import pg from 'pg';
import assert from 'node:assert/strict';
const { Pool } = pg;
async function inTransaction(client, work) {
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
try {
const result = await work(client);
await client.query('COMMIT');
return result;
} catch (error) {
try {
await client.query('ROLLBACK');
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
'Transaction failed; rollback also failed'
);
}
throw error;
}
}
async function insertArticle(client, article) {
const result = await client.query(
`INSERT INTO pg_temp.transaction_demo_articles (id, title)
VALUES ($1, $2)
RETURNING id, title`,
[article.id, article.title]
);
return result.rows[0];
}
async function insertBatch(client, articles) {
const inserted = [];
for (const article of articles) {
inserted.push(await insertArticle(client, article));
}
return inserted;
}
async function main() {
const pool = new Pool({ connectionTimeoutMillis: 5000 });
let client;
let discardClient = false;
pool.on('error', (error) => {
console.error('Idle client error:', error);
process.exitCode = 1;
});
try {
client = await pool.connect();
await client.query(`
CREATE TEMP TABLE transaction_demo_articles (
id text PRIMARY KEY,
title text NOT NULL
) ON COMMIT PRESERVE ROWS
`);
const saved = await inTransaction(client, (tx) =>
insertBatch(tx, [
{ id: 'article-001', title: 'A guide to paper shapes' },
{ id: 'article-002', title: 'Choosing a notebook layout' }
])
);
assert.equal(saved.length, 2);
await assert.rejects(
() => inTransaction(client, (tx) =>
insertBatch(tx, [
{ id: 'article-003', title: 'Arranging a reading desk' },
{ id: 'article-001', title: 'Deliberate duplicate ID' }
])
),
(error) => error.code === '23505'
);
const remaining = await client.query(`
SELECT id, title
FROM pg_temp.transaction_demo_articles
ORDER BY id
`);
assert.deepEqual(
remaining.rows.map((row) => row.id),
['article-001', 'article-002']
);
console.log('Commit and rollback checks passed.');
console.table(remaining.rows);
} catch (error) {
discardClient = true;
throw error;
} finally {
if (client) client.release(discardClient);
await pool.end();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Run it from the same directory:
PGDATABASE=article_demo node transaction-demo.mjs
The pool reads PostgreSQL connection environment variables. If your local setup requires different connection settings, supply the appropriate PGHOST and PGPORT alongside PGDATABASE. The official Connecting guide describes these variables and their defaults.
On a successful run, expect Commit and rollback checks passed. followed by the two original article rows. The second batch inserts article-003 and then attempts to reuse article-001. That duplicate produces SQLSTATE 23505, documented as unique_violation in Serialization Failure Handling. Rolling back the batch removes its earlier insert too; the assertion checks that article-003 is absent.
The transaction helper’s success, work-error, and rollback-error control paths were checked with a deterministic mock client. The database example was not executed here because the environment lacked the pg package and a PostgreSQL test connection; the output above describes the expected result.
How should helpers share the transaction client?
Use explicit client parameters throughout the call chain. In the program, main() acquires the client, inTransaction() passes that same object to its callback, and insertBatch() forwards it to insertArticle(). There is no connection checkout inside either insert helper.
For this implementation, keep ownership clear:
| Function | Responsibility |
|---|---|
main() |
Acquire the client, perform demo setup, release the client, and close the script’s pool. |
inTransaction() |
Start and finish one transaction on the supplied client. |
insertBatch() |
Await each article operation and forward the supplied client. |
insertArticle() |
Execute its insert through the supplied client. |
The helper contract is deliberately narrow: call inTransaction() only when that client has no active transaction, and let failures propagate out of its callback. Calling BEGIN inside an existing PostgreSQL transaction does not create an independent nested transaction; PostgreSQL documents savepoints as the mechanism for nested work. See BEGIN.
Await every operation that belongs to the transaction before returning from its callback. In this example, the sequential loop makes that completion point explicit. Starting asynchronous inserts without returning or awaiting their promises would let the callback finish before it had observed all their results.
What should happen when a query or rollback fails?
An ordinary query failure reaches the transaction helper’s catch block, which attempts rollback on the same client and rethrows the original error. The deliberate duplicate is accepted only after that rollback completes. If rollback fails, the helper throws an AggregateError; the duplicate-error check does not accept it, so execution reaches the outer failure handler.
The example conservatively destroys the client after any unexpected failure using client.release(true). On the normal path it returns the client with client.release(false). The destroy argument is documented in the Pool API.
Always release a successfully acquired client. Otherwise later callers can wait for a connection that never becomes available. Close the pool after this standalone script finishes; in an application, pool shutdown belongs to application shutdown. The Pooling guide explains release requirements and how pool.end() waits for checked-out clients to return.
Does one client guarantee a stable snapshot?
Client continuity and isolation level answer different questions. The client determines which transaction receives your statements. The isolation level determines what those statements can observe while other transactions run. PostgreSQL’s Transaction Isolation documentation defines these limits:
| Isolation level | Relevant limit |
|---|---|
READ COMMITTED |
The default. Successive ordinary reads can see different concurrently committed data. Each statement gets a new snapshot. |
REPEATABLE READ |
Uses a snapshot established at the first non-transaction-control statement. PostgreSQL prevents phantom reads, but serialization anomalies remain possible. |
SERIALIZABLE |
Committed serializable transactions behave consistently with some serial execution; a transaction may fail and require retrying. |
READ UNCOMMITTED |
Behaves as READ COMMITTED in PostgreSQL. |
Reads can also see changes made earlier by their own transaction. Consequently, observing your own insert does not mean all later reads have a frozen view of changes from other transactions. See Transaction Isolation.
The example explicitly selects READ COMMITTED in its opening statement. To use a different level, change that statement before running the transaction. PostgreSQL allows transaction characteristics in BEGIN; if you instead use SET TRANSACTION, isolation must be set before the first query or data-modification statement. See BEGIN and SET TRANSACTION.
If you choose REPEATABLE READ or SERIALIZABLE, prepare for SQLSTATE 40001. Retry the complete transaction, including the logic that chooses statements and values, rather than replaying only the failed query. The example intentionally propagates such errors instead of implementing retries. PostgreSQL’s Serialization Failure Handling explains this requirement and notes that retries can fail again.
How can you find a query that escaped the transaction?
Trace one intended batch from its connection checkout to its final commit or rollback. Use this sequence to review your code:
- Locate the
pool.connect()call and identify the client it returns. - Follow each helper invoked between
BEGINand transaction completion. Confirm that each receives and uses that client. - Inspect shared query wrappers for hidden
pool.query()calls or additional connection checkouts. - Confirm that every operation is awaited and that helpers propagate failures.
- Adapt the synthetic duplicate-row test to your batch and verify that an earlier insert disappears after a later statement fails.
A passing rollback test supplies useful evidence for the exercised path, but it cannot establish that every application branch uses the right client. Review conditional helpers too. If queries stay on one client yet repeated reads still differ, compare that behavior with the selected isolation level before changing connection handling.
Keep acquisition and release visible, pass the client explicitly, and make transaction completion depend on all intended operations finishing. That gives you a concrete structure to inspect and a rollback test to repeat as the article workflow grows.
Sources
- Transactions — node-postgres.
- pg.Pool — node-postgres.
- Queries — node-postgres.
- Connecting — node-postgres.
- Pooling — node-postgres.
- BEGIN — PostgreSQL Global Development Group.
- CREATE TABLE — PostgreSQL Global Development Group.
- 13.2. Transaction Isolation — PostgreSQL Global Development Group.
- SET TRANSACTION — PostgreSQL Global Development Group.
- 13.5. Serialization Failure Handling — PostgreSQL Global Development Group.