Anomalies and Isolation Levels
0% read · 7 min left
Anomalies and Isolation Levels
JrCodex7 min readChapter 30 of 43
Jr Codex DBMS Notes
Level: Advanced Prerequisites: Chapter 1: Transactions in Depth Time to complete: ~25 minutes
Table of Contents
- The Four Classic Anomalies
- The Four Isolation Levels
- Write Skew
- What Your Database Actually Does
- Choosing a Level
- Summary & Next Steps
1. The Four Classic Anomalies
DIRTY READ
─────────────────────────────────────────
Reading data another transaction has written but
NOT committed.
T1: UPDATE accounts SET bal = 500 WHERE id='A'
T2: SELECT bal FROM accounts WHERE id='A'
──► reads 500
T1: ROLLBACK
──► 500 never existed. T2 acted on a
phantom value.
NON-REPEATABLE READ
─────────────────────────────────────────
Reading the same ROW twice in one transaction
and getting different values.
T1: SELECT bal WHERE id='A' ──► 300
T2: UPDATE accounts SET bal=500 WHERE id='A'
COMMIT
T1: SELECT bal WHERE id='A' ──► 500
──► T1's two reads disagree
PHANTOM READ
─────────────────────────────────────────
Re-running the same QUERY and getting different
ROWS, because another transaction inserted or
deleted.
T1: SELECT COUNT(*) WHERE city='Pune' ──► 3
T2: INSERT a Pune student; COMMIT
T1: SELECT COUNT(*) WHERE city='Pune' ──► 4
Differs from non-repeatable read: no existing row
changed. A NEW row appeared, so row-level locks
cannot prevent it.
LOST UPDATE
─────────────────────────────────────────
Two transactions read the same value, both
compute from it, and the second overwrites the
first.
T1: read bal=100
T2: read bal=100
T1: write bal=100-60=40
T2: write bal=100-50=50 ← T1's deduction gone
─────────────────────────────────────────
2. The Four Isolation Levels
The SQL Standard Table
─────────────────────────────────────────
LEVEL DIRTY NON-REP PHANTOM
─────────────────────────────────────────
READ UNCOMMITTED yes yes yes
READ COMMITTED no yes yes
REPEATABLE READ no no yes
SERIALIZABLE no no no
"yes" = the anomaly is PERMITTED at that level.
─────────────────────────────────────────
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
-- ...
COMMIT;
-- Or per session:
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;Level by Level
─────────────────────────────────────────
READ UNCOMMITTED
Almost never used. PostgreSQL treats it as READ
COMMITTED — it will not give you dirty reads
even if you ask.
READ COMMITTED
Each STATEMENT sees a fresh snapshot of
committed data. Two statements in the same
transaction may see different data.
── the most common default
REPEATABLE READ
The whole TRANSACTION sees one snapshot, taken
at its first read. Rows you read will not
change under you.
SERIALIZABLE
Full correctness (Chapter 1). Transactions may
be ABORTED to preserve it, so your code must
handle retries.
─────────────────────────────────────────
3. Write Skew
The anomaly the standard's table does not mention, and the one that catches experienced developers.
The Scenario
─────────────────────────────────────────
RULE: at least one doctor must always be on call.
Currently Alice and Bob are both on call.
Both try to go off call, simultaneously, under
REPEATABLE READ:
T1: SELECT COUNT(*) WHERE on_call ──► 2. Fine.
T2: SELECT COUNT(*) WHERE on_call ──► 2. Fine.
T1: UPDATE doctors SET on_call=false
WHERE name='Alice'
T2: UPDATE doctors SET on_call=false
WHERE name='Bob'
Both COMMIT.
Now ZERO doctors are on call. The invariant is
broken, and NO anomaly from Section 1 occurred.
─────────────────────────────────────────
Why It Slips Through
─────────────────────────────────────────
Each transaction read data the OTHER then
modified — but they wrote to DIFFERENT rows.
No dirty read. No non-repeatable read. No
phantom. No lost update. Every classic anomaly
check passes.
This is why REPEATABLE READ is not "almost
serialisable". It is meaningfully weaker, and the
gap is exactly this shape: a constraint spanning
multiple rows.
─────────────────────────────────────────
-- FIX 1: SERIALIZABLE. The database detects the conflict and aborts one.
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- FIX 2: take an explicit lock so the reads conflict.
SELECT COUNT(*) FROM doctors WHERE on_call FOR UPDATE;
-- FIX 3: make the invariant a constraint the database can enforce alone.
-- e.g. a summary row with a CHECK, updated by both transactions.The General Lesson
─────────────────────────────────────────
Write skew happens whenever a decision is based
on reading rows OTHER than the ones you write.
Check-then-act across rows:
"if no overlapping booking exists, insert one"
"if the balance stays positive, withdraw"
"if this username is free, claim it"
All of these are write-skew-shaped, and all of
them are wrong under REPEATABLE READ.
─────────────────────────────────────────
4. What Your Database Actually Does
The standard describes minimums. Real implementations differ, and the differences matter.
POSTGRESQL
─────────────────────────────────────────
READ COMMITTED default. MVCC snapshot per
STATEMENT.
REPEATABLE READ a true snapshot for the whole
transaction, so it also prevents
PHANTOMS — stronger than the
standard requires. Still allows
write skew.
SERIALIZABLE Serializable Snapshot Isolation
(SSI). Detects dangerous
patterns and aborts one
transaction with a
serialization_failure. YOU MUST
RETRY.
MYSQL / INNODB
─────────────────────────────────────────
REPEATABLE READ default. Uses NEXT-KEY LOCKING
(Chapter 3) which locks gaps
between index entries, so it
also prevents most phantoms.
Note: plain SELECT is a non-locking snapshot
read; SELECT ... FOR UPDATE is a locking read
with different visibility. Mixing them surprises
people.
ORACLE
─────────────────────────────────────────
READ COMMITTED default.
"SERIALIZABLE" is actually snapshot isolation —
it permits WRITE SKEW despite
the name. There is no true
serialisable level.
─────────────────────────────────────────
The Practical Warning
─────────────────────────────────────────
Do not port concurrency assumptions between
databases.
Code correct under MySQL's REPEATABLE READ can be
wrong under PostgreSQL's, and code correct under
Oracle's "SERIALIZABLE" can be wrong under
PostgreSQL's actual serialisable.
Read your database's documentation for the level
you are on. This is one of very few places where
"it works the same everywhere" is badly false.
─────────────────────────────────────────
5. Choosing a Level
Decision Guide
─────────────────────────────────────────
Reads that tolerate slight staleness —
dashboards, listings, search results
──► READ COMMITTED. The default, and fine.
A report that must be internally consistent
across many queries
──► REPEATABLE READ. One snapshot, so totals
agree with details.
Money, inventory, booking, or any multi-row
invariant
──► SERIALIZABLE, and write the retry loop.
A single-row check-then-act
──► SELECT ... FOR UPDATE at READ COMMITTED
is usually enough and cheaper.
─────────────────────────────────────────
import psycopg
from psycopg.errors import SerializationFailure
import time, random
def run_serialisable(conn_factory, work, max_attempts=5):
"""SERIALIZABLE transactions CAN be aborted. Retrying is mandatory, not optional."""
for attempt in range(max_attempts):
try:
with conn_factory() as conn:
conn.isolation_level = psycopg.IsolationLevel.SERIALIZABLE
with conn.transaction():
return work(conn)
except SerializationFailure:
if attempt == max_attempts - 1:
raise
time.sleep((2 ** attempt) * 0.01 + random.random() * 0.01) # backoff + jitterThe Rule That Comes With SERIALIZABLE
─────────────────────────────────────────
If you set SERIALIZABLE and do not write a retry
loop, you have not made your code correct. You
have made it fail differently.
Serialisation failures are NORMAL operation under
contention, not exceptional errors.
─────────────────────────────────────────
6. Summary & Next Steps
Key Takeaways
- The four classic anomalies are dirty read, non-repeatable read, phantom read and lost update, and each isolation level permits a specific subset.
- Write skew is not in the standard's table: it occurs when transactions read rows other than the ones they write, and it defeats REPEATABLE READ entirely.
- Real databases deviate from the standard in both directions — PostgreSQL's REPEATABLE READ is stronger than required, Oracle's SERIALIZABLE is weaker than its name.
- SERIALIZABLE aborts transactions to preserve correctness, so adopting it without a retry loop replaces one bug with another.
Concept Check
- What distinguishes a phantom read from a non-repeatable read, and why does that distinction matter for locking?
- Explain why the on-call doctors example breaks under REPEATABLE READ despite no classic anomaly occurring.
- Why is "we set SERIALIZABLE, so we are safe" incomplete as a statement?
Next Chapter
→ Chapter 3: Locking and Two-Phase Locking
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to DBMS Index