| title | Transactions |
|---|
import { Alert } from '/components/alert.tsx'
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.
The pg-transaction module provides a tiny level of abstraction for handling transactions, automatically managing BEGIN, COMMIT, and ROLLBACK operations while ensuring proper client lifecycle management.
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.
The pg-transaction module is included as part of the node-postgres monorepo:
npm install pg-transactionThe 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)Parameters:
clientOrPool: Apg.Clientorpg.Poolinstancecallback: 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
BEGINbefore the callback - Executes
COMMITif the callback completes successfully - Executes
ROLLBACKif 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.
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
}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()
}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'])
})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)
}-
Use with Pools: When possible, use the transaction function with a
Poolrather than aClientfor better resource management. -
Keep Transactions Short: Minimize the time spent in transactions to reduce lock contention.
-
Handle Errors Appropriately: Let the transaction function handle rollbacks, but ensure your application logic handles the errors appropriately.
-
Avoid Nested Transactions: PostgreSQL doesn't support true nested transactions. Use savepoints if you need nested behavior.
-
Return Values: Use the callback's return value to pass data out of the transaction.
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]
})For cases where you need more control or prefer to handle transactions manually, you can execute BEGIN, COMMIT, and ROLLBACK queries directly.
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()
}Consider manual transaction handling when you need:
- Savepoints: Creating intermediate rollback points within a transaction
- Custom Transaction Isolation Levels: Setting specific isolation levels
- Complex Transaction Logic: Conditional commits or rollbacks based on business logic
- Performance Optimization: Fine-grained control over transaction boundaries
const client = await pool.connect()
try {
await client.query('BEGIN')
// First operation
await client.query('INSERT INTO orders(user_id, total) VALUES($1, $2)', [userId, total])
// Create a savepoint
await client.query('SAVEPOINT order_items')
try {
// Attempt to insert order items
for (const item of items) {
await client.query('INSERT INTO order_items(order_id, product_id, quantity) VALUES($1, $2, $3)',
[orderId, item.productId, item.quantity])
}
} catch (error) {
// Rollback to savepoint, keeping the order
await client.query('ROLLBACK TO SAVEPOINT order_items')
console.log('Order items failed, but order preserved')
}
await client.query('COMMIT')
} catch (error) {
await client.query('ROLLBACK')
throw error
} finally {
client.release()
}Recommendation: Use pg-transaction for most use cases, and fall back to manual transaction handling only when you need advanced features like savepoints or custom isolation levels. Note: the number of times I've done this in production apps is nearly zero.