Transactions and Isolation Levels
Understand what transactions guarantee, how isolation levels differ, and how lost updates happen — through two live sessions running against a real PostgreSQL database.
Most backend bugs involving double-charges, negative stock, or vanishing writes trace back to a misunderstood isolation level. This course fixes that from the ground up. We start by showing exactly what happens when a money transfer runs without a transaction — and what autocommit leaves behind when the process dies mid-flight. Then we wrap the same transfer in BEGIN/COMMIT and watch Postgres make it atomic. From there we compare the three isolation levels side by side on two live sessions: Read Committed shows non-repeatable reads in action; Repeatable Read holds a consistent snapshot; Serializable catches the write-skew cycle and aborts with a real error you can reproduce. We close with the lost-update problem on an inventory table — two sessions both reading stock=50 and both writing 49 — and show the two standard fixes: an atomic UPDATE that never reads first, and SELECT FOR UPDATE that serialises access at the row level.
What you'll build
- Open a transaction with BEGIN, commit it, and roll it back
- Explain why partial changes without a transaction are permanent
- Observe non-repeatable reads at Read Committed with two live sessions
- See Repeatable Read hold a stable snapshot across concurrent commits
- Reproduce a real serialization failure and understand the retry contract
- Demonstrate lost update on an inventory table and explain why it happens
- Fix lost update with an atomic UPDATE expression
- Fix lost update with SELECT FOR UPDATE and understand the trade-off
Contents
- A database for concurrent sessions
- Start it
- Connect with psql
- The accounts table
- The transfers table
- The inventory table
- Create the tables
- The seed data
- Load the data
- Confirm the starting balances
- Three statements, no transaction
- It works — until it doesn't
- Simulate a crash mid-transfer
- The money is gone
- Reset to clean state
- Wrap it in BEGIN and COMMIT
- The atomic transfer
- The transfer landed correctly
- The audit log was written
- Reset before the rollback demo
- What happens when something goes wrong inside a transaction
- The error and the rollback
- The balance is intact
- Reset before the isolation demos
- Two sessions, two terminals
- Session A opens a Read Committed transaction
- Session A reads 1000.00
- Session B commits a change while A is still open
- Session B updated and committed
- Session A reads again — sees the new value
- Reset for the Repeatable Read demo
- Session A opens a Repeatable Read transaction
- Session A reads 1000.00 at Repeatable Read
- Session B commits the same update
- Session A reads again — still 1000.00
- Back to the default after COMMIT
- Reset for the Serializable demo
- Session A opens a Serializable transaction and reads
- Session A reads the total balance
- Session B also runs at Serializable and writes
- Session B committed successfully
- Session A tries to write and commit — but Postgres says no
- Reset for the lost-update demo
- The read-modify-write race condition
- Session A reads stock and holds the transaction open
- Session A sees stock = 50
- Session B reads the same 50, writes 49, and commits
- Session B committed — stock is now 49
- Session A writes its own 49 — one decrement is lost
- Reset before the fix
- Fix 1: never read first — keep the logic inside the UPDATE
- Atomic update returns the new stock
- Reset for the SELECT FOR UPDATE demo
- Fix 2: lock the row at read time with FOR UPDATE
- Session A holds the lock on row 1
- Session B tries to lock the same row — and blocks
- Session B is waiting
- Session A decrements and releases the lock
- Session B unblocks and sees the current value
- The stand is still here
- Choosing the right isolation level
- Atomic update versus SELECT FOR UPDATE