Learn · SQL · Core
SQL Transactions
A transaction groups several changes so they all succeed or all fail — never half. It’s the reason money doesn’t vanish between two accounts. This is your world.
Before we start
- What a transaction is: all-or-nothing groups of statements
- ACID in plain words
- COMMIT vs ROLLBACK
- The money race and how locking prevents it
- Explain why a bank transfer must be a transaction
- Say what ACID means with an example
- Describe how two transactions can corrupt a balance
Why you’re learning it: anything touching money or critical state needs transactions — directly relevant to your EMV/payments work and the RupeeRail ledger. A favourite deep-dive topic. ⏱️ ~30 min.
The idea — a bank transfer
Move ₹500 from A to B. That’s two steps: subtract from A, then add to B. If the system crashes between them, ₹500 vanished. A transaction wraps both into one indivisible unit — both, or neither. Watch it:
Start the transaction — nothing is saved yet.
A goes 1000 → 500 (held, not committed).
B goes 200 → 700 (held, not committed).
Both saved together. If anything failed → ROLLBACK, neither happened.
BEGIN; UPDATE accounts SET balance = balance - 500 WHERE id = 'A'; UPDATE accounts SET balance = balance + 500 WHERE id = 'B'; COMMIT; -- both saved together, or ROLLBACK undoes both
ACID — in plain words
| Letter | Means |
|---|---|
| Atomicity | All steps happen, or none (the transfer above) |
| Consistency | The DB moves from one valid state to another — rules never break |
| Isolation | Concurrent transactions don’t step on each other |
| Durability | Once committed, it survives a crash / power loss |
The money race — why isolation matters
Two withdrawals hit the same ₹1000 account at the same instant, with no locking:
- reads balance = 1000
- computes 1000 − 800 = 200
- writes 200
- reads balance = 1000 (stale!)
- computes 1000 − 800 = 200
- writes 200
Bug: two ₹800 withdrawals happened on a ₹1000 account, and the balance says ₹200. ₹600 vanished.
The fix — locking: SELECT ... FOR UPDATE makes Thread B wait until A commits, so B reads the fresh ₹200 and correctly rejects the second withdrawal. This exact race is a top payments interview question.
What COMMIT actually promises
When COMMIT returns to you, PostgreSQL has already written your changes to the write-ahead log (WAL) and forced that log to physical disk — before it answers you. The real table pages may get updated lazily, minutes later. If the machine loses power first, PostgreSQL replays the WAL on startup and rebuilds every committed change. That’s durability: COMMIT returning means your payment survives a power cut a millisecond later.
It’s also why COMMIT has real latency — it’s a disk flush. “The database is slow” sometimes just means “the disk under the WAL is slow.”
Autocommit: every plain statement you run without BEGIN is silently its own tiny transaction — begin, run, commit, in one breath. Typing BEGIN is you taking manual control of where the boundary sits.
The three concurrency anomalies — they have names
When isolation is weak, one transaction can see another’s half-done work. Each way this happens has a name, and interviewers love these:
| Anomaly | What happens | In PostgreSQL? |
|---|---|---|
| Dirty read | You read a value another transaction wrote but hasn’t committed — then it rolls back, and you acted on money that never existed | Never, at any level. MVCC only shows committed data |
| Non-repeatable read | You SELECT the same row twice in one transaction and get two different numbers, because someone committed in between | Yes — at READ COMMITTED (the default) |
| Phantom read | You run the same WHERE twice and new rows have appeared the second time | At READ COMMITTED; blocked from REPEATABLE READ up |
The dirty-read row is worth a pause: many databases historically allowed it at their lowest level. PostgreSQL’s design (every read comes from a committed snapshot) makes it flat-out impossible. That single fact is a strong interview signal.
Isolation levels — honestly
Atomicity, Consistency and Durability are mostly automatic. Isolation is the one you choose — it comes in levels, and each level has a price:
| Level | What it blocks | What it costs |
|---|---|---|
| READ UNCOMMITTED | An alias — PostgreSQL silently treats it as READ COMMITTED. Dirty reads still impossible | Nothing; it’s a synonym |
| READ COMMITTED (default) | Dirty reads. Each statement sees the latest committed data | Values can still change between your statements (non-repeatable reads, phantoms) |
| REPEATABLE READ | Plus non-repeatable reads and phantoms — one frozen snapshot for the whole transaction | Conflicting concurrent writes now fail with serialization_failure (code 40001) — your app must retry |
| SERIALIZABLE | Everything, including write skew — as if transactions ran one at a time | More 40001 failures; a retry loop is mandatory, and you pay throughput |
Two honest footnotes textbooks fudge. First: the SQL standard permits phantoms at REPEATABLE READ, but PostgreSQL blocks them anyway — it’s stronger than the standard here. Second: stronger levels don’t make bugs vanish, they turn silent wrong answers into loud serialization_failure errors your code must catch and retry. Correctness moves from “hope” to “error handling.”
Switching level is per-transaction:
BEGIN ISOLATION LEVEL REPEATABLE READ; -- ... your statements ... COMMIT;
The lock in full — FOR UPDATE
Back to the money race. Isolation levels alone can’t say “nobody else may even read-for-writing this row until I’m done.” Locks do. An UPDATE already takes a row lock — the problem is that your plain SELECT took no lock, so both transactions sailed past the check. The fix: make the read grab the same lock the write would.
BEGIN; SELECT balance FROM wallet WHERE id = 1 FOR UPDATE; -- row is now locked; anyone else's FOR UPDATE or UPDATE on it WAITS UPDATE wallet SET balance = balance - 400 WHERE id = 1; COMMIT; -- lock released
Now the second transaction’s SELECT ... FOR UPDATE just hangs — it waits for the first to commit. Then it reads the post-debit truth, its sufficiency check fails honestly, and it refuses the payout. One payout succeeds, one is refused, zero rupees invented. This is the standard tool for every read-then-write money flow.
Deadlocks — produced, read, fixed
Locks add one new failure mode. Transaction A locks row 1 and wants row 2; transaction B locked row 2 and wants row 1. Both would wait forever — except PostgreSQL detects the cycle within about a second, picks a victim, and kills it:
ERROR: deadlock detected
DETAIL: Process 5141 waits for ShareLock on transaction 770; blocked by process 5172.
Process 5172 waits for ShareLock on transaction 769; blocked by process 5141.
HINT: See server log for query details.Read it like a stack trace: two processes, each waiting on the other’s transaction — a cycle. The victim is rolled back (atomicity working as designed — just retry it); the survivor proceeds. The fix is not “fewer locks,” it’s lock order: if every transaction touching multiple rows locks them in the same order — say ascending id — a cycle is geometrically impossible. Learn this one sentence for life: deadlocks come from inconsistent lock order; the fix is a consistent one.
How this surfaces in Spring
Everything above is what @Transactional actually configures. The annotation opens BEGIN when the method starts, COMMIT when it returns, ROLLBACK on a runtime exception — the boundary you drew by hand, drawn by a proxy. Isolation is literally a property on it:
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void settleMonth(long groupId) { ... }And SELECT ... FOR UPDATE surfaces as @Lock(LockModeType.PESSIMISTIC_WRITE) on a repository method. When a Spring service does read-check-write on money, the code-review question is “what stops two threads passing the check together?” — and now you can answer it at the SQL layer, where the answer actually executes.
Interview-ready — say these from memory
- What does COMMIT actually put on disk, and what may not be there yet?
- Why is a dirty read impossible in PostgreSQL, even at its lowest level?
- Define a non-repeatable read and name the cheapest level that prevents it.
- Why doesn’t raising the isolation level alone fix the check-then-debit race the way
FOR UPDATEdoes? - What causes a deadlock, and what’s the one-sentence permanent fix?
- The WAL is flushed to disk (durability); the table pages may update later — WAL replay rebuilds them after a crash.
- MVCC: every read comes from a committed snapshot, so uncommitted writes are never visible.
- Same SELECT, two different values in one transaction. REPEATABLE READ prevents it (one snapshot).
- Isolation controls what reads see; it doesn’t make a plain SELECT hold the row.
FOR UPDATEmakes the read take the lock, serializing the check. - A cycle of transactions each holding a lock the other wants. Fix: acquire multi-row locks in one agreed order (e.g. ascending id).
Where you’ll use it — real life
Payments, refunds, ledger postings — all must be atomic. Your EMV world lives on this.
“Create order + reduce stock + charge card” commit together or roll back.
Row locking stops two requests double-charging one payment — the RupeeRail problem.
Durability means a committed payment survives the server dying a second later.
Now YOU do the reps
Out loud: “What does ACID mean, and walk me through why a bank transfer must be a transaction — and the race a lock prevents.” Then log it in your Journal.
Next: OS Fundamentals →