← Engineering Notes

Isolation Levels

Data and Persistence · last updated Oct 2026

Concept

Isolation levels control how much one database transaction can see of another transaction's uncommitted work. They represent a configurable trade-off between consistency (how correct and predictable the data is) and performance (how much locking and contention the database must manage).

The SQL standard defines four isolation levels. Most databases implement variations of these, and PostgreSQL in particular implements them using multiversion concurrency control (MVCC), which means reads generally don't block writes.

Problem

When multiple transactions run at the same time, several anomalies can occur:

  • Dirty read — a transaction reads data written by another transaction that has not yet committed. If that transaction rolls back, the first transaction has read data that never existed.
  • Non-repeatable read — a transaction reads the same row twice and gets different values because another transaction modified and committed that row between the two reads.
  • Phantom read — a transaction executes the same query twice and gets a different number of rows because another transaction inserted or deleted rows matching the query between the two executions.
  • Serialization anomaly — the result of running transactions concurrently is inconsistent with any possible sequential execution of those same transactions.

Different applications can tolerate different anomalies. A financial system processing transfers needs strong guarantees. An analytics dashboard reading approximate aggregate counts can tolerate weaker consistency.

How it works

The four standard levels, from weakest to strongest:

Read Uncommitted — transactions can read uncommitted changes from other transactions. Dirty reads are possible. PostgreSQL maps this to Read Committed in practice.

Read Committed — a transaction only sees data that has been committed. Each statement within a transaction sees the latest committed snapshot at the time that statement runs. This is PostgreSQL's default level. Non-repeatable reads are possible because two statements in the same transaction may see different snapshots.

Repeatable Read — a transaction sees a consistent snapshot of the database taken at the start of the transaction. Non-repeatable reads are prevented. Phantom reads can still occur in standard SQL, but PostgreSQL's MVCC implementation prevents them at this level too.

Serializable — the strongest level. Transactions execute as if they ran one after another in some serial order. All anomalies are prevented. PostgreSQL implements this using predicate locking to detect and abort transactions that would produce a serialization anomaly.

Trade-offs

Stronger isolation means more overhead. At Serializable, PostgreSQL must track access patterns and may abort transactions that would cause anomalies, requiring the application to retry them. This adds complexity to application code.

Read Committed is the practical default for most applications. It prevents the worst anomalies (dirty reads) while keeping contention manageable. Moving to Repeatable Read or Serializable should be a deliberate decision, usually for specific operations that require stronger guarantees.

Practical application

In Simple Bank, the money transfer logic requires that the debit and credit are consistent. If two transfers are running concurrently that both touch the same account, they must not interfere. The solution is to acquire locks in a consistent order — always lock the lower account ID first — which prevents deadlocks without requiring Serializable isolation for the entire database.

Setting isolation level in PostgreSQL:

BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- queries here see a consistent snapshot
COMMIT;

In Go with database/sql:

tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelRepeatableRead,
})

My understanding

Isolation levels are one of those things that seem academic until you build something with concurrent writes. In Simple Bank, I initially assumed that using transactions was enough to make concurrent transfers safe. It wasn't. I had to understand what Read Committed actually guarantees — and what it doesn't — before I could reason correctly about the locking strategy.

The key insight is that isolation and atomicity are separate concerns. Transactions give you atomicity. The isolation level controls what concurrent transactions can see of each other. You need to think about both.

← Back to Engineering Notes