Isolation Anomalies Across Databases — Postgres vs MySQL vs SQLite
The same isolation level name (REPEATABLE READ) means different things in different databases. Senior interviews probe whether you actually understand the anomalies, not just the names.
The anomalies (recap)
| Anomaly | What |
|---|---|
| Dirty read | read uncommitted data from another transaction |
| Non-repeatable read | same SELECT returns different rows after another committed update |
| Phantom read | same SELECT returns new rows after another committed insert |
| Write skew | both transactions read overlapping data, then write to different rows, leaving an aggregate constraint violated |
| Lost update | two transactions read then write the same value; one is silently overwritten |
ANSI SQL isolation levels
Dirty Non-repeat Phantom
READ UNCOMMITTED yes yes yes
READ COMMITTED no yes yes
REPEATABLE READ no no yes
SERIALIZABLE no no no
But implementations differ wildly from this spec.
Postgres
Default: READ COMMITTED.
| Postgres level | Actual behavior |
|---|---|
READ UNCOMMITTED |
same as READ COMMITTED (Postgres has no true uncommitted reads) |
READ COMMITTED |
no dirty reads; non-repeatable + phantoms possible |
REPEATABLE READ |
snapshot isolation: stable view of data for the whole transaction. Prevents non-repeatable AND phantom reads. But does NOT prevent write skew. |
SERIALIZABLE |
SSI (Serializable Snapshot Isolation): catches write skew via predicate dependency tracking; aborts transactions that would cause non-serializable schedules |
Notable: Postgres’ REPEATABLE READ is stronger than the ANSI definition. Snapshot isolation. But it still allows write skew.
Write skew example in Postgres
-- T1 -- T2
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors
WHERE on_call = true;
-- returns 2
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors
WHERE on_call = true;
-- also returns 2 (same snapshot)
UPDATE doctors SET on_call = false
WHERE id = 1;
COMMIT;
UPDATE doctors SET on_call = false
WHERE id = 2;
COMMIT;
-- Both commit; now 0 doctors on call.
-- Constraint violated.
Both transactions saw 2 doctors; each removed one. Each thinks “the other doctor is still there.” Now there are 0. SSI (SERIALIZABLE) detects this and aborts one transaction.
Postgres SERIALIZABLE
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- ... transaction work ...
COMMIT; -- may raise serialization_failure (SQLSTATE 40001)
You must handle serialization failures by retrying the whole transaction:
for attempt in range(MAX_RETRIES):
try:
with conn.transaction():
do_work()
break
except SerializationFailure:
if attempt == MAX_RETRIES - 1:
raise
time.sleep(backoff(attempt))
SERIALIZABLE on Postgres is “optimistic” — it commits things, then checks at commit time. Throughput cost depends on conflict rate.
MySQL (InnoDB)
Default: REPEATABLE READ. Different mechanism than Postgres.
| MySQL/InnoDB level | Actual behavior |
|---|---|
READ UNCOMMITTED |
dirty reads possible |
READ COMMITTED |
uses MVCC for reads; row-level locks on writes; non-repeatable + phantoms possible |
REPEATABLE READ (default) |
snapshot reads + gap locks on reads to prevent phantoms |
SERIALIZABLE |
converts plain SELECTs to SELECT … LOCK IN SHARE MODE |
MySQL’s REPEATABLE READ — gap locks
To prevent phantoms in REPEATABLE READ, MySQL takes gap locks during certain reads:
SELECT * FROM orders WHERE user_id BETWEEN 10 AND 20 FOR UPDATE;
MySQL locks the range [10, 20] plus the gaps. Concurrent inserts of new rows in that range block.
Consequence: locking conflicts in MySQL are far more common than in Postgres. Two transactions touching disjoint rows can deadlock via gap locks.
Postgres doesn’t take gap locks; SSI catches the equivalent issue at commit time instead.
Write skew in MySQL REPEATABLE READ
MySQL’s REPEATABLE READ does prevent phantoms (via gap locks), but write skew can still occur depending on which rows are read with locks. SERIALIZABLE in MySQL converts SELECT to read-locking, which prevents most write skew at heavy lock-conflict cost.
SQLite
Different model entirely. SQLite is single-writer, multi-reader.
| Mode | Behavior |
|---|---|
journal_mode = DELETE (default) |
rollback journal; readers block writers and vice versa |
journal_mode = WAL (recommended) |
write-ahead log; readers don’t block writers; one writer at a time |
In WAL mode:
- Readers see a consistent snapshot from when they began (SERIALIZABLE-like).
- Writers serialize via
BEGIN EXCLUSIVEorBEGIN IMMEDIATE. - No phantoms; no write skew between concurrent writers (only one writer).
SQLite is effectively SERIALIZABLE in WAL mode, because writers can’t be concurrent. Throughput is bounded but consistency is strong.
Oracle
Oracle has the same default as Postgres — READ COMMITTED — and is also MVCC-based. Its SERIALIZABLE is more permissive than Postgres’; it’s roughly snapshot isolation without the SSI checks. So Oracle’s SERIALIZABLE allows write skew, contrary to the ANSI name.
When migrating between Postgres and Oracle, audit isolation assumptions.
Comparative summary
| Postgres | MySQL/InnoDB | SQLite (WAL) | Oracle | |
|---|---|---|---|---|
| Default level | RC | RR | (effectively SERIALIZABLE) | RC |
| RC mechanism | MVCC | MVCC + row locks | n/a | MVCC |
| RR mechanism | snapshot iso | snapshot + gap locks | n/a | n/a |
| SERIALIZABLE mechanism | SSI (optimistic) | converts to share-locking | concurrent writer impossible | snapshot iso (no SSI) |
| Phantoms at RR? | no (snapshot) | no (gap locks) | n/a | n/a |
| Write skew at RR? | YES | depends | no | no |
| Write skew at SERIALIZABLE? | no (SSI catches) | rare | no | YES (Oracle is weak) |
| Deadlocks common? | rare | common (gap locks) | no concurrent writers | rare |
When write skew bites real systems
Classic scenarios:
- “Always at least one doctor on call” with concurrent goes-off-call updates.
- “Username uniqueness” via two separate SELECTs without unique constraint.
- “Wallet balance can’t go negative” with concurrent debits.
- “Schedule slot capacity” with concurrent bookings.
Fixes:
- Use SERIALIZABLE (Postgres SSI handles it).
- Use SELECT … FOR UPDATE to escalate the read to a row lock — manual approach to forcing serialization on the rows you read.
- Use database constraints (unique constraints, exclusion constraints) — let the DB enforce invariants the application can’t reliably check.
- Use advisory locks for explicit cross-transaction coordination.
Practical recommendations
For Postgres:
- Default
READ COMMITTEDis usually fine for typical OLTP. - Use
SERIALIZABLEfor specific transactions with multi-row invariants (booking, wallet, scheduling). Wrap them with retry logic. - Use
SELECT FOR UPDATEfor narrower locking when you only need to serialize on specific rows. - Use unique / exclusion constraints to enforce invariants the DB can check directly.
For MySQL:
- Default
REPEATABLE READprovides stronger isolation than Postgres’RC, but at the cost of more locking. - Watch for gap-lock deadlocks — they’re a frequent MySQL footgun.
READ COMMITTEDis often used in MySQL OLTP for less locking and better concurrency, at the cost of phantom reads.- For write skew,
SERIALIZABLEworks but is heavy; explicitSELECT FOR UPDATEis usually preferred.
For SQLite:
- Use WAL mode (
PRAGMA journal_mode=WAL). - Accept single-writer; design for low write contention.
- Effectively SERIALIZABLE; less to worry about beyond write capacity.
Interview angle
- “Default isolation levels for Postgres and MySQL?” — Postgres: READ COMMITTED. MySQL: REPEATABLE READ. Different mechanisms (Postgres MVCC; MySQL MVCC + gap locks) and different anomaly profiles.
- “What’s write skew?” — two transactions read overlapping data, each writes to different (but related) rows; both commit, violating an aggregate invariant. Famous example: doctors-on-call — both transactions see 2 doctors on call, each removes one, ending with 0.
- “Does Postgres REPEATABLE READ prevent write skew?” — no. It’s snapshot isolation; prevents phantoms but allows write skew. Need SERIALIZABLE (SSI) for write skew prevention.
- “What does Postgres’ SERIALIZABLE actually do?” — Serializable Snapshot Isolation (SSI). Tracks predicate dependencies between transactions; aborts at commit if the schedule isn’t serializable. Optimistic — better than pessimistic locking for low conflict rates but throws serialization_failure that you must retry.
- “MySQL gap locks?” — under REPEATABLE READ, MySQL takes locks on gaps in the index range it’s reading to prevent phantom inserts. Side effect: locking conflicts and deadlocks more common than in Postgres. Often the actual reason a MySQL workload feels “more deadlocky” than equivalent Postgres.
- “Where would SQLite NOT serialize?” — SQLite (WAL mode) only allows one writer at a time; concurrent writes serialize. Concurrent readers + one writer is fine. Effectively SERIALIZABLE; the trade-off is throughput, not anomalies.
- “Same isolation level name, different behaviors. Example?” —
REPEATABLE READin Postgres = snapshot isolation (no phantoms, write skew possible). In MySQL = snapshot + gap locks (no phantoms, fewer write skews, more deadlocks). In Oracle, the name is similar but with different semantics again. Always check the engine’s docs, not just the level name.