Build the mental model
A transaction groups multiple operations so they succeed or fail together - debiting Account A and crediting Account B must both happen.
BEGIN
Start a new transaction
Operation A + B
Run the debit and credit operations
COMMIT or ROLLBACK
COMMIT if everything succeeded, ROLLBACK if anything failed
- Atomicity - all-or-nothing
- Consistency - valid state to valid state
- Isolation - concurrent transactions don't interfere unexpectedly
- Durability - once committed, it stays committed
Concurrency - multiple users reading and writing at once - can cause problems like lost updates or stale reads.
Isolation controls how much transactions can see of each other. PostgreSQL's transaction/MVCC lessons and Redis's transaction lesson cover this hands-on.
- Transaction
- A group of database operations that succeed or fail together as one unit.
- ACID
- The four guarantees a transaction is expected to provide - Atomicity, Consistency, Isolation, Durability.
BANK TRANSFER TRANSACTION FLOW
------------------------------
BEGIN
|
v
Operation A: debit Account A (balance -= amount)
|
v
Operation B: credit Account B (balance += amount)
|
v
Did both operations succeed and stay valid?
|
+--YES--> COMMIT (both changes are kept)
|
+--NO ---> ROLLBACK (both changes undone,
as if nothing happened)Connect it to a real scenario
Wrap related writes in a transaction whenever they must all succeed or fail together - creating an order plus decrementing stock, or transferring points, are typical cases.
Most frameworks and libraries provide a transaction helper, so you rarely write raw BEGIN/COMMIT/ROLLBACK by hand - but understanding what happens underneath still helps.
Be deliberate about what gets grouped - wrapping too much unrelated work in one long transaction can hurt concurrency.
The runnable example below simulates a bank-transfer transaction, rolling both balances back untouched if funds are insufficient.
Try the working example
function transferFunds(accounts, fromId, toId, amount) {
const original = accounts.map((a) => ({ ...a }));
const from = accounts.find((a) => a.id === fromId);
const to = accounts.find((a) => a.id === toId);
from.balance -= amount;
to.balance += amount;
if (from.balance < 0) {
for (const a of accounts) {
const o = original.find((x) => x.id === a.id);
a.balance = o.balance;
}
return {
status: "ROLLBACK",
reason: "Insufficient funds",
accounts: accounts.map((a) => ({ ...a })),
};
}
return { status: "COMMIT", accounts: accounts.map((a) => ({ ...a })) };
}
const accountsA = [{ id: "A", balance: 500 }, { id: "B", balance: 100 }];
console.log("Successful transfer:", transferFunds(accountsA, "A", "B", 200));
const accountsB = [{ id: "A", balance: 50 }, { id: "B", balance: 100 }];
console.log("Rolled-back transfer:", transferFunds(accountsB, "A", "B", 200));Successful transfer: { status: 'COMMIT', accounts: [ {id:'A',balance:300}, {id:'B',balance:300} ] }
Rolled-back transfer: { status: 'ROLLBACK', reason: 'Insufficient funds', accounts: [ {id:'A',balance:50}, {id:'B',balance:100} ] }
The first transfer succeeds because A had 500. The second would push A's balance negative, so both balances are rolled back to their original values instead.5-minute try-it
Extend transferFunds to also push into a transferHistory log array - add an entry on every COMMIT, and add nothing on a ROLLBACK.
One important caution
Leaving related writes ungrouped, which risks half-completed updates when one step fails
Bundling too much unrelated work into one long transaction, unnecessarily hurting concurrency
Wikipedia: ACID — How Databases Work