Isolation Levels

Concurrent Transactions and Anomalies

Thousands of users buy at the same time. Isolation controls how much one transaction sees of others that are still running.

AnomalyWhat happens
Dirty readyou read a value another transaction wrote but has not committed (and may roll back)
Non-repeatable readyou read the same row twice and get different values, because someone committed in between
Phantom readyou run the same query twice and new rows appear
Lost updatetwo transactions read stock=5, both write stock=4; one sale is lost (see Locking in the advance level)
IsolationDirty readNon-repeatable readPhantom read
READ_UNCOMMITTEDpossiblepossiblepossible
READ_COMMITTED (PostgreSQL, Oracle, SQL Server default)nopossiblepossible
REPEATABLE_READ (MySQL InnoDB default)nonopossible (InnoDB prevents most)
SERIALIZABLEnonono

Watching the Levels Differ

Example

Transaction T1 reads the stock of product 7 three times. In between, T2 changes the stock to 7 (not yet committed) and then commits. Each isolation level shows T1 something different:

import java.util.*;

public class Main {
    static int committed;
    static Integer uncommitted;

    public static void main(String[] args) {
        for (String level : List.of("READ_UNCOMMITTED", "READ_COMMITTED", "REPEATABLE_READ")) {
            committed = 10;
            uncommitted = null;
            Map<String, Integer> snapshot = new HashMap<>();   // T1's snapshot (REPEATABLE_READ)

            int r1 = read(level, snapshot);
            uncommitted = 7;                                   // T2: update stock = 7 (not committed)
            int r2 = read(level, snapshot);
            committed = uncommitted;                           // T2: commit
            uncommitted = null;
            int r3 = read(level, snapshot);
            System.out.printf("%-16s T1 reads: %d, %d, %d%n", level, r1, r2, r3);
        }
    }

    static int read(String level, Map<String, Integer> snapshot) {
        return switch (level) {
            case "READ_UNCOMMITTED" -> uncommitted != null ? uncommitted : committed;   // sees dirty data
            case "READ_COMMITTED" -> committed;                                         // latest committed
            default -> snapshot.computeIfAbsent("stock", k -> committed);                // first read fixed
        };
    }
}

Output

READ_UNCOMMITTED T1 reads: 10, 7, 7
READ_COMMITTED   T1 reads: 10, 10, 7
REPEATABLE_READ  T1 reads: 10, 10, 10
  • READ_UNCOMMITTED: the second read (7) is a dirty read.
  • READ_COMMITTED: 10, 10, 7 is a non-repeatable read.
  • REPEATABLE_READ: 10 every time; T1 works on a consistent snapshot.

Setting the Level

@Transactional(isolation = Isolation.SERIALIZABLE)
public void closeDailyLedger(LocalDate day) { ... }

@Transactional(isolation = Isolation.READ_COMMITTED, timeout = 5)   // seconds
public ReportDto salesReport() { ... }
  • Isolation.DEFAULT uses the database default. Most apps never change it.
  • Higher isolation = more locking or more serialization failures to retry, so less throughput.
  • Isolation does not stop lost updates on its own at READ_COMMITTED. Use optimistic locking (@Version) or an atomic UPDATE like stock = stock - ? (advance level: Locking and Concurrency).