PostgreSQL transactions ensure that a series of database operations either all succeed or all fail together, maintaining data consistency. Node-postgres provides two approaches for handling transactions: manual transaction management and the very slightly higher-level pg-transaction
module.
pg-transaction Module
The pg-transaction
module provides a tiny level of abstraction for handling transactions, automatically running BEGIN
, COMMIT
, and/or ROLLBACK
.
The motivation for this module was I pretty much write the same exact thing in every project I start. Sounds like a good thing to just publish widely.
Installation
The pg-transaction
module is included as part of the node-postgres monorepo:
npm install pg-transaction
Basic Usage
The transaction
function accepts either a Client
or Pool
instance and a callback function:
import { Pool } from 'pg'
import { transaction } from 'pg-transaction'
const pool = new Pool()
// Using with a Pool (recommended)
const result = await transaction(pool, async (client) => {
const userResult = await client.query(
'INSERT INTO users(name) VALUES($1) RETURNING id',
['Alice']
)
await client.query(
'INSERT INTO photos(user_id, photo_url) VALUES ($1, $2)',
[userResult.rows[0].id, 's3.bucket.foo']
)
return userResult.rows[0]
})
console.log('User created:', result)
API Reference
transaction(clientOrPool, callback)
Parameters:
clientOrPool
: Apg.Client
orpg.Pool
instancecallback
: An async function that receives a client and returns a promise
Returns: A promise that resolves to the return value of the callback
Behavior:
- Automatically executes
BEGIN
before the callback - Executes
COMMIT
if the callback completes successfully - Executes
ROLLBACK
if the callback throws an error, then re-throws the error for you to handle - When using a Pool, automatically acquires and releases a client
- When using a Client, uses the provided client directly. The client must be connected already.
Usage Examples
With Pool (Recommended)
import { Pool } from 'pg'
import { transaction } from 'pg-transaction'
const pool = new Pool()
try {
const userId = await transaction(pool, async (client) => {
// All queries within this callback are part of the same transaction
const userResult = await client.query(
'INSERT INTO users(name, email) VALUES($1, $2) RETURNING id',
['John Doe', 'john@example.com']
)
const profileResult = await client.query(
'INSERT INTO user_profiles(user_id, bio) VALUES($1, $2)',
[userResult.rows[0].id, 'Software developer']
)
// Return the user ID
return userResult.rows[0].id
})
console.log('Created user with ID:', userId)
} catch (error) {
console.error('Transaction failed:', error)
// All changes have been automatically rolled back
}
With Client
import { Client } from 'pg'
import { transaction } from 'pg-transaction'
const client = new Client()
await client.connect()
try {
await transaction(client, async (client) => {
await client.query('UPDATE accounts SET balance = balance - 100 WHERE id = $1', [1])
await client.query('UPDATE accounts SET balance = balance + 100 WHERE id = $1', [2])
})
console.log('Transfer completed successfully')
} catch (error) {
console.error('Transfer failed:', error)
} finally {
await client.end()
}
Binding for Reuse
You can bind the transaction function to a specific pool or client for convenient reuse. I usually do this as a module level singleton I export after I define my pool.
import { Pool } from 'pg'
import { transaction } from 'pg-transaction'
const pool = new Pool()
const txn = transaction.bind(null, pool)
// Now you can use txn directly
await txn(async (client) => {
await client.query('INSERT INTO logs(message) VALUES($1)', ['Operation 1'])
})
await txn(async (client) => {
await client.query('INSERT INTO logs(message) VALUES($1)', ['Operation 2'])
})
Error Handling and Rollback
The transaction function automatically handles rollbacks when errors occur:
import { transaction } from 'pg-transaction'
try {
await transaction(pool, async (client) => {
await client.query('INSERT INTO orders(user_id, total) VALUES($1, $2)', [userId, 100])
// This will cause the transaction to rollback
if (Math.random() > 0.5) {
throw new Error('Payment processing failed')
}
await client.query('UPDATE inventory SET quantity = quantity - 1 WHERE product_id = $1', [productId])
})
} catch (error) {
// The transaction has been automatically rolled back
console.error('Order creation failed:', error.message)
}
Migration from Manual Transactions
If you're currently using manual transaction handling, migrating to pg-transaction
is straightforward:
Before (Manual):
const client = await pool.connect()
try {
await client.query('BEGIN')
const result = await client.query('INSERT INTO users(name) VALUES($1) RETURNING id', ['Alice'])
await client.query('INSERT INTO profiles(user_id) VALUES($1)', [result.rows[0].id])
await client.query('COMMIT')
return result.rows[0]
} catch (error) {
await client.query('ROLLBACK')
throw error
} finally {
client.release()
}
After (pg-transaction):
return await transaction(pool, async (client) => {
const result = await client.query('INSERT INTO users(name) VALUES($1) RETURNING id', ['Alice'])
await client.query('INSERT INTO profiles(user_id) VALUES($1)', [result.rows[0].id])
return result.rows[0]
})
Manual Transaction Handling
For cases where you need more control or prefer to handle transactions manually, you can execute BEGIN
, COMMIT
, and ROLLBACK
queries directly.
You must use the same client instance for all statements within a transaction. PostgreSQL isolates a transaction to individual clients. This means if you initialize or use transactions with the pool.query method you will have problems. Do not use transactions with the pool.query method.
Manual Transaction Example
import { Pool } from 'pg'
const pool = new Pool()
const client = await pool.connect()
try {
await client.query('BEGIN')
const queryText = 'INSERT INTO users(name) VALUES($1) RETURNING id'
const res = await client.query(queryText, ['brianc'])
const insertPhotoText = 'INSERT INTO photos(user_id, photo_url) VALUES ($1, $2)'
const insertPhotoValues = [res.rows[0].id, 's3.bucket.foo']
await client.query(insertPhotoText, insertPhotoValues)
await client.query('COMMIT')
} catch (e) {
await client.query('ROLLBACK')
throw e
} finally {
client.release()
}