Transactions
Some operations only make sense all-or-nothing. Transferring money debits one account and credits another; placing an order writes the order row and decrements inventory. If half of that happens and the other half fails, your data is corrupt. A transaction groups multiple statements into a single atomic unit: either every change commits together, or none of them do. This page covers the ACID guarantees, the BEGIN/COMMIT/ROLLBACK mechanics, how each ORM exposes them, isolation levels, and the failure modes — deadlocks, long-held locks, and the connection rule that ties back to pooling.
ACID — what a transaction guarantees
Property | Guarantee |
|---|---|
Atomicity | All statements commit, or none do — no partial writes |
Consistency | Constraints (FKs, checks, uniqueness) hold before and after |
Isolation | Concurrent transactions don't see each other's half-done work |
Durability | Once committed, the change survives a crash (written to disk/WAL) |
The mechanics: BEGIN, COMMIT, ROLLBACK
BEGIN; -- start the transaction UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT; -- make both permanent -- If anything fails between BEGIN and COMMIT: ROLLBACK; -- undo everything since BEGIN
Transactions in the raw Postgres driver (pg)
async function transfer(pool, fromId, toId, amount) {
const client = await pool.connect() // one dedicated connection
try {
await client.query('BEGIN')
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, fromId],
)
await client.query(
'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
[amount, toId],
)
await client.query('COMMIT')
} catch (e) {
await client.query('ROLLBACK') // undo on ANY failure
throw e
} finally {
client.release() // ALWAYS return the connection
}
}The same pattern in mysql2
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
await conn.execute('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1])
await conn.execute('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2])
await conn.commit()
} catch (e) {
await conn.rollback()
throw e
} finally {
conn.release()
}ORM transaction APIs
Prisma — sequential array, or interactive callback
// Array form — all succeed or all roll back:
await prisma.$transaction([
prisma.account.update({ where: { id: 1 }, data: { balance: { decrement: 100 } } }),
prisma.account.update({ where: { id: 2 }, data: { balance: { increment: 100 } } }),
])
// Interactive form — logic between queries; throw to roll back:
await prisma.$transaction(async (tx) => {
const from = await tx.account.findUnique({ where: { id: 1 } })
if (from.balance < 100) throw new Error('Insufficient funds') // → ROLLBACK
await tx.account.update({ where: { id: 1 }, data: { balance: { decrement: 100 } } })
await tx.account.update({ where: { id: 2 }, data: { balance: { increment: 100 } } })
})Sequelize (managed) & TypeORM
// Sequelize managed transaction — auto-commits on resolve, rolls back on throw:
await sequelize.transaction(async (t) => {
await Account.decrement('balance', { by: 100, where: { id: 1 }, transaction: t })
await Account.increment('balance', { by: 100, where: { id: 2 }, transaction: t })
})
// TypeORM — the manager is scoped to the transaction:
await AppDataSource.transaction(async (manager) => {
await manager.decrement(Account, { id: 1 }, 'balance', 100)
await manager.increment(Account, { id: 2 }, 'balance', 100)
})Isolation levels
Level | Prevents | Trade-off |
|---|---|---|
Read Uncommitted | Nothing (can read dirty data) | Fastest, least safe (rare in PG) |
Read Committed | Dirty reads | Default in Postgres; allows non-repeatable reads |
Repeatable Read | Dirty + non-repeatable reads | Default in MySQL/InnoDB |
Serializable | All anomalies — acts as if serial | Safest; may abort on conflict, must retry |
Deadlocks
Transaction A: locks row 1, then wants row 2
Transaction B: locks row 2, then wants row 1
→ each waits for the other forever → DEADLOCK
The database detects the cycle, kills one transaction (error 40P01 / 1213),
and the victim must RETRY. Avoid by locking rows in a consistent order.Row locking for read-modify-write
BEGIN; -- Lock the row so no other transaction can change it until we commit: SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- ...check balance in app code, then: UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT;
Rules for healthy transactions
Keep them short — a transaction holds locks until commit; long ones block others and bloat the DB.
Never do slow I/O inside — no HTTP calls, no waiting on user input while a transaction is open.
One connection, released in
finally— or use a managed/callback transaction that handles it.Let errors roll back — throw out of the block; don’t catch-and-ignore.
Retry transient failures — deadlocks and serialization errors are expected; loop a few times.
Don't wrap reads that don't need it — transactions add overhead; use them where atomicity matters.