Of ACID's four letters, three are non-negotiable and one has a volume knob: isolation. You can ask for the perfect illusion that you're the only user —and pay for it with less concurrency and transactions that abort— or relax it in exchange for performance, accepting that certain odd things happen. Those odd things have had names since 1992, there are four of them, and this lesson demonstrates them one by one in two sessions.
Then come the standard SQL's four levels with the classic table of which anomaly each one allows; and after that, what almost no course tells you: that table doesn't describe PostgreSQL. PostgreSQL doesn't implement READ UNCOMMITTED, and its REPEATABLE READ already prevents phantoms, which the standard doesn't require. We'll finish with the 40001 error, the module's most important one, and with its practical consequence: if you raise the level, your application has to know how to retry.
Every example is reproducible with two psql terminals on the freshly reloaded database, in 09-01's two-session format.
Contents
- Dirty read
- Non-repeatable read
- Phantom read
- Lost update and serialization anomaly
- The four levels of standard SQL
- What PostgreSQL really does
- Each engine's default level
- How the level is set and how it's checked
READ COMMITTEDagainstREPEATABLE READin two sessionsSERIALIZABLEand the40001error- The retry pattern with exponential backoff
- Which level to choose
- Common Mistakes and Tips
- Exercises
- Conclusion
- Dirty read
Dirty read: a transaction reads data another has written and not yet committed, which can disappear with a
ROLLBACK.
| Instant | Session A | Session B |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | UPDATE products SET price = 99.00 WHERE id = 15; → UPDATE 1 |
|
| t3 | SELECT price FROM products WHERE id = 15; |
|
| t4 | ROLLBACK; |
In an engine that allowed the dirty read, at t3 session B would see 99.00 — a price that has never existed for anybody, because at t4 it's undone. If B were the process generating the price-comparison feed, GreenStore would have published a matcha tea at €99.
In PostgreSQL, by contrast, B sees 22.00. Always. At any isolation level, including the one called READ UNCOMMITTED:
-- Session B, at t3
BEGIN ISOLATION LEVEL READ UNCOMMITTED;
SELECT id, name, price FROM products WHERE id = 15;
COMMIT;| id | name | price |
|---|---|---|
| 15 | Ceremonial matcha green tea 30 g | 22.00 |
And it isn't that PostgreSQL "makes an effort" to avoid it: with MVCC the dirty read is impossible by construction. The row's new version carries an xmin from an uncommitted transaction, and 09-02's visibility rule discards it without further ado. There's no code to remove and no level to lower.
Dialect note: the dirty read does exist, and it does get used. SQL Server implements
READ UNCOMMITTEDfor real, and its famousWITH (NOLOCK)hint is exactly that: reading without respecting locks or commits. It's put in reports so as not to block anybody, and in exchange you can see uncommitted rows, duplicated rows and missing rows in the same scan. MySQL/InnoDB implements it too. PostgreSQL, Oracle and SQLite don't.
- Non-repeatable read
Non-repeatable read: the same query, over the same row, returns different values within a single transaction, because another one modified it and committed in between.
The GreenStore case: the analyst is computing the margin report and, halfway through, the purchasing manager raises the matcha's price.
| Instant | Session A — analyst (READ COMMITTED) |
Session B — purchasing |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | SELECT price FROM products WHERE id = 15; → 22.00 |
|
| t3 | UPDATE products SET price = 24.00 WHERE id = 15; COMMIT; |
|
| t4 | SELECT price FROM products WHERE id = 15; → 24.00 |
|
| t5 | COMMIT; |
Two reads of the same row within the same transaction, two different values. If the report sums amounts at t2 and computes percentages at t4, the percentages won't match the amounts and nobody will know why.
The cause is exactly 09-02's: in READ COMMITTED, each statement takes a new snapshot. In REPEATABLE READ the snapshot is a single one for the whole transaction, and t4 would still return 22.00 — you'll see it in section 9.
- Phantom read
Phantom read: the same query returns a different set of rows, because another transaction inserted or deleted rows satisfying the condition.
The difference from the previous one is subtle but important: there a row's value changed; here which rows there are changes.
| Instant | Session A — monthly report (READ COMMITTED) |
Session B — a customer buying |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | SELECT COUNT(*) FROM orders; → 20 |
|
| t3 | INSERT INTO orders (...) VALUES (6, NULL, DATE '2026-03-05', 'paid', 'card', 4.95); COMMIT; |
|
| t4 | SELECT COUNT(*) FROM orders; → 21 |
|
| t5 | SELECT COUNT(*) FROM order_lines; → 47 |
|
| t6 | COMMIT; |
The report will say there are 21 orders and 47 lines, and somebody will spend the afternoon looking for the order with no lines. That order 21 is the phantom: it appeared halfway through the report.
The detail almost nobody tells you: the SQL standard allows phantoms in
REPEATABLE READ, but PostgreSQL doesn't allow them. ItsREPEATABLE READis implemented as snapshot isolation: a single snapshot for the whole transaction, and a snapshot doesn't change its contents. In PostgreSQL, t4 would return 20 — the same number as t2. It's a stronger guarantee than the standard's, and we'll come back to it in section 6.
- Lost update and serialization anomaly
This is the important one, the one that can cost GreenStore money, and the one 09-03 left pending.
Lost update: two transactions read the same value, each computes a new value from it and both write. The second write erases the first one's work.
The scenario: there are 40 units of matcha tea left and two customers buy one each at the same time. The application does the natural thing —read the stock, subtract, write—, which is precisely what 05-05 already flagged as incorrect:
| Instant | Session A — customer 1 | Session B — customer 2 |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | BEGIN; |
|
| t3 | SELECT stock FROM products WHERE id = 15; → 40 |
|
| t4 | SELECT stock FROM products WHERE id = 15; → 40 |
|
| t5 | UPDATE products SET stock = 39 WHERE id = 15; → UPDATE 1 |
|
| t6 | COMMIT; |
|
| t7 | UPDATE products SET stock = 39 WHERE id = 15; → UPDATE 1 |
|
| t8 | COMMIT; |
| id | name | stock |
|---|---|---|
| 15 | Ceremonial matcha green tea 30 g | 39 |
Two units have been sold and only one has been deducted. Neither session got an error, nothing was left in the log, and the discrepancy won't show up until the physical stocktake. With two sessions the loss is one unit; with a Christmas traffic peak, dozens.
And here's the surprise: the relative version is correct
Only the shape of the UPDATE changes:
| Instant | Session A | Session B |
|---|---|---|
| t5 | UPDATE products SET stock = stock - 1 WHERE id = 15; |
|
| t6 | COMMIT; |
|
| t7 | UPDATE products SET stock = stock - 1 WHERE id = 15; ← waits until t6 |
|
| t8 | COMMIT; |
| id | name | stock |
|---|---|---|
| 15 | Ceremonial matcha green tea 30 g | 38 |
Thirty-eight: correct. And it isn't magic. When B tries at t7 to update a row A has locked, B is left waiting (it's 09-05's implicit lock). On A committing, PostgreSQL doesn't apply B's UPDATE blindly: it rereads the updated row, re-evaluates the WHERE and recomputes the expression over the new version. So stock - 1 is computed over 39, not over 40.
The rule to extract, and it holds for the whole module: in
READ COMMITTED, a single write statement is safe; the danger lies in reading with one statement and writing with another.SET stock = stock - 1is safe;SELECTand thenSET stock = 39isn't. It's the same lesson as 05-05's upsert and 09-02'sWHERE stock >= 1.
When the logic doesn't fit in one statement —because you have to query three tables, apply a rule and decide— there are two correct solutions, and they're the two remaining lessons: raise the isolation level (the following sections) or explicitly lock the row with SELECT ... FOR UPDATE (09-05).
- The four levels of standard SQL
The SQL:1992 standard defined the levels by the anomalies they allow, not by how they're implemented:
| Level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
READ UNCOMMITTED |
Possible | Possible | Possible |
READ COMMITTED |
No | Possible | Possible |
REPEATABLE READ |
No | No | Possible |
SERIALIZABLE |
No | No | No |
This is the table that comes up in every job interview. And it's a minimum definition: it says what an engine may allow, not what it does. An engine that allows no anomaly at any level complies with the standard perfectly.
- What PostgreSQL really does
| Level | Dirty read | Non-repeatable | Phantom | Lost update | Serialization anomaly |
|---|---|---|---|---|---|
READ UNCOMMITTED |
Impossible | Possible | Possible | Possible | Possible |
READ COMMITTED (default) |
Impossible | Possible | Possible | Possible | Possible |
REPEATABLE READ |
Impossible | No | No | No (aborts with 40001) |
Possible |
SERIALIZABLE |
Impossible | No | No | No | No (aborts with 40001) |
Three differences from the standard, and all three matter:
READ UNCOMMITTEDdoesn't exist. The syntax is accepted for compatibility, but it behaves exactly likeREAD COMMITTED. The dirty read is impossible with MVCC (section 1).REPEATABLE READalready prevents phantoms. The standard allows them; PostgreSQL doesn't. Its implementation is snapshot isolation: a single snapshot for the whole transaction. There are only three distinguishable levels in practice.REPEATABLE READandSERIALIZABLEdon't block to achieve it: they abort. Instead of making you wait, they let you work and, if they detect a conflict at the end, they kill your transaction with error40001. That change of model is what forces you to retry, and it's section 11.
And a fourth that deserves its own line, because it's the reason the highest level exists:
The serialization anomaly is more general than the three classic ones: it's any result no sequential execution of the same transactions could have produced, even though each one read and wrote committed and different data.
SERIALIZABLEis the only level that prevents it, and it does so with SSI (Serializable Snapshot Isolation): it watches the read and write dependencies between transactions and aborts the ones forming a dangerous cycle.
- Each engine's default level
| Engine | Default | Essential notes |
|---|---|---|
| PostgreSQL | READ COMMITTED |
READ UNCOMMITTED = READ COMMITTED. REPEATABLE READ with no phantoms. SERIALIZABLE with SSI |
| MySQL / InnoDB | REPEATABLE READ |
It avoids phantoms in normal snapshot reads, and in locking reads with gap locks. But its writes read the latest committed version, not the snapshot's: it allows lost updates PostgreSQL would abort |
| SQL Server | READ COMMITTED |
With locks, not with snapshots: a reader can block a writer. With READ_COMMITTED_SNAPSHOT ON it switches to an MVCC-style model. It implements READ UNCOMMITTED (WITH (NOLOCK)) |
| Oracle | READ COMMITTED |
It doesn't implement REPEATABLE READ: it only has READ COMMITTED, SERIALIZABLE and READ ONLY. Its SERIALIZABLE is snapshot isolation, weaker than PostgreSQL's |
| SQLite | SERIALIZABLE de facto |
A single writer at a time across the whole database. Perfect isolation and zero write concurrency |
Dialect note — the migration trap. The level's name doesn't say the same thing in two engines. An application written against MySQL runs in
REPEATABLE READwithout knowing it and, when carried over to PostgreSQL, moves toREAD COMMITTED: non-repeatable reads appear that didn't happen there. And the other way round: code that works in PostgreSQL underREPEATABLE READbecause the engine aborts the conflicts, in MySQL doesn't abort and silently loses updates. Always verify the effective level when migrating; it's the first thing to look at.
- How the level is set and how it's checked
BEGIN ISOLATION LEVEL REPEATABLE READ; -- for this transaction
-- or, equivalently:
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SHOW transaction_isolation; -- what level am I on now| transaction_isolation |
|---|
| repeatable read |
And for the whole session or the whole server, 09-03's tools: SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL ..., ALTER ROLE ... SET default_transaction_isolation, or that same parameter in postgresql.conf.
READ COMMITTED against REPEATABLE READ in two sessions
READ COMMITTED against REPEATABLE READ in two sessionsThe whole difference is in when the snapshot is taken:
flowchart LR
subgraph RC["<b>READ COMMITTED</b>"]
direction TB
R1["statement 1 → <b>new snapshot</b>"] --> R2["statement 2 → <b>new snapshot</b>"] --> R3["statement 3 → <b>new snapshot</b>"]
end
subgraph RR["<b>REPEATABLE READ</b> / <b>SERIALIZABLE</b>"]
direction TB
S0["<b>a single snapshot</b><br/>taken at the 1st statement"] --> S1["statement 1"] & S2["statement 2"] & S3["statement 3"]
end
Same script, two levels, different results. Session A is the report; B is the shop's day-to-day operation.
A in READ COMMITTED (a new snapshot per statement):
| Instant | Session A — BEGIN; |
Session B |
|---|---|---|
| t1 | SELECT price FROM products WHERE id = 15; → 22.00 |
|
| t2 | SELECT COUNT(*) FROM orders; → 20 |
|
| t3 | UPDATE products SET price = 24.00 WHERE id = 15; |
|
| t4 | INSERT INTO orders (...); COMMIT; |
|
| t5 | SELECT price FROM products WHERE id = 15; → 24.00 ← non-repeatable |
|
| t6 | SELECT COUNT(*) FROM orders; → 21 ← phantom |
A in REPEATABLE READ (BEGIN ISOLATION LEVEL REPEATABLE READ;), with an identical script:
| Instant | Session A | Session B |
|---|---|---|
| t5 | SELECT price FROM products WHERE id = 15; → 22.00 |
|
| t6 | SELECT COUNT(*) FROM orders; → 20 |
The same values as at t1 and t2. For A, the world froze at the instant of its first query, and B's UPDATE and INSERT don't exist until A commits and starts another transaction. Neither non-repeatable read nor phantom: it's exactly what a report needs.
The price of that frozen snapshot appears the moment A tries to write something B has already changed:
| Instant | Session A (REPEATABLE READ) |
Session B |
|---|---|---|
| t1 | BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT stock FROM products WHERE id = 15; → 40 |
|
| t2 | UPDATE products SET stock = 39 WHERE id = 15; COMMIT; |
|
| t3 | UPDATE products SET stock = 39 WHERE id = 15; |
Section 4's lost update no longer happens: instead of silently trampling B's change, PostgreSQL kills A's transaction. That's the deal with the high levels, and it's an honest deal: you'd rather have an error you can retry than an invisible discrepancy.
SERIALIZABLE and the 40001 error
SERIALIZABLE and the 40001 errorREPEATABLE READ protects each row, but it doesn't protect relationships between rows. The typical case in GreenStore: the rule "the Drinks category's total stock can't drop below 250 units", checked by two warehouse operators at the same time.
| Instant | Session A (SERIALIZABLE) |
Session B (SERIALIZABLE) |
|---|---|---|
| t1 | BEGIN ISOLATION LEVEL SERIALIZABLE; |
BEGIN ISOLATION LEVEL SERIALIZABLE; |
| t2 | SELECT SUM(stock) FROM products WHERE category_id = 4; → 370 |
|
| t3 | SELECT SUM(stock) FROM products WHERE category_id = 4; → 370 |
|
| t4 | UPDATE products SET stock = stock - 60 WHERE id = 14; (370 − 60 = 310 ≥ 250 ✓) |
|
| t5 | UPDATE products SET stock = stock - 60 WHERE id = 16; (370 − 60 = 310 ≥ 250 ✓) |
|
| t6 | COMMIT; → COMMIT |
|
| t7 | COMMIT; |
ERROR: could not serialize access due to read/write dependencies among transactions DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt. HINT: The transaction might succeed if retried.
Both read 370, both subtracted 60, and the real total would have ended at 250… which still satisfies the rule. Change the 60s for 70s and the total drops to 230: both checks were correct and the combined result wasn't. No row was trampled —A touched product 14 and B product 16—, so REPEATABLE READ would have let both through. Only SERIALIZABLE sees that A read what B wrote and vice versa and aborts one of them.
Notice three things about the error, because they're the whole level's signature: the 40001 code, common to every serialization error; the explicit HINT that retrying may work —it isn't a data error, it's a temporal scheduling conflict—; and that the failure arrives at the COMMIT, when you've already done all the work.
REPEATABLE READ |
SERIALIZABLE |
|
|---|---|---|
| What it watches | That the same row isn't written by two | Additionally, the read and write dependencies between transactions |
| When it aborts | At the conflicting UPDATE/DELETE |
Normally at the COMMIT |
| Cost | Practically that of READ COMMITTED |
Predicate tracking; more memory and more aborts |
| When to choose it | Coherent reports, batch processes | Invariants spanning several rows or tables |
- The retry pattern with exponential backoff
If you use
REPEATABLE READorSERIALIZABLE, your application has to be prepared to retry. It isn't optional and it isn't a rare case: it's those levels' normal mode of operation.
MAX_ATTEMPTS = 5
wait = 0.05 s # 50 ms
for attempt in 1..MAX_ATTEMPTS:
connection.begin(isolation = SERIALIZABLE)
try:
... the COMPLETE unit of work ...
connection.commit()
exit successfully
except error with SQLSTATE == '40001' or '40P01': # serialization or deadlock
connection.rollback()
if attempt == MAX_ATTEMPTS:
rethrow # give up and report
sleep(wait * (1 + random(0, 0.5))) # ← "jitter": desynchronises
wait = wait * 2 # 50, 100, 200, 400 ms
except any_other_error:
connection.rollback()
rethrow # NOT retriedSix rules that make this loop actually work:
- The whole transaction is retried, from the
BEGIN. You can't carry on where you left off: its snapshot is no longer valid. - Only
40001and40P01(deadlock, 09-05) are retried. A foreign-key violation isn't fixed by repeating it: it'll fail the same way five times and five seconds will be lost. - The backoff is exponential and randomised. Without the random component (jitter), two colliding transactions retry at the same time and collide again, indefinitely.
- The transaction has to be idempotent, which is 05-03's and 09-03's property: if the first attempt got as far as inserting the order and then failed, the second can't duplicate it. A unique business key solves it.
- Nothing outside the database inside the block: retrying five times a transaction that charges a card means five charges (09-02). And count the retries: a rising rate of
40001is the signal that there's real contention and that the problem is one of design, not of configuration.
- Which level to choose
| Case | Level | Why |
|---|---|---|
| A standalone query, a catalogue listing, a product page | READ COMMITTED |
Each statement sees the latest committed data. It's what you want and it's the default |
| An analytical report of several queries that have to agree with each other | REPEATABLE READ, ideally with READ ONLY |
A frozen snapshot of the whole database. In PostgreSQL, with no phantoms. And SERIALIZABLE READ ONLY DEFERRABLE (09-03) if it must also never abort |
| Confirming an order while reducing stock | READ COMMITTED + UPDATE ... WHERE stock >= n, or SELECT ... FOR UPDATE (09-05) |
The solution isn't raising the level: it's doing the operation in one statement or locking the row. Simpler and faster |
A counter or a balance (SET total = total + n) |
READ COMMITTED |
The relative form is already safe (section 4). Raising the level only adds aborts |
| An invariant over several rows or tables ("the category's total doesn't drop below 250", "no more than N bookings") | SERIALIZABLE with retry |
It's the only case where the highest level is the right answer, because no single statement can express the rule |
| A nightly batch process over data nobody else touches | REPEATABLE READ |
Coherence for free: with no real concurrency there are no conflicts to abort |
The decision rule, in one sentence: always start at READ COMMITTED and go up only when you can name the specific anomaly that's hurting you. Raising the level isn't "safer" full stop: it's swapping a silent problem for an explicit error that has to be handled with code.
Common Mistakes and Tips
- Learning the standard's table and believing it describes your engine. It describes none of them exactly: PostgreSQL has no
READ UNCOMMITTEDand itsREPEATABLE READallows no phantoms. AndREAD COMMITTEDdoesn't protect against the lost update: it protects against the dirty read and nothing else. - Confusing
SELECT+UPDATEwith a relativeUPDATE.SET stock = stock - 1is safe even inREAD COMMITTED; reading 40 and writing 39 isn't. It's the lesson's most profitable distinction. - Going up to
SERIALIZABLEwithout writing the retry. You swap silent corruption for visible outages in production. The high level without a retry is worse than the low one. - Retrying any error, or retrying with no jitter. Only
40001and40P01deserve a retry; repeating a constraint violation is wasting time five times over. And without the random component, two synchronised transactions collide again on every round. - Assuming MySQL's
REPEATABLE READis PostgreSQL's. MySQL's allows lost updates PostgreSQL aborts; PostgreSQL's prevents phantoms MySQL's only avoids with gap locks. AndWITH (NOLOCK)in SQL Server isn't "going faster": it's a dirty read, with uncommitted, duplicated or missing rows in the same scan. - Tip: put
SHOW transaction_isolation;on your checklist when debugging concurrency; it's the first question. And for a report,BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;: total coherence and not a single lock on anybody. - Tip: measure the
40001s. Their rate tells you whether your design has real contention, and that information isn't anywhere else.
Exercises
With two psql terminals and the freshly reloaded database.
Exercise 1
Demonstrate the lost update and its two solutions.
- Reproduce section 4 with the
SELECT→UPDATE ... SET stock = 39pattern inREAD COMMITTED. Check that the final stock is 39 and explain which unit has been lost. - Repeat it with
UPDATE ... SET stock = stock - 1. Check that the result is 38 and describe exactly what session B does at t7. - Repeat case 1 with both sessions in
REPEATABLE READ. What happens and exactly when? Copy the error. - Which isolation level would you use in GreenStore's real application for confirming an order, and why is going up to
SERIALIZABLEnot the answer?
Exercise 2
The analyst Daniel Vercher (employee 8) runs the monthly report and complains that "the numbers don't agree between the tables in the PDF".
- Reproduce the problem: session A in
READ COMMITTEDcounts orders, another session inserts an order with two lines and commits, and A counts orders and lines again. Show the inconsistent numbers. - Identify which anomaly it is and why the standard calls it that.
- Fix it by changing a single line of A's script, and demonstrate that it now adds up.
- If the report takes 40 minutes, what side effect does keeping that transaction open for all that time have? Relate it to what you saw in 09-02 and propose 09-03's variant that mitigates it.
Exercise 3
GreenStore imposes a new rule: the Drinks category (4) can never drop below 250 units of total stock. Today it has 370.
- Write the transaction that reduces product 14 by 70 units, checking the rule first, in
SERIALIZABLE. - Run it at the same time in two sessions —one over product 14 (stock 180) and another over 17 (stock 90)— and show what happens at each
COMMIT. - What would have happened under
REPEATABLE READ? And underREAD COMMITTED? Reason out why neither of them detects the problem. - Write the retry pseudocode that would be needed for this operation to work in production, and say why it can't be solved with a
CHECK.
Solutions
Solution 1
1. Final stock 39. Session A's unit has been lost: its UPDATE at t5 did get written and committed, but B's overwrote it with an absolute value computed from an already stale read. A sold and didn't deduct.
2. Final stock 38. At t7, session B tries to update a row A has locked, so it's left waiting —the UPDATE returns nothing, the terminal hangs— until A commits at t6. Then PostgreSQL rereads the already updated row, re-evaluates the WHERE and recomputes stock - 1 over 39, not over 40. A single statement's atomicity does the job.
3. The session that arrives second fails at its UPDATE, not at the COMMIT:
The lost update is now impossible, but in exchange you have to retry. And notice the nuance: in REPEATABLE READ the conflict is detected on writing, whereas in SERIALIZABLE (exercise 3) it's usually detected on committing.
4. In the real application, READ COMMITTED plus one of these two: 09-02's UPDATE ... WHERE id = 15 AND stock >= 1, or 09-05's SELECT ... FOR UPDATE. Going up to SERIALIZABLE isn't the answer for three reasons: it would work, but it would force you to implement the retry loop on the shop's hottest path; it aborts under load exactly when there are the most orders, which is the worst possible thing; and it's unnecessary, because the invariant affects a single row and a single statement can express it. The high level is reserved for invariants no statement can express.
Solution 2
1. A gets 20 orders in its first query and, after the other session's INSERT, 21 orders and 49 lines. The PDF would say 21 orders with 49 lines if both counts were done afterwards, or 20 orders and 49 lines if they straddled the other session's COMMIT: either way, two figures taken from two different states of the world.
2. It's a phantom read: the same query returns a different set of rows because another transaction inserted rows satisfying the condition. It's called that because the new rows "appear" in a query that had already been run, as if they materialised out of nowhere.
3. The line that changes is the BEGIN:
With the snapshot fixed, A sees 20 orders and 47 lines in all its queries, whatever session B gets up to. In PostgreSQL this is enough because its REPEATABLE READ allows no phantoms; in an engine following the standard to the letter you'd need SERIALIZABLE.
4. Keeping a transaction with a fixed snapshot open for 40 minutes prevents VACUUM from cleaning any row version later than that instant, across the whole database: it's the bloat mechanism 09-02 explained and the damage 09-01 anticipated. 09-03's mitigation is to run the report as BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;: it still holds its snapshot, but being read-only and deferrable it blocks nobody and can't abort. And the underlying solution is the usual one: run long reports against a replica (09-02).
Solution 3
1. The transaction, for product 14:
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(stock) AS total_drinks FROM products WHERE category_id = 4; -- 370
-- The application checks: 370 - 70 = 300 >= 250 ✓
UPDATE products SET stock = stock - 70 WHERE id = 14;
COMMIT;2. The first to commit succeeds. The second fails at the COMMIT:
ERROR: could not serialize access due to read/write dependencies among transactions HINT: The transaction might succeed if retried.
Each one checked 370 − 70 = 300 and both were right separately; together they'd leave the total at 230, below the limit. SERIALIZABLE detects that A read a set of rows B modified and vice versa —a crossed read/write dependency— and cancels one of the two.
3. Neither REPEATABLE READ nor READ COMMITTED detects it, and for the same reason: there's no conflicting row. A writes product 14 and B product 17; they're different rows, so there's no lock, no update conflict and nothing to abort. REPEATABLE READ protects rows; the invariant here is over a set. Under READ COMMITTED it would be even worse, because each statement would additionally see different data. Both would commit happily and the total would end at 230.
4. The pseudocode is section 11's: a loop of up to five attempts, BEGIN ISOLATION LEVEL SERIALIZABLE, catching 40001, rollback(), a wait with exponential backoff and jitter, and rethrowing any other error without retrying.
And it can't be solved with a CHECK for the reason 09-02 explained: a CHECK can only look at the columns of the row itself, and this rule needs to sum the stock of four rows of the table. The real alternatives are three: SERIALIZABLE with a retry (this exercise's); explicitly locking the four rows with SELECT ... FOR UPDATE before checking (09-05); or keeping the aggregated total in a row of its own and protecting it with a CHECK and a trigger that maintains it (10-05). The first is the cleanest; the second, the most predictable under load.
Conclusion
You now know what can happen to you and how much avoiding it costs:
- The four anomalies, demonstrated in two sessions: dirty read (reading the uncommitted), non-repeatable read (the same row changes value), phantom read (new rows appear) and lost update / serialization anomaly (two individually correct transactions produce a result no sequential execution would give).
- That the lost update is the central case: two customers buy the last matcha, both read 40, both write 39, and a unit is sold that nobody deducts, with no error and no trace.
- And its most useful counterpart in the whole module: in
READ COMMITTED, a single write statement is safe —SET stock = stock - 1gives 38 because the engine rereads and recomputes— and the danger is in reading with one statement and writing with another. - The standard's four levels with their classic table… and what PostgreSQL really does: it doesn't implement
READ UNCOMMITTED(the dirty read is impossible with MVCC), itsREPEATABLE READalready prevents phantoms and there are only three distinguishable levels. - Each engine's default level —
READ COMMITTEDin PostgreSQL, Oracle and SQL Server;REPEATABLE READin InnoDB;SERIALIZABLEde facto in SQLite— and the migration trap: the same name doesn't mean the same thing. - The
40001error, in its two forms (concurrent updateinREPEATABLE READ,read/write dependenciesinSERIALIZABLE), with itsHINTsaying that retrying may work. And the consequence: going up a level forces you to write the retry loop, with exponential backoff, jitter, SQLSTATE filtering and an idempotent unit of work. - And the criterion:
READ COMMITTEDby default;REPEATABLE READ READ ONLYfor reports;SERIALIZABLEonly for invariants spanning several rows that no statement can express. Go up a level only when you can name the anomaly that's hurting you.
There remains the other answer to the lost update, the one that aborts nothing and the one e-commerce systems actually use: locking the row. In Handling Concurrency: Locks and Deadlocks you'll see why session B was left waiting at t7 and what was holding it; the safe read-modify-write pattern with SELECT ... FOR UPDATE and its whole family; SKIP LOCKED for sharing out a queue of orders across several processes without treading on each other; optimistic locking with a version column against pessimistic locking; what an ALTER TABLE locks and why CREATE INDEX CONCURRENTLY had to exist; and deadlocks: how they arise, how the engine detects and resolves them, how they're diagnosed with pg_locks and pg_blocking_pids, and the rules for never causing them.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
