Transaction Isolation Levels: ACID, MVCC & Race Condition Prevention at Scale
A transaction is a sequence of operations that either all succeed (commit) or all fail (rollback). ACID properties—Atomicity, Consistency, Isolation, Durability—guarantee correctness. But implementing ACID completely comes at a cost: locking and reduced concurrency. Most systems relax the isolation guarantee: instead of serializing all transactions, they allow some transactions to see uncommitted changes from others (dirty reads), or see phantom rows that appeared between reads. Understanding isolation levels—and the race conditions each allows—is critical for building systems that are both correct and fast. This guide dissects the four SQL isolation levels, race conditions, and optimization strategies.
Software Engineer · Database · Systems Design
ACID Properties: The Foundation
Atomicity
A transaction is atomic: it either completes entirely or rolls back completely. There is no middle ground. If a transfer debits account A and credits account B, both operations succeed or both fail.
Consistency
A transaction moves the database from one consistent state to another. All constraints (primary keys, foreign keys, CHECK constraints) are satisfied. The database never violates its invariants, even if a transaction fails halfway.
Isolation
Concurrent transactions do not interfere with each other. Transaction A's reads and writes are isolated from Transaction B's. Isolation is the property relaxed by most systems for concurrency.
Durability
Once a transaction commits, the data persists, even if the server crashes. Durability is provided by write-ahead logging: transactions are written to disk before responding to the client.
The Four Isolation Levels
READ UNCOMMITTED (Dirty Reads)
Transaction A can read uncommitted changes from Transaction B. If B rolls back, A has read a value that never existed. This is rarely used in production.
-- Transaction B
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- balance = 900
-- Transaction A (same time)
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- Reads 900 (uncommitted!)
COMMIT;
-- Transaction B
ROLLBACK; -- Oops, the -100 never happened. A read a phantom value.
READ COMMITTED (Default in Most Systems)
Transaction A can only read committed changes from Transaction B. But between two reads in A, B can insert new rows (phantom reads) or update rows (non-repeatable reads).
-- Transaction A
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 123; -- Result: 5
COMMIT;
-- Transaction B (same time)
BEGIN;
INSERT INTO orders (user_id, ...) VALUES (123, ...);
COMMIT;
-- Transaction A (continued)
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 123; -- Result: 6! (phantom read)
REPEATABLE READ
Within a transaction, the same query returns the same result. New rows cannot appear (phantom reads are prevented), and existing rows cannot change. But between transactions, updates are visible (lost update race condition possible).
SERIALIZABLE
Complete isolation. Transactions execute as if they were serial (one after another), even though they run concurrently. This is the highest isolation level but the slowest due to locking overhead.
MVCC: Multi-Version Concurrency Control
Modern databases use MVCC to achieve high concurrency without locking. Each transaction sees a snapshot of the database at a specific point in time. When a row is updated, the database creates a new version; the old version remains visible to transactions that started before the update.
-- Simplified MVCC model
rows = {
(id=1, balance=1000, version=1),
(id=1, balance=900, version=2), // updated by T2
(id=1, balance=800, version=3) // updated by T3
}
// T1 (started at version 2) sees: balance=900
// T2 (started at version 3) sees: balance=800
// New transactions see: balance=800
Race Conditions and How to Prevent Them
Dirty Read
Reading uncommitted data. Prevented by: READ COMMITTED, REPEATABLE READ, SERIALIZABLE.
Non-Repeatable Read
The same query returns different results within a transaction. Transaction A reads a row, Transaction B updates it, A reads again and gets a different value. Prevented by: REPEATABLE READ, SERIALIZABLE.
-- T1 (REPEATABLE READ)
BEGIN;
SELECT salary FROM employees WHERE id = 123; -- 50,000
-- T2
UPDATE employees SET salary = 60,000 WHERE id = 123;
-- T1
SELECT salary FROM employees WHERE id = 123; -- Still 50,000 (repeatable)
Phantom Read
A query returns different rows in two executions within the same transaction. Between the two SELECT statements, Transaction B inserted rows matching the WHERE clause. Prevented by: SERIALIZABLE (and sometimes REPEATABLE READ, depending on the database).
Lost Update
Two transactions read the same value, both increment it, and the increments are lost. Prevented by: optimistic locking (version numbers) or pessimistic locking (SELECT FOR UPDATE).
-- Lost update scenario
-- T1: SELECT balance = 100; balance++; UPDATE balance = 101;
-- T2: SELECT balance = 100; balance++; UPDATE balance = 101;
-- Result: balance is 101, not 102. One increment was lost.
-- Solution: SELECT FOR UPDATE (pessimistic locking)
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- Lock the row
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;
Optimistic vs Pessimistic Locking
Pessimistic Locking
Lock the resource before reading: SELECT FOR UPDATE. If another transaction holds the lock, you wait. Safe but can cause deadlocks and reduce concurrency.
-- PostgreSQL
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;
Optimistic Locking
Add a version field to each row. On update, check that the version hasn't changed. If it has, the transaction fails (conflict). Retry the transaction. Reduces locking overhead but requires retry logic.
@Entity
public class Account {
@Version
Long version;
BigDecimal balance;
}
// JPA automatically handles optimistic locking
account.setBalance(account.getBalance() + 100);
accountRepository.save(account); // Throws OptimisticLockingFailureException if version changed
Deadlocks and Prevention
A deadlock occurs when two transactions wait for each other. T1 holds lock A and waits for lock B; T2 holds lock B and waits for lock A. Both are stuck.
// Prevention strategies
// 1. Always acquire locks in the same order
// 2. Keep transactions short
// 3. Use consistent READ COMMITTED isolation level
// 4. Monitor deadlock frequency and investigate
SELECT * FROM pg_stat_database WHERE deadlocks > 0;
Choosing Isolation Levels in Practice
- Financial transactions (transfers, payments): SERIALIZABLE or pessimistic locking. Correctness is paramount.
- Inventory updates: REPEATABLE READ or optimistic locking. Non-repeatable reads are acceptable; lost updates are not.
- Analytics queries: READ COMMITTED. Analytical consistency is not critical.
- User profile updates: READ COMMITTED with optimistic locking. High concurrency is needed.
Performance Implications
Higher isolation levels reduce concurrency:
- READ COMMITTED: Highest throughput, lowest latency. 5000+ transactions/sec.
- REPEATABLE READ: 3000-4000 transactions/sec. Slightly more locking.
- SERIALIZABLE: 500-1000 transactions/sec. Extensive locking; deadlocks increase.
Understanding isolation is essential for building correct systems at scale. Master these patterns, and you avoid data corruption, race conditions, and cascading failures.
Key Takeaways
- ACID properties are foundational. Atomicity and durability are non-negotiable; consistency and isolation can be relaxed for performance.
- READ COMMITTED is the default in most databases and works for most OLTP workloads. Understand its limitations (phantom reads, non-repeatable reads).
- REPEATABLE READ prevents non-repeatable reads but allows phantom reads. Useful for strict consistency needs without full serialization.
- SERIALIZABLE provides complete isolation but at a high performance cost. Reserve it for critical transactions (financial transfers).
- MVCC enables high concurrency by allowing readers and writers to coexist without blocking. Understand version management and cleanup.
- Choose between optimistic locking (lower overhead, requires retries) and pessimistic locking (blocks other transactions, risk of deadlocks).
- Monitor deadlocks and lock contention. High deadlock rates indicate schema or query issues that should be fixed.
Tags:
Read More
Explore related articles on databases, consistency, and distributed systems:
- Database Replication: Leader-Follower, Consistency & Failover — understand consistency across replicas
- Database Sharding at Scale: Consistent Hashing and Resharding — combining isolation with sharding
- Distributed Caching Patterns: Invalidation & Consistency — caching and consistency interactions
Discussion / Comments
Related Posts
Last updated: March 2026 — Written by Md Sanwar Hossain