backend / databases / sql / 23_isolation_anomalies_across_dbs.md

Isolation Anomalies Across Databases — Postgres vs MySQL vs SQLite

7 interview angles 7 min read source

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 EXCLUSIVE or BEGIN 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:

  1. Use SERIALIZABLE (Postgres SSI handles it).
  2. Use SELECT … FOR UPDATE to escalate the read to a row lock — manual approach to forcing serialization on the rows you read.
  3. Use database constraints (unique constraints, exclusion constraints) — let the DB enforce invariants the application can’t reliably check.
  4. Use advisory locks for explicit cross-transaction coordination.

Practical recommendations

For Postgres:

  • Default READ COMMITTED is usually fine for typical OLTP.
  • Use SERIALIZABLE for specific transactions with multi-row invariants (booking, wallet, scheduling). Wrap them with retry logic.
  • Use SELECT FOR UPDATE for 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 READ provides stronger isolation than Postgres’ RC, but at the cost of more locking.
  • Watch for gap-lock deadlocks — they’re a frequent MySQL footgun.
  • READ COMMITTED is often used in MySQL OLTP for less locking and better concurrency, at the cost of phantom reads.
  • For write skew, SERIALIZABLE works but is heavy; explicit SELECT FOR UPDATE is 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 READ in 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.