Definition
- Dirty read – reading data that another transaction has modified but not yet committed; if that transaction rolls back, the data you read never existed.
- Non-repeatable read – reading the same row twice within one transaction and getting different values because another transaction committed a change in between.
- Phantom read – running the same query twice and getting different row counts because another transaction inserted or deleted rows in between.
- READ_UNCOMMITTED – allows all three phenomena; fastest but never safe for banking.
- READ_COMMITTED – prevents dirty reads; PostgreSQL and Oracle default; re-reads can see committed changes.
- REPEATABLE_READ – prevents dirty and non-repeatable reads; MySQL InnoDB default; takes a consistent snapshot at the first read.
- SERIALIZABLE – prevents all phenomena; transactions execute as if serial; highest safety, lowest throughput.
At the National Bank of India, imagine Rajesh checking his account SAV2001 balance. READ_UNCOMMITTED is like looking at the teller's scratch pad while they are still writing (the teller might erase it). READ_COMMITTED is like waiting until the teller finishes and stamps the entry, but if you look again later, the amount might be different because another teller made a change. REPEATABLE_READ is like taking a photo of the ledger when you first look: every subsequent look shows the same photo. SERIALIZABLE is like locking the entire ledger so only one person can use it at a time.
Understanding the Concepts
What are the three read phenomena?
Before understanding isolation levels, you need to know the three problems they prevent.
A dirty read occurs when transaction A reads a row that transaction B has modified but not yet committed. If B rolls back, A has read data that never existed in the database. In banking, this means Rajesh might see a balance that includes a deposit that was never finalized.
A non-repeatable read occurs when transaction A reads the same row twice and gets different values because transaction B committed a change between the two reads. In banking, Rajesh checks his balance at the start of a report (Rs.100,000) and again at the end (Rs.95,000) because Priya's transfer committed in between.
A phantom read occurs when transaction A runs the same query twice and gets different row counts because transaction B inserted or deleted rows that match the query criteria. In banking, a count of active savings accounts returns 5,000 on the first query and 5,001 on the second because a new account was opened in between.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class ReadPhenomenaDemo {
private final AccountRepository accountRepo;
public ReadPhenomenaDemo(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
// Non-repeatable read: balance may change between reads
@Transactional(isolation = Isolation.READ_COMMITTED)
public void showNonRepeatableRead(String accountId) {
BigDecimal first = accountRepo.findById(accountId).getBalance();
// ... another TX commits a deposit here ...
BigDecimal second = accountRepo.findById(accountId).getBalance();
System.out.println("First: " + first + ", Second: " + second);
// Under READ_COMMITTED, first and second may differ
}
}
What is READ_UNCOMMITTED?
READ_UNCOMMITTED is the lowest isolation level. A transaction can see another transaction's uncommitted changes. This allows dirty reads, non-repeatable reads, and phantom reads. It provides the highest throughput because no read locks are acquired, but it is never acceptable in banking because you might make decisions based on data that gets rolled back.
// Never use this in banking
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public BigDecimal getBalanceUnsafe(String accountId) {
// May see uncommitted deposits or withdrawals
return accountRepo.findById(accountId).getBalance();
}
What is READ_COMMITTED?
READ_COMMITTED is the default isolation level for PostgreSQL and Oracle. A transaction sees only data that has been committed by other transactions. Dirty reads are impossible because uncommitted changes from other transactions are invisible. However, non-repeatable reads and phantom reads are possible because if you read the same row twice, another transaction might commit a change between your two reads.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class BalanceService {
private final AccountRepository accountRepo;
public BalanceService(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
// PostgreSQL default: no dirty reads, but re-reads may differ
@Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true)
public BigDecimal getBalance(String accountId) {
return accountRepo.findById(accountId).getBalance();
}
}
For single-row, single-query operations, READ_COMMITTED is sufficient and performant.
What is REPEATABLE_READ?
REPEATABLE_READ is the default isolation level for MySQL InnoDB. It takes a consistent snapshot at the first read in the transaction and reuses it for all subsequent reads. This means a value read at the start stays the same throughout the transaction, even if another transaction commits a change in between. Non-repeatable reads are impossible. In InnoDB, gap locks also prevent phantom reads.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
public class PortfolioService {
private final AccountRepository accountRepo;
private final HoldingsRepository holdingsRepo;
public PortfolioService(
AccountRepository accountRepo,
HoldingsRepository holdingsRepo) {
this.accountRepo = accountRepo;
this.holdingsRepo = holdingsRepo;
}
// Consistent snapshot: balance and holdings from the same instant
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public PortfolioReport generateReport(String customerId) {
BigDecimal cashBalance = accountRepo
.findByCustomerId(customerId).getBalance();
List<Holding> holdings = holdingsRepo
.findByCustomerId(customerId);
// Both queries see the same snapshot
return new PortfolioReport(cashBalance, holdings);
}
}
Under READ_COMMITTED, a deposit between the two queries would make the cash balance inconsistent with the holdings. REPEATABLE_READ prevents this.
What is SERIALIZABLE?
SERIALIZABLE is the strictest isolation level. Transactions execute as if they were run one after another, not concurrently. All three read phenomena are prevented. The cost is maximum locking (or serialization failure retries under MVCC), which reduces throughput significantly.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class AuditService {
private final AccountRepository accountRepo;
private final AuditLogRepository auditRepo;
public AuditService(
AccountRepository accountRepo,
AuditLogRepository auditRepo) {
this.accountRepo = accountRepo;
this.auditRepo = auditRepo;
}
// Full isolation: audit totals must be perfectly consistent
@Transactional(isolation = Isolation.SERIALIZABLE, readOnly = true)
public BigDecimal auditTotalDeposits() {
return accountRepo.sumAllBalances();
}
}
Use SERIALIZABLE only when absolute correctness is required and you can tolerate the performance cost.
How do isolation levels compare?
| Level | Dirty Read | Non-Repeatable Read | Phantom Read | Performance | Default For |
|-------|-----------|-------------------|-------------|-------------|-------------|
| READ_UNCOMMITTED | Possible | Possible | Possible | Fastest | None (avoid) |
| READ_COMMITTED | Prevented | Possible | Possible | Good | PostgreSQL, Oracle |
| REPEATABLE_READ | Prevented | Prevented | Possible* | Moderate | MySQL InnoDB |
| SERIALIZABLE | Prevented | Prevented | Prevented | Slowest | None |
* InnoDB prevents phantom reads with gap locks under REPEATABLE_READ.
package com.bank.service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
// Setting isolation level in @Transactional
public class IsolationExamples {
@Transactional(isolation = Isolation.READ_COMMITTED)
public void singleQuery() { /* single row lookup */ }
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void multiQueryReport() { /* consistent multi-query */ }
@Transactional(isolation = Isolation.SERIALIZABLE)
public void criticalAudit() { /* full isolation needed */ }
@Transactional(isolation = Isolation.DEFAULT)
public void useDbDefault() { /* database decides */ }
}
How do you set the isolation level in Spring?
Use the isolation attribute of @Transactional. The value Isolation.DEFAULT means Spring does not set any isolation level and the database uses its own default.
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true, timeout = 30)
public PortfolioReport generateReport(String customerId) {
// consistent snapshot with 30-second timeout
}
For TransactionTemplate, use the setter method:
import org.springframework.transaction.TransactionDefinition;
TransactionTemplate template = new TransactionTemplate(txManager);
template.setIsolationLevel(
TransactionDefinition.ISOLATION_REPEATABLE_READ);
What are common mistakes with isolation levels?
Mistake 1: using READ_COMMITTED for multi-query consistency
// WRONG: balance and holdings can be from different points in time
@Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true)
public PortfolioReport generateReport(String customerId) {
BigDecimal balance = accountRepo.findByCustomerId(customerId).getBalance();
// Another TX commits a deposit here
List<Holding> holdings = holdingsRepo.findByCustomerId(customerId);
// balance and holdings are inconsistent
return new PortfolioReport(balance, holdings);
}
// CORRECT: REPEATABLE_READ ensures consistent snapshot
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public PortfolioReport generateReport(String customerId) {
BigDecimal balance = accountRepo.findByCustomerId(customerId).getBalance();
List<Holding> holdings = holdingsRepo.findByCustomerId(customerId);
return new PortfolioReport(balance, holdings);
}
Mistake 2: using SERIALIZABLE everywhere
// WRONG: SERIALIZABLE for a simple balance lookup wastes resources
@Transactional(isolation = Isolation.SERIALIZABLE)
public BigDecimal getBalance(String accountId) {
return accountRepo.findById(accountId).getBalance();
}
// CORRECT: READ_COMMITTED is sufficient for single-row reads
@Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true)
public BigDecimal getBalance(String accountId) {
return accountRepo.findById(accountId).getBalance();
}
What should a senior engineer know?
- The isolation level is set per-transaction, not per-query. All queries within a transaction share the same isolation behavior.
- Under REPEATABLE_READ with MVCC (PostgreSQL, InnoDB), write-write conflicts on the same row produce serialization failures that the application must retry.
- PostgreSQL's REPEATABLE_READ is technically Snapshot Isolation, which is slightly weaker than true REPEATABLE_READ in the SQL standard. It prevents phantom reads via MVCC snapshots, not via gap locks.
- Setting
@Transactional(isolation = Isolation.REPEATABLE_READ)sends aSET TRANSACTION ISOLATION LEVELto the database at the start of the transaction. Not all databases support all levels. - In a connection pool (HikariCP), the isolation level is reset to the default when the connection is returned to the pool, so transaction-level settings do not leak.
- The rule of thumb: READ_COMMITTED for single-row operations, REPEATABLE_READ for multi-query consistency within a transaction, SERIALIZABLE only when absolute correctness is required.
package com.bank.service;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.dao.CannotAcquireLockException;
@Service
public class RetryableReportService {
private final AccountRepository accountRepo;
public RetryableReportService(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
// Retry on serialization failure (write-write conflict under MVCC)
@Retryable(retryFor = CannotAcquireLockException.class, maxAttempts = 3)
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public java.math.BigDecimal getTotalDeposits() {
return accountRepo.sumAllBalances();
}
}
Visual Overview
Read Phenomena vs Isolation Levels
────────────────────────────────────
Phenomenon READ_ READ_ REPEATABLE_ SERIAL-
UNCOMMITTED COMMITTED READ IZABLE
─────────────────── ────────── ─────────── ──────────── ────────
Dirty Read Possible Prevented Prevented Prevented
Non-Repeatable Read Possible Possible Prevented Prevented
Phantom Read Possible Possible Possible* Prevented
* InnoDB prevents phantoms with gap locks under REPEATABLE_READ
┌─────────────────────────────────────────────────┐
│ Less isolation ◄────────────► More isolation │
│ More throughput Less throughput │
│ │
│ READ_UNCOMMITTED → READ_COMMITTED → │
│ REPEATABLE_READ → SERIALIZABLE │
└─────────────────────────────────────────────────┘
Key Takeaway
Transaction isolation levels control what a transaction can see of concurrent transactions' changes. There are three read phenomena to prevent: dirty reads (seeing uncommitted data), non-repeatable reads (a value changes between two reads in the same transaction), and phantom reads (new rows appear between two identical queries). Each isolation level prevents progressively more phenomena at the cost of lower throughput.
READ_COMMITTED, the PostgreSQL default, prevents dirty reads but allows non-repeatable reads. For single-query operations like checking Rajesh's balance on account SAV2001, this is sufficient. REPEATABLE_READ, the MySQL InnoDB default, creates a consistent snapshot at the first read so all subsequent reads in the transaction see the same data. This is essential for Pankaj's portfolio valuation service at National Bank of India, where the cash balance and stock holdings must come from the same point in time. Under READ_COMMITTED, a deposit between the two queries would make them inconsistent.
SERIALIZABLE provides full isolation where transactions behave as if executed one at a time. It prevents all phenomena including phantom reads, but at the cost of maximum locking and the lowest throughput. In practice, it is rarely needed. The rule of thumb is: READ_COMMITTED for single-row operations, REPEATABLE_READ for multi-query consistency, and SERIALIZABLE only when absolute correctness is required and the performance cost is acceptable.
Remember one sentence: READ_COMMITTED prevents dirty reads and is enough for single queries, REPEATABLE_READ provides a consistent snapshot for multi-query reports, and SERIALIZABLE gives full isolation at the cost of throughput.
---