Transactions and ACID
Transactions make groups of statements all-or-nothing, and isolation levels decide what concurrent transactions can see. Watch lost updates, non-repeatable reads and deadlocks happen, and learn which level or lock prevents each.
🎯 What you'll learn
- What a transaction is and what ACID guarantees
- Isolation levels and the anomalies they allow
- Pessimistic locking (SELECT … FOR UPDATE) alongside MVCC
- Deadlocks and savepoints
📚 Before you start
1What a Transaction Actually Is
A transaction is a group of one or more statements that the database executes as a single, indivisible unit: either every statement's effects become permanent, or none of them do. It's the mechanism that lets you say "move $100 from account A to account B" as one operation instead of two independent writes that a crash could catch halfway through.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';
COMMIT;If the process crashes after the first UPDATE but before COMMIT, the database guarantees that on restart, neither update happened — money is not destroyed. If both statements succeed and COMMIT returns, both changes are permanent even if the server loses power one millisecond later.
2ACID, One Letter at a Time
A — Atomicity. All statements in the transaction succeed, or all are rolled back. There is no partial state visible to anyone, ever — not even for a moment.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE id = 'ZZZ'; -- 'ZZZ' doesn't exist, 0 rows affected, no error by itself
-- an application bug might COMMIT here anyway -- atomicity doesn't save you from a bug that fails to notice 0 rows changed
ROLLBACK; -- correct handling: check rowcount, or add a CHECK/trigger, and abortAtomicity protects against crashes and explicit aborts. It does not protect against application logic that commits nonsense — see Common Pitfalls.
C — Consistency. Every transaction moves the database from one valid state to another valid state, where "valid" means every constraint (CHECK, NOT NULL, FOREIGN KEY, UNIQUE) holds. This is largely a restatement of "constraints are enforced," and it's the one ACID letter that's more a consequence of the other three than a mechanism of its own.
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
balance INTEGER NOT NULL CHECK (balance >= 0)
);
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 'A'; -- if this makes balance negative...
COMMIT; -- ...the CHECK constraint fails here, the whole transaction rolls back automaticallyI — Isolation. Concurrent transactions should not see each other's uncommitted or partial work. This is the deepest and most configurable of the four — covered fully in Section 3.
D — Durability. Once a transaction commits, its effects survive a crash, power loss, or restart — because the change was written to durable storage (the write-ahead log) before COMMIT returned, not just cached in memory.
3Isolation Levels and the Anomalies They Prevent
Running transactions concurrently is where correctness gets hard. The SQL standard defines four isolation levels, each permitting fewer anomalies than the last, at the cost of more blocking or more rollback-and-retry:
| Anomaly | Definition |
|---|---|
| Dirty read | Transaction A reads a row that transaction B has modified but not yet committed. If B rolls back, A read data that never officially existed. |
| Non-repeatable read | Transaction A reads the same row twice, and gets different values, because B committed an UPDATE to that row in between A's two reads. |
| Phantom read | Transaction A runs the same range query twice, and the second run returns rows the first didn't, because B committed an INSERT (or DELETE) matching the range in between. |
| Isolation Level | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | Possible | Possible | Possible |
| Read Committed | Prevented | Possible | Possible |
| Repeatable Read | Prevented | Prevented | Possible (standard) / Prevented (Postgres) |
| Serializable | Prevented | Prevented | Prevented |
-- Transaction A -- Transaction B
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id='A'; -- reads 500
BEGIN;
UPDATE accounts SET balance = 300 WHERE id='A';
COMMIT;
SELECT balance FROM accounts WHERE id='A'; -- reads 300 -- non-repeatable read
COMMIT;Under REPEATABLE READ, A's second SELECT in that example would still return 500 — A's snapshot is frozen at the start of its transaction (see Section 4 for the mechanism). Most applications default to READ COMMITTED (Postgres's and Oracle's default) because it's fast and "good enough" for most logic; reach for REPEATABLE READ or SERIALIZABLE specifically when a piece of business logic reads-then-writes based on data that must not shift underneath it (see Section 5's pitfall).
4Locking: Pessimistic Control Alongside MVCC
Isolation levels describe what you can see. Locks describe what you can do concurrently while writing.
-- Row-level lock: blocks any other transaction from UPDATE/DELETE-ing
-- this specific row (or acquiring the same lock on it) until COMMIT.
BEGIN;
SELECT * FROM accounts WHERE id = 'A' FOR UPDATE;
-- ... application logic decides the new balance based on the current one ...
UPDATE accounts SET balance = 450 WHERE id = 'A';
COMMIT;SELECT ... FOR UPDATE is the standard tool for "read a value, then write a new value derived from it" without another transaction sneaking in a conflicting write between your read and your write — the classic lost update anomaly. Without it, two transactions can both read balance = 500, both independently decide to subtract 100, and both write 400 — one of the two withdrawals is silently lost.
5Deadlocks
A deadlock happens when transaction A holds a lock B wants, while B holds a lock A wants — neither can proceed.
-- Transaction A -- Transaction B
BEGIN; BEGIN;
UPDATE accounts SET ... WHERE id='X';
UPDATE accounts SET ... WHERE id='Y';
UPDATE accounts SET ... WHERE id='Y'; -- blocks, waiting on B
UPDATE accounts SET ... WHERE id='X'; -- blocks, waiting on A -> deadlockThe database detects the cycle (Postgres checks periodically, roughly every second by default) and kills one transaction with an error, letting the other proceed. The surviving application code must catch that error and retry. The fix at the application level is almost always lock ordering: always acquire locks on rows in the same, consistent order (e.g., always by ascending id) across every code path, so a cycle can never form.
6Transaction Control: SAVEPOINT
BEGIN;
INSERT INTO orders (id, status) VALUES (1, 'pending');
SAVEPOINT before_risky_step;
UPDATE inventory SET stock = stock - 1000 WHERE product_id = 42; -- CHECK constraint fails: stock can't go negative
ROLLBACK TO SAVEPOINT before_risky_step; -- undoes only the UPDATE, keeps the INSERT
UPDATE inventory SET stock = stock - 1 WHERE product_id = 42; -- retry with a sane quantity
COMMIT;A SAVEPOINT is a named point inside a transaction you can roll back to without abandoning the whole transaction — useful for "try this, and if it fails, fall back to a safer path" without losing everything already accumulated in the transaction.
Under the HoodHow it works internally▾
MVCC: giving every transaction its own consistent snapshot
Postgres (and most modern relational engines) implement isolation using Multi-Version Concurrency Control (MVCC) instead of locking every row a transaction merely reads. Every row version carries two hidden system columns: xmin (the id of the transaction that created this version) and xmax (the id of the transaction that deleted/superseded it, or empty if it's still current).
An UPDATE never overwrites a row in place — it writes a new row version with a new xmin, and sets the old version's xmax to the updating transaction's id. The old version isn't physically removed yet; it just becomes invisible to new transactions once nobody's snapshot can see it anymore.
UPDATE accounts SET balance = 400 WHERE id = 'A';
-- old row: (id='A', balance=500, xmin=100, xmax=105) <- now "dead" but still on disk
-- new row: (id='A', balance=400, xmin=105, xmax=null) <- current versionEach transaction, when it starts (or at each statement, depending on isolation level), takes a snapshot: a record of which transaction ids were already committed. A row version is visible to a transaction if its xmin was committed before the snapshot was taken and its xmax was not. This is the entire mechanism behind "readers never block writers and writers never block readers" in Postgres — a reader simply ignores row versions that shouldn't exist in its snapshot, without ever needing to acquire a lock against the writer.
This is also exactly why REPEATABLE READ can prevent non-repeatable reads without any extra locking: the transaction just keeps using its original snapshot for every subsequent statement, so a concurrent commit is invisible to it until it commits and starts a new transaction.
Why old row versions need VACUUM
Because UPDATE/DELETE leave dead row versions behind instead of erasing them, a table under heavy write load accumulates bloat — dead tuples nobody's snapshot can see anymore but that still occupy disk pages. Postgres's VACUUM process (usually running automatically as autovacuum) scans for tuples no longer visible to any active snapshot and reclaims that space for reuse. A long-running transaction (even a read-only one left open by mistake) prevents vacuum from reclaiming anything newer than its snapshot, which is the single most common cause of catastrophic table bloat in production Postgres.
Serializable isolation: detecting conflicts instead of preventing them upfront
SERIALIZABLE in Postgres is implemented as Serializable Snapshot Isolation (SSI): transactions run exactly like REPEATABLE READ (their own MVCC snapshot, no extra blocking) but the engine additionally tracks read/write dependencies between concurrent transactions. If it detects a pattern that could not have arisen from any serial (one-at-a-time) execution order, it aborts one of the transactions with a serialization failure at commit time, and the application is expected to retry. This is why serializable transactions must always be wrapped in retry logic — an abort under SERIALIZABLE is an expected, not exceptional, outcome under contention.
Durability: the write-ahead log
Before any data page is modified on disk, the change is first appended to the write-ahead log (WAL) — a sequential, append-only file. COMMIT does not return to the client until the WAL record for that transaction has been flushed (fsync) to durable storage. If the server crashes immediately after, redo from the WAL replays every committed-but-not-yet-applied change on restart, which is how a committed transaction survives a crash even though its actual data pages might not have been written to disk yet. This same log is what enables replication (streaming WAL to replicas) and point-in-time recovery.
⚠️ Common Pitfalls
- Assuming atomicity means "logically correct" — atomicity guarantees all-or-nothing, not that the logic itself is right. A transaction that commits an
UPDATEaffecting 0 rows because of a typo'dWHEREclause is still perfectly atomic and perfectly wrong. - Read-modify-write without
FOR UPDATEor an atomic expression — reading a balance, computing a new value in application code, then writing it back is a classic lost-update race. PreferUPDATE accounts SET balance = balance - 100 WHERE id='A'(atomic, single statement) over separate read-then-write, or lock the row explicitly withFOR UPDATEwhen the logic can't be a single statement. - Leaving a transaction open ("idle in transaction") — an open transaction, even one doing nothing, pins an MVCC snapshot, which blocks
VACUUMfrom cleaning up dead tuples the whole table over, causing table bloat. Always commit or roll back promptly; setidle_in_transaction_session_timeoutas a safety net. - Choosing
SERIALIZABLEwithout retry logic — a serialization failure at commit time is a normal, expected error under this isolation level, not a bug. Code that doesn't catch and retry40001will simply lose transactions under load. - Assuming
REPEATABLE READprevents lost updates on its own — it prevents your reads from changing mid-transaction, but a naive read-then-write can still race with anotherREPEATABLE READtransaction; Postgres detects the conflict and aborts one of them at commit time (serialization failure), it does not silently merge the writes. - Inconsistent lock ordering across code paths — if one code path updates account X then Y, and another updates Y then X, a deadlock is a matter of when, not if, under concurrent load. Always acquire locks (including implicit ones from
UPDATE/FOR UPDATE) in the same global order. - Treating
BEGIN/COMMITas free — every transaction, even a trivial one, costs a WAL flush on commit. Batching many small logical writes into one transaction is usually faster than committing each individually, up to the point where the transaction gets so large it holds locks too long.
📋 Quick Reference
ACID:
Atomicity -> all statements commit, or none do
Consistency -> constraints hold before and after every commit
Isolation -> concurrent transactions don't see each other's half-finished work
Durability -> a committed transaction survives a crash (via WAL)
Anomalies prevented per isolation level:
Read Uncommitted -> none
Read Committed -> dirty reads
Repeatable Read -> dirty reads + non-repeatable reads (+ phantoms, in Postgres)
Serializable -> all of the above, via conflict detection + abort-and-retry
Mechanism:
MVCC -> each transaction gets a snapshot; readers never block writers
FOR UPDATE -> pessimistic row lock, prevents lost updates on read-modify-write
WAL -> durability; commit waits for the log to be flushed, not the data pages
VACUUM -> reclaims dead row versions once no snapshot can see them anymore
Deadlock: cycle of transactions waiting on each other's locks -> engine kills one -> app must retry.
Fix: consistent lock ordering across every code path.🧠 Key takeaways
- A transaction is all-or-nothing; ACID = atomicity, consistency (constraints), isolation, durability (WAL).
- Isolation levels trade anomalies for concurrency: read committed (default in Postgres), repeatable read, serializable.
- Prevent lost updates with relative updates, SELECT … FOR UPDATE, or optimistic version checks.
- Deadlocks happen: lock in a consistent order, keep transactions short, and retry aborted ones.
🎤 Interview Q&A
Q1What does ACID stand for?core▾
Atomicity: all or nothing. Consistency: transactions move the database between valid states (constraints hold). Isolation: concurrent transactions don't interfere (to a degree set by the isolation level). Durability: committed data survives crashes (write-ahead log).
- How is durability implemented? The WAL is flushed to disk before commit returns; data pages are written later.
Q2Explain the isolation levels and the anomalies they prevent.core▾
Read uncommitted: dirty reads possible. Read committed: no dirty reads, but non-repeatable reads. Repeatable read: same row reads are stable, phantoms possible (in the SQL standard). Serializable: as if transactions ran one at a time. Postgres defaults to read committed; MySQL InnoDB to repeatable read.
- What's a phantom read? Re-running a range query returns new rows inserted by another transaction.
Q3What is a lost update and how do you prevent it?core▾
Two transactions read the same value, modify it and write back; one overwrites the other. Prevent with atomic updates (SET stock = stock - 1), SELECT … FOR UPDATE (pessimistic locking), optimistic locking with a version column, or serializable isolation.
- Optimistic vs pessimistic? Optimistic suits low contention (retry on conflict); pessimistic suits high contention.
Q4What is MVCC?senior▾
Multi-Version Concurrency Control keeps multiple versions of rows so readers see a consistent snapshot without blocking writers, and writers don't block readers. Old versions are cleaned up later (VACUUM in Postgres, purge in InnoDB).
- What's write skew? Two transactions read overlapping data and write different rows, together violating a constraint; snapshot isolation allows it, serializable prevents it.
Q5Why should transactions be short?core▾
Long transactions hold locks (blocking others), keep old row versions alive (bloat in MVCC databases), increase deadlock chances and replication lag. Never wait for user input or call slow external APIs inside a transaction.
- How do you handle deadlocks? The DB aborts one transaction; the app retries it. Reduce them by locking rows in a consistent order.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.