Transaction Isolation Levels: Dirty Reads to Serializable
Every database transaction makes a promise: your operations will be atomic, consistent, isolated, and durable. But isolation is not binary — it exists on a spectrum, and choosing the right level determines whether your application sees stale data, phantom rows, or grinds to a halt under contention.
ACID: The Foundation#
Before diving into isolation, recall the four guarantees:
- Atomicity — all operations in a transaction succeed or none do
- Consistency — the database moves from one valid state to another
- Isolation — concurrent transactions do not interfere with each other
- Durability — committed data survives crashes
Isolation is the most nuanced of these. Full isolation (serializability) is expensive, so databases offer weaker levels that trade correctness for throughput.
The Four Isolation Levels#
The SQL standard defines four levels, each preventing progressively more anomalies:
Level | Dirty Read | Non-Repeatable Read | Phantom Read
---------------------|------------|---------------------|-------------
Read Uncommitted | Possible | Possible | Possible
Read Committed | Prevented | Possible | Possible
Repeatable Read | Prevented | Prevented | Possible
Serializable | Prevented | Prevented | Prevented
Read Uncommitted#
The weakest level. A transaction can see uncommitted changes from other transactions.
-- Session A
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 1;
-- Not yet committed
-- Session B (Read Uncommitted)
SELECT balance FROM accounts WHERE id = 1;
-- Returns 0 — a "dirty read"
-- If Session A rolls back, Session B used invalid data
Almost no production system uses this level. PostgreSQL does not even implement it — setting it silently upgrades to Read Committed.
Read Committed#
The default in PostgreSQL and Oracle. Each statement sees only data committed before that statement began.
-- Session A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- Returns 100
-- Session B
UPDATE accounts SET balance = 200 WHERE id = 1;
COMMIT;
-- Session A (same transaction)
SELECT balance FROM accounts WHERE id = 1; -- Returns 200 (non-repeatable read)
The same query within one transaction can return different results. This is safe for most OLTP workloads but problematic for reports or multi-step business logic.
Repeatable Read#
The default in MySQL/InnoDB. Once a transaction reads a row, subsequent reads return the same value — even if another transaction modifies it.
-- Session A
BEGIN; -- snapshot taken here
SELECT balance FROM accounts WHERE id = 1; -- Returns 100
-- Session B
UPDATE accounts SET balance = 200 WHERE id = 1;
COMMIT;
-- Session A
SELECT balance FROM accounts WHERE id = 1; -- Still returns 100
However, phantom reads can still occur — new rows inserted by other transactions may appear in range queries.
Serializable#
The strongest level. Transactions execute as if they ran one at a time. Any anomaly causes an abort.
-- Both sessions try to insert if no row exists
-- Session A
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM reservations WHERE seat = 'A1'; -- Returns 0
INSERT INTO reservations (seat) VALUES ('A1');
-- Session B (concurrent)
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM reservations WHERE seat = 'A1'; -- Returns 0
INSERT INTO reservations (seat) VALUES ('A1');
-- One of these transactions will be aborted with a serialization error
Your application must be prepared to retry aborted transactions.
Understanding the Anomalies#
Dirty Reads#
Reading data written by a transaction that has not yet committed. If that transaction rolls back, you acted on data that never existed.
Non-Repeatable Reads#
Reading the same row twice in one transaction and getting different values because another transaction modified and committed between your reads.
Phantom Reads#
Running the same range query twice and getting different sets of rows because another transaction inserted or deleted rows matching your predicate.
MVCC: How Modern Databases Implement Isolation#
PostgreSQL, MySQL/InnoDB, and Oracle all use Multi-Version Concurrency Control. Instead of locking rows during reads, the database keeps multiple versions of each row.
Row versions for accounts.id = 1:
Version | Value | Created by TX | Visible to
---------|---------|---------------|------------------
v1 | 100 | TX 50 | TX 50-99
v2 | 200 | TX 100 | TX 100+
Readers never block writers. Writers never block readers.
Each transaction gets a snapshot — a consistent view of the database at a point in time. The isolation level determines when the snapshot is taken:
- Read Committed — new snapshot per statement
- Repeatable Read — snapshot at first statement in transaction
- Serializable — snapshot at first statement, plus conflict detection
MVCC is why PostgreSQL can offer Repeatable Read without range locks and why VACUUM is necessary to clean up old row versions.
Optimistic vs Pessimistic Locking#
Pessimistic Locking#
Acquire locks before accessing data. Other transactions wait.
-- Lock the row explicitly
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- Other transactions trying to update this row will block
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
COMMIT;
Best when conflicts are frequent — avoids wasted work from retries.
Optimistic Locking#
Proceed without locks, check for conflicts at commit time.
-- Application-level version check
SELECT balance, version FROM accounts WHERE id = 1;
-- balance = 100, version = 5
UPDATE accounts
SET balance = 50, version = 6
WHERE id = 1 AND version = 5;
-- If 0 rows affected, another transaction changed it — retry
Best when conflicts are rare — avoids lock contention and improves throughput.
PostgreSQL vs MySQL Defaults#
Feature | PostgreSQL | MySQL/InnoDB
---------------------|-----------------------|------------------------
Default isolation | Read Committed | Repeatable Read
MVCC | Yes | Yes
Serializable impl | SSI (predicate-based) | Gap locks + next-key locks
Phantom prevention | Via SSI at Serializable| Via gap locks at RR
Read Uncommitted | Maps to Read Committed| Actually implemented
PostgreSQL's Serializable Snapshot Isolation (SSI) is particularly elegant — it detects dangerous patterns in the dependency graph and aborts only the minimum transactions necessary.
MySQL prevents phantoms at Repeatable Read using gap locks, which lock ranges between index entries. This can cause unexpected lock waits on INSERT statements.
Choosing the Right Level#
Read Committed (default for most): suitable for typical web applications where each request is a short transaction and occasional non-repeatable reads are acceptable.
Repeatable Read: use when a transaction performs multiple reads that must be consistent — financial reports, inventory checks, or any multi-step read-then-write pattern.
Serializable: use for critical correctness requirements — double-booking prevention, constraint enforcement that spans multiple tables, or anywhere a race condition would cause data corruption.
Rule of thumb: start with your database's default. Only escalate when you can identify a specific anomaly that would cause a real bug. Every step up costs throughput.
Practical Checklist#
- Know your database's default isolation level
- Use
SELECT ... FOR UPDATEwhen you read-then-write in Read Committed - Always handle serialization errors with retry logic at Serializable
- Monitor lock wait times and deadlock frequency
- Prefer optimistic locking for low-contention, high-throughput paths
- Use pessimistic locking for high-contention resources like counters or inventory
- Test concurrent scenarios — isolation bugs only appear under load
Explore more system design and engineering guides on the Codelit blog.
Try these templates
Continue learning
Go deeper on data and storage
As an Amazon Associate I earn from qualifying purchases. Codelit may receive a commission at no extra cost to you.
Fundamentals of Data Engineering
Joe Reis, Matt Housley · 2022
Covers the pipeline lifecycle from ingestion to serving, with trade-off tables for each stage.
4.7 (897)#3 in Data MiningRedis in Action
Josiah L. Carlson · 2013
Cache, lock, queue and rate-limit patterns in Redis written out as complete code, not snippets.
3.8 (27)Designing Data-Intensive Applications
Martin Kleppmann, Chris Riccomini · 2026
Traces replication, consistency and consensus end to end, with the failure modes behind each choice.
3.6 (196)#1 in Data MiningA Philosophy of Software Design
John Ousterhout · 2021
Short book on module depth and complexity, with before/after code showing why an interface is too wide.