Hook: Interviewers love this topic because it reveals whether you understand what happens when two users touch the same data at the same time.
Question: What are transaction isolation levels in Spring Boot?
Answer: Isolation level is the rule that decides how much one transaction can see of another transaction while both are running. In Spring Boot, you usually set it with @Transactional(isolation = Isolation.READ_COMMITTED), or you leave it as DEFAULT and let the database decide. Higher isolation prevents more anomalies like dirty reads and non-repeatable reads, but it also reduces concurrency and can increase waiting or retries.
Interview-Ready Answer: I think of isolation as the amount of privacy a transaction gets. In Spring Boot I can request a level with @Transactional, but the real enforcement comes from the database through JDBC. The main levels are READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, and SERIALIZABLE; as the level goes up, correctness increases, but throughput usually drops. One important detail is that DEFAULT is not a real isolation level, it just uses the database default, which differs by vendor.
Isolation is one part of ACID. It answers a simple question: if two transactions overlap, what can each one see? A good mental model is that the database is deciding whether you are allowed to look at a half-finished notebook, a finished notebook, or a frozen copy of that notebook.
| Level | Dirty read | Non-repeatable | Phantom | Typical use |
|---|---|---|---|---|
| READ_UNCOMMITTED | Possible | Possible | Possible | Rare |
| READ_COMMITTED | No | Possible | Possible | Common default |
| REPEATABLE_READ | No | No | Depends on DB | Stable reads |
| SERIALIZABLE | No | No | No | Highest safety |
Note: DEFAULT means use the database default, not a special Spring behavior. That is why the same code can behave differently on PostgreSQL, MySQL, or SQL Server.
There is no meaningful sorting-style big-O here. The real cost is extra lock wait time, version storage, and possible retries. In a busy OLTP system, long transactions of even 100 to 500 ms can increase contention; serializable workloads can reduce throughput by 20 to 50 percent compared with read committed, depending on the database and traffic pattern. MVCC also uses extra space for old row versions until the oldest active transaction finishes.
READ_UNCOMMITTED as READ_COMMITTED.@Version optimistic locking in JPA. They solve related but different problems.@Transactional only works when the call goes through the proxy. Self-invocation bypasses it, so the isolation setting may never apply.Memory check: lower isolation means more concurrency and more surprises; higher isolation means fewer surprises and more waiting.
Real-World Story: Imagine a checkout service for a flash-sale store. Two shoppers try to buy the last headset at the same time. With only READ_COMMITTED and a naive read-then-write flow, both requests can read stock as 1, both charge successfully, and one order gets canceled later when inventory reconciliation fails. The symptom is ugly: payment logs say success, inventory logs show stale reads, and customer support sees angry users with missing items. The fix is usually a mix of the right isolation level, atomic updates like UPDATE ... WHERE stock >= 1, and optimistic locking for the domain model.
import org.springframework.beans.factory.annotation.Bean;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import javax.sql.DataSource;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
@SpringBootApplication
public class TransactionIsolationDemoApplication {
public static void main(String[] args) {
SpringApplication.run(TransactionIsolationDemoApplication.class, args);
}
@Bean
public DataSource dataSource() {
// H2 is only for a runnable demo. Real databases may show slightly different
// behavior, which is exactly why interviewers ask this question.
DriverManagerDataSource ds = new DriverManagerDataSource();
ds.setDriverClassName("org.h2.Driver");
ds.setUrl("jdbc:h2:mem:iso_demo;DB_CLOSE_DELAY=-1;MODE=PostgreSQL");
ds.setUsername("sa");
ds.setPassword("");
return ds;
}
@Bean
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
@Bean
public CommandLineRunner demo(JdbcTemplate jdbcTemplate, PlatformTransactionManager txManager) {
return args -> {
jdbcTemplate.execute("DROP TABLE IF EXISTS accounts");
jdbcTemplate.execute("CREATE TABLE accounts (id BIGINT PRIMARY KEY, balance INT NOT NULL)");
jdbcTemplate.update("INSERT INTO accounts(id, balance) VALUES (?, ?)", 1L, 100);
System.out.println("\n--- READ COMMITTED: second read may change ---");
runNonRepeatableReadDemo(jdbcTemplate, txManager, TransactionDefinition.ISOLATION_READ_COMMITTED);
resetBalance(jdbcTemplate, 100);
System.out.println("\n--- REPEATABLE READ: second read should stay stable in many databases ---");
runNonRepeatableReadDemo(jdbcTemplate, txManager, TransactionDefinition.ISOLATION_REPEATABLE_READ);
resetBalance(jdbcTemplate, 100);
System.out.println("\n--- Failure path: lost update with naive read-modify-write ---");
runLostUpdateDemo(jdbcTemplate, txManager);
resetBalance(jdbcTemplate, 100);
System.out.println("\n--- Safer fix: atomic update prevents overspending ---");
boolean first = atomicWithdraw(jdbcTemplate, txManager, 80);
boolean second = atomicWithdraw(jdbcTemplate, txManager, 80);
System.out.println("First withdraw success = " + first);
System.out.println("Second withdraw success = " + second + " (expected false)");
System.out.println("Final balance = " + currentBalance(jdbcTemplate));
};
}
private static void runNonRepeatableReadDemo(JdbcTemplate jdbcTemplate,
PlatformTransactionManager txManager,
int isolationLevel) throws Exception {
TransactionTemplate readerTx = newTemplate(txManager, isolationLevel);
TransactionTemplate writerTx = newTemplate(txManager, isolationLevel);
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch firstReadDone = new CountDownLatch(1);
Future<List<Integer>> reader = pool.submit(() -> readerTx.execute(status -> {
int first = currentBalance(jdbcTemplate);
System.out.println("Reader first read = " + first);
firstReadDone.countDown();
// Small pause gives the writer a chance to commit between the two reads.
sleep(350);
int second = currentBalance(jdbcTemplate);
System.out.println("Reader second read = " + second);
return List.of(first, second);
}));
Future<?> writer = pool.submit(() -> {
await(firstReadDone);
sleep(100);
writerTx.execute(status -> {
jdbcTemplate.update("UPDATE accounts SET balance = balance + 50 WHERE id = 1");
return null;
});
System.out.println("Writer committed +50");
return null;
});
System.out.println("Observed values = " + reader.get(5, TimeUnit.SECONDS));
writer.get(5, TimeUnit.SECONDS);
pool.shutdownNow();
}
private static void runLostUpdateDemo(JdbcTemplate jdbcTemplate,
PlatformTransactionManager txManager) throws Exception {
TransactionTemplate tx1 = newTemplate(txManager, TransactionDefinition.ISOLATION_READ_COMMITTED);
TransactionTemplate tx2 = newTemplate(txManager, TransactionDefinition.ISOLATION_READ_COMMITTED);
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch bothRead = new CountDownLatch(2);
CountDownLatch allowWrite = new CountDownLatch(1);
Callable<Integer> task1 = () -> tx1.execute(status -> {
int current = currentBalance(jdbcTemplate);
int planned = current - 80;
System.out.println("T1 read " + current + ", plans to write " + planned);
bothRead.countDown();
await(allowWrite);
jdbcTemplate.update("UPDATE accounts SET balance = ? WHERE id = 1", planned);
return planned;
});
Callable<Integer> task2 = () -> tx2.execute(status -> {
int current = currentBalance(jdbcTemplate);
int planned = current - 80;
System.out.println("T2 read " + current + ", plans to write " + planned);
bothRead.countDown();
await(allowWrite);
jdbcTemplate.update("UPDATE accounts SET balance = ? WHERE id = 1", planned);
return planned;
});
Future<Integer> f1 = pool.submit(task1);
Future<Integer> f2 = pool.submit(task2);
await(bothRead);
allowWrite.countDown();
System.out.println("T1 wrote = " + f1.get(5, TimeUnit.SECONDS));
System.out.println("T2 wrote = " + f2.get(5, TimeUnit.SECONDS));
System.out.println("Final balance after lost update demo = " + currentBalance(jdbcTemplate));
pool.shutdownNow();
}
private static boolean atomicWithdraw(JdbcTemplate jdbcTemplate,
PlatformTransactionManager txManager,
int amount) {
TransactionTemplate tx = newTemplate(txManager, TransactionDefinition.ISOLATION_READ_COMMITTED);
Boolean result = tx.execute(status -> {
// The database changes the row only if enough balance remains.
// This is safer than reading first and writing later.
int updated = jdbcTemplate.update(
"UPDATE accounts SET balance = balance - ? WHERE id = 1 AND balance >= ?",
amount,
amount
);
return updated == 1;
});
return Boolean.TRUE.equals(result);
}
private static TransactionTemplate newTemplate(PlatformTransactionManager txManager, int isolationLevel) {
TransactionTemplate template = new TransactionTemplate(txManager);
template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
template.setIsolationLevel(isolationLevel);
return template;
}
private static int currentBalance(JdbcTemplate jdbcTemplate) {
Integer balance = jdbcTemplate.queryForObject(
"SELECT balance FROM accounts WHERE id = 1",
Integer.class
);
if (balance == null) {
throw new IllegalStateException("Account row missing");
}
return balance;
}
private static void resetBalance(JdbcTemplate jdbcTemplate, int balance) {
jdbcTemplate.update("UPDATE accounts SET balance = ? WHERE id = 1", balance);
}
private static void await(CountDownLatch latch) {
try {
if (!latch.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("Timed out waiting for latch");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while waiting", e);
}
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while sleeping", e);
}
}
}
Follow-up & Tricky Questions:
@Transactional isolation differ from propagation? Isolation controls what data one transaction can observe; propagation controls whether a method joins an existing transaction or starts a new one.DEFAULT, which delegates to the database default. That is why PostgreSQL and MySQL can behave differently with the same code.REPEATABLE_READ over SERIALIZABLE? Choose it when you need stable rereads but still want better concurrency than full serializability. It is a common middle ground for read-heavy workflows.@Version detects that an entity changed after you read it.READ_UNCOMMITTED? Not in the practical dirty-read sense. PostgreSQL maps it to READ_COMMITTED, so many candidates get fooled by the name.Tricky / gotchas:
DEFAULT a real level? No. It means use whatever the database says, so it is a request to delegate, not a guarantee.SERIALIZABLE mean no retries? No. It often means the opposite: the database may abort one transaction and ask the application to retry.REPEATABLE_READ always prevent phantoms? Not by definition. Different databases implement it differently, especially when MVCC is involved.Common Mistakes:
DEFAULT means the same thing everywhere. Correction: it depends on the database vendor and even the storage engine.READ_UNCOMMITTED is a safe performance trick. Correction: it can return data that never actually commits.@Transactional only applies when the call goes through the proxy, not a direct self-call.Memory Hook: Think of isolation like a restaurant table: READ_COMMITTED lets you see only food that was actually served, REPEATABLE_READ keeps your plate unchanged during the meal, and SERIALIZABLE gives you the whole restaurant one table at a time.
Cheat Sheet:
DEFAULT = database default, not a real Spring-specific level.READ_COMMITTED blocks dirty reads and is the common production default.REPEATABLE_READ makes repeated reads stable inside one transaction.SERIALIZABLE is strongest, safest, and slowest under contention.@Version or atomic SQL updates.Practice Tasks:
UPDATE ... WHERE balance >= ? and observe how the bug disappears.