bigonotes
System DesignDatabases

Isolation Levels

The read anomalies concurrent transactions cause, the four SQL isolation levels that prevent them, and how serializable is implemented.

9 min readLast updated DatabasesListen · Watch on NotebookLM

Isolation is the "I" in ACID. When many transactions touch the same data at the same time, isolation decides how much they can see of each other's work. Too little and you get wrong balances and double bookings. Too much and everything waits in line. Databases give you a dial, the isolation level, and choosing it well is a real design decision.

The core problem#

One bank account holds $1000. Two ATMs process withdrawals at the same moment:

ATM 1 ATM 2
Reads balance: $1000
Reads balance: $1000
Writes $1000 − $500 = $500
Writes $1000 − $300 = $700

The final balance is $700. It should be $200. ATM 1's withdrawal vanished.

This is the same race condition you get with threads and shared memory, except here the fix is transactions and isolation levels instead of mutexes.

Isolation is a spectrum:

Low isolation High isolation
Fast, more anomalies Slower, behaves as if transactions ran one at a time

Before looking at the levels, you need the three classic anomalies they prevent.

Anomaly 1: dirty read#

A transaction reads data another transaction hasn't committed yet. If that other transaction rolls back, you acted on a value that never really existed.

T1 T2
BEGIN
UPDATE balance = $500 (not committed)
BEGIN
Reads balance: $500 (uncommitted!)
ROLLBACK (balance back to $1000)
Makes decisions using $500, a value that never existed

Analogy: a friend says they're quitting their job tomorrow, you tell everyone, and then they change their mind. You acted on something that was never final.

Why it's dangerous:

  • Shop: T1 reserves an item (stock 10 → 9). T2 reads 9 and shows "9 left". T1 rolls back. Stock is really 10.
  • Bank: T1 deposits $10,000 (balance $100 → $10,100). T2 reads $10,100 and approves a loan. T1 rolls back. The loan was approved on money that never arrived.

Anomaly 2: non-repeatable read#

You read the same row twice in one transaction and get different values, because another transaction committed a change in between.

T1 T2
BEGIN
Reads balance: $1000
UPDATE balance = $500; COMMIT ✓
Reads balance: $500

Same query, same transaction, different answer.

The difference from a dirty read: both values were committed and real. The value just changed under you mid-transaction.

Analogy: you check your balance ($1000) and start planning bills. Your roommate legitimately moves money out. You check again: $500. Both readings were true at the time, but they disagree within your one session.

Why it's dangerous:

  • Reports: a monthly report reads total revenue as $50,000 at the top. A refund commits. The bottom of the report reads $49,500. The report contradicts itself.
  • Booking: you read seat 5A as free and tell the user. Someone else books it and commits. You read again and it's taken.
Dirty read Non-repeatable read
Data was committed? No, might be rolled back Yes, both values were committed
Was the value real? No Yes, it just changed
Danger Acting on fake data Inconsistent view within one transaction

Anomaly 3: phantom read#

A non-repeatable read is about an existing row changing. A phantom read is about rows appearing or disappearing from the result of a range query.

T1 T2
SELECT * FROM orders WHERE status = 'pending' returns 3 rows
Inserts a new 'pending' order and commits ✓
The same SELECT returns 4 rows

The original 3 rows didn't change. A new "phantom" row appeared.

Analogy: you count 10 people in a meeting room and start preparing 10 lunch boxes. Someone walks in. Nobody changed seats (that would be a non-repeatable read); a new person appeared.

Why it's dangerous:

  • Payroll: you count 50 engineers and start summing salaries. Someone transfers into Engineering and commits. A second query sees 51. Your totals don't match.
  • Inventory: you count 12 out-of-stock products. Another transaction sells the last unit of something. Your next aggregate query includes a 13th.

The three anomalies side by side#

Anomaly What changed Scope Example
Dirty read You saw uncommitted data One row Balance shows $500 that was rolled back
Non-repeatable read A row's committed value changed between reads One row Balance went from $1000 to $500
Phantom read The set of rows matching a query changed Many rows Pending orders went from 3 to 4

The four isolation levels#

The SQL standard defines four levels by which anomalies they allow.

Read uncommitted (lowest)#

You can see other transactions' uncommitted changes, so dirty reads are possible.

  • Allows: dirty, non-repeatable and phantom reads.
  • Use: almost never. At most for rough analytics ("about how many orders are pending?"), where speed beats accuracy. PostgreSQL doesn't even implement it separately; asking for it gives you Read Committed.

Read committed (the practical default)#

You only ever see committed data. Each statement sees the latest committed data as of when that statement starts, so two reads in one transaction can still differ.

T1 T2
UPDATE balance = $500 (not committed)
Reads $1000: the last committed value ✓
COMMIT
Reads $500: a non-repeatable read is still possible
  • Blocks: dirty reads.
  • Allows: non-repeatable and phantom reads.
  • Default in: PostgreSQL, Oracle, SQL Server.
  • Good for: most web traffic. A page load is essentially one read. If a price changes between loading a list and clicking a product, showing the newer price is fine. A worker that reads one item, processes it and writes a result is also fine here.

Repeatable read (snapshot isolation)#

The transaction works from a snapshot taken when it starts. Every read sees the same data, whatever others commit meanwhile.

Time T1 T2
100 Starts, and sees the database as of time 100
105 UPDATE balance = $500; COMMIT
Later Reads $1000 (from the snapshot)
Later Reads $1000 (still the snapshot)

How it works: MVCC. With multi-version concurrency control, the database keeps several versions of each row, tagged with when they were committed. Each transaction reads the newest version committed before it started.

Say the row has two versions: balance = $1000 committed at time 50, and balance = $500 committed at time 105.

Transaction started at Sees
100 $1000
110 $500
  • Blocks: dirty and non-repeatable reads.
  • Phantoms: the SQL standard allows them at this level, but common implementations go further. PostgreSQL's repeatable read reads everything from the snapshot, so phantoms don't appear. MySQL's InnoDB uses the snapshot for plain reads and adds gap locks for locking reads.
  • Still possible: write skew. Two transactions each read the same snapshot, make a decision, and write different rows. Example: two doctors each check that someone else is on call, and both go off call. Neither overwrote the other, but the rule "at least one doctor on call" is broken. Only serializable prevents this.
  • Default in: MySQL (InnoDB).
  • Good for: long reports, balance checks that read the same data twice, batch jobs that must see one consistent picture.

Serializable (highest)#

Transactions behave as if they ran one at a time, in some order. No anomalies are possible.

T1 T2
Reads stock: 1. Sets stock to 0. COMMIT
Reads stock: 0. Can't buy. ROLLBACK

The result is the same as running T1 fully, then T2.

  • Blocks: everything above, plus write skew.
  • Cost: the lowest concurrency. Under contention you get waiting or retries.
  • Good for: moving money, buying the last unit in stock, booking the last seat: anywhere one anomaly is unacceptable.

Summary#

Each level blocks more anomalies than the one before it:

Level Dirty read Non-repeatable read Phantom read
Read uncommitted Possible Possible Possible
Read committed Prevented Possible Possible
Repeatable read Prevented Prevented Allowed by the standard; often prevented in practice
Serializable Prevented Prevented Prevented

That safety costs speed:

Level Relative speed Typical use
Read uncommitted Fastest Almost never
Read committed Fast Most web apps (PostgreSQL default)
Repeatable read Moderate Reports, batch jobs (MySQL default)
Serializable Slowest Money, inventory, bookings
  • Higher isolation: safer data, but more locking or aborts, less concurrency and lower throughput.
  • Lower isolation: more throughput, but your application must handle the anomalies.

How serializable is implemented#

There are three main approaches.

1. Actual serial execution. Run one transaction at a time on a single thread. There are no conflicts because there is no concurrency. It only works if transactions are short and the data fits in memory. Redis works this way: commands, MULTI/EXEC blocks and Lua scripts run one after another.

2. Two-phase locking (2PL): pessimistic. Take locks before touching data. Readers and writers of the same rows block each other until the holder commits or aborts.

T1 T2
Wants stock, so locks the row
Wants stock, but is blocked and waits
Commits and releases the lock
Proceeds and sees the new value

Conflicts are handled up front, but there is a lot of waiting and a risk of deadlocks (each transaction waiting on a lock the other holds). The database detects these and aborts one. MySQL's serializable level works this way.

3. Serializable snapshot isolation (SSI): optimistic. Let transactions run on snapshots without blocking, track what each one read and wrote, and check for dangerous conflicts at commit time.

T1 T2
Starts, reads stock = 1 Starts, reads stock = 1
Decrements stock, COMMIT ✓
Decrements stock, tries to COMMIT
Database: "something T2 read was changed by a transaction that committed", so it aborts T2
Retries: reads stock = 0, can't buy ✓

This is what PostgreSQL's serializable level uses. It works well when conflicts are rare. When they're frequent, many transactions abort and retry, so your code must be ready to retry.

Serial execution Two-phase locking SSI
Conflicts handled Never arise Up front, by blocking At commit, by aborting
Concurrency None Limited High until commit
Main risk Low throughput Deadlocks and waiting Retries under contention
Used by Redis MySQL serializable PostgreSQL serializable

Isolation without isolation levels#

Many NoSQL stores don't offer SQL isolation levels. You get similar effects with other tools:

  • Eventually consistent reads never return uncommitted data, but might not show the very latest write.
  • Strongly consistent reads return the latest committed write for that item.
  • Conditional writes give optimistic concurrency without locks: "decrement stock only if stock > 0". If the condition fails, the write is rejected and you retry or report "sold out". This is a common fix for the last-item problem.

The same trick works in SQL:

UPDATE products SET stock = stock - 1
WHERE id = 42 AND stock > 0;
-- 0 rows updated means it was already sold out

Key takeaways#

  • Without isolation, concurrent transactions cause lost updates, dirty reads, non-repeatable reads and phantoms.
  • Read committed (PostgreSQL's default) blocks dirty reads and is fine for most web traffic.
  • Repeatable read gives each transaction a consistent snapshot via MVCC, but still allows write skew.
  • Serializable prevents every anomaly, implemented by serial execution, two-phase locking or SSI.
  • Stronger isolation means less throughput. Use it where correctness is critical and be ready to retry aborted transactions.

Found a mistake? Tell me on LinkedIn ↗

Further reading#