Appearance
Spring Boot @Transactional Annotation Part 3 | Isolation Levels
What Is an Isolation Level?
In one sentence: an isolation level tells how the changes made by one transaction are visible to other transactions running in parallel.
That definition sounds abstract, so let us build it from the ground up with problems first. Once you understand the three concurrency problems that isolation levels solve, the definition will make complete sense.
Real World Analogy: The Library Reading Room
Imagine a library where multiple people read and update the same books simultaneously.
- One person is editing chapter 3 of a book but has not finished yet (uncommitted change)
- Another person reads chapter 3 mid edit and gets the half finished version
- Later the editor decides to scrap all their changes and reverts to the original
The second person read something that never officially existed. That is a dirty read.
Now imagine:
- You read the price of a book — it is ₹500
- Someone else changes the price to ₹700 and commits
- You read the price again in the same transaction — it is now ₹700
You read the same thing twice and got different values. That is a non repeatable read.
Finally:
- You query "all books priced under ₹1000" — you get 50 books
- Someone inserts 5 new books priced at ₹800 and commits
- You run the same query again in the same transaction — you now get 55 books
The result set itself changed. That is a phantom read.
Isolation levels exist to prevent these three problems to varying degrees.
The Three Concurrency Problems
Problem 1: Dirty Read
Definition: Transaction A reads uncommitted data from Transaction B. If Transaction B rolls back, Transaction A has read data that never really existed.
Timeline:
| Time | Transaction A | Transaction B | DB State (id=123) |
|---|---|---|---|
| T1 | BEGIN | BEGIN | status = FREE |
| T2 | UPDATE status to BOOKED | status = BOOKED (uncommitted) | |
| T3 | READ id=123 → gets BOOKED | ||
| T4 | ROLLBACK | status = FREE (reverted) | |
| T5 | Proceeds with stale "BOOKED" data |
Transaction A read BOOKED, but that value never existed in a committed state. Transaction A has a dirty read.
Problem 2: Non Repeatable Read
Definition: Transaction A reads the same row multiple times within the same transaction and gets different values because another transaction committed a change in between.
Timeline:
| Time | Transaction A | DB State (id=1) |
|---|---|---|
| T1 | BEGIN | status = FREE |
| T2 | READ id=1 → status = FREE | |
| T3 | (Transaction B updates and COMMITS) | status = BOOKED |
| T4 | READ id=1 again → status = BOOKED |
Transaction A read the same row twice and got two different values within the same transaction. The row was NOT repeatable.
Problem 3: Phantom Read
Definition: Transaction A runs the same range query multiple times and gets a different number of rows because another transaction inserted or deleted rows in between.
Timeline:
| Time | Transaction A | DB State |
|---|---|---|
| T1 | BEGIN | Rows: id=1, id=4 |
| T2 | SELECT WHERE id > 0 AND id < 5 → 2 rows | |
| T3 | (Transaction B inserts id=2 and COMMITS) | Rows: id=1, id=2, id=4 |
| T4 | SELECT WHERE id > 0 AND id < 5 → 3 rows |
The result set changed. A phantom row appeared. This is a phantom read.
Database Locking: The Mechanism Behind Isolation
Before covering isolation levels, you need to understand how databases physically enforce isolation using locks.
Shared Lock (Read Lock)
Represented as S. When a transaction wants to read a row, it can acquire a shared lock.
Rules for shared locks:
- Multiple transactions can hold shared locks on the same row simultaneously
- Any transaction with a shared lock can only read — not modify
- If a shared lock is held, no other transaction can take an exclusive lock on that row
T1: SHARED LOCK on row id=1 → can read
T2: SHARED LOCK on row id=1 → can also read (allowed — shared locks can coexist)
T3: EXCLUSIVE LOCK on row id=1 → BLOCKED (shared lock is held)Exclusive Lock (Write Lock)
Represented as X. When a transaction wants to write (insert, update, delete) a row, it must acquire an exclusive lock.
Rules for exclusive locks:
- Only one transaction can hold an exclusive lock at a time
- No other transaction can take a shared lock OR an exclusive lock while an exclusive lock is held
- The holder of the exclusive lock can both read and modify
T1: EXCLUSIVE LOCK on row id=1 → can read and write
T2: SHARED LOCK on row id=1 → BLOCKED (exclusive lock is held)
T3: EXCLUSIVE LOCK on row id=1 → BLOCKED (exclusive lock is held)Lock Compatibility Matrix
| Shared Lock Request | Exclusive Lock Request | |
|---|---|---|
| Shared Lock Held | Allowed | Blocked |
| Exclusive Lock Held | Blocked | Blocked |
| No Lock Held | Allowed | Allowed |
With this understanding, the behavior of each isolation level becomes logical and predictable.
How to Set Isolation in Spring Boot
java
@Transactional(isolation = Isolation.READ_COMMITTED)
public void someMethod() {
// this method runs under READ_COMMITTED isolation
}The four available values are:
java
Isolation.READ_UNCOMMITTED
Isolation.READ_COMMITTED
Isolation.REPEATABLE_READ
Isolation.SERIALIZABLEThere is also Isolation.DEFAULT, which tells Spring to use whatever the database's default isolation level is. Do not rely on this if you need specific behavior — always check what your specific database defaults to. Most relational databases like MySQL (InnoDB) and PostgreSQL default to READ_COMMITTED or REPEATABLE_READ, but this can vary.
The Four Isolation Levels
Level 1: READ UNCOMMITTED
Locking strategy: No shared locks acquired when reading. No exclusive locks for writing either.
Problems it solves: None.
Problems it allows:
- Dirty read — YES
- Non repeatable read — YES
- Phantom read — YES
Since there is absolutely no locking, any transaction can read any data that any other transaction has touched, whether committed or not. A transaction can change a row without any lock, so any concurrent reader sees the change immediately even before commit.
Why does this level even exist?
It is the highest concurrency mode. Many transactions can run in parallel with zero waiting. If your application is purely read only and the data is static enough that stale reads do not matter, READ_UNCOMMITTED maximizes throughput. It is extremely risky for anything involving writes.
java
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public List<Product> getProductsForAnalytics() {
// reading approximate data for analytics — occasional dirty read is acceptable
return productRepository.findAll();
}Level 2: READ COMMITTED
Locking strategy:
- Reading: Take a shared lock, release it immediately after reading (not held till transaction end)
- Writing: Take an exclusive lock, hold it until the transaction ends (commit or rollback)
Problems it solves: Dirty read.
Problems it allows:
- Non repeatable read — YES
- Phantom read — YES
Why does it solve dirty read but not the others?
Because the exclusive lock for writing is held until transaction end. Another transaction trying to read a row that has an exclusive lock on it is blocked — it cannot see uncommitted data. Once the write transaction commits, the exclusive lock is released and the read gets the committed value.
However, since shared locks (read locks) are released immediately after reading, another transaction can acquire an exclusive lock and commit a change between two reads by the same transaction. This allows non repeatable reads.
Timeline showing dirty read prevention:
| Time | Transaction A | Transaction B |
|---|---|---|
| T1 | UPDATE id=1 → BOOKED, acquires EXCLUSIVE lock | |
| T2 | READ id=1 → BLOCKED (exclusive lock held by B) | |
| T3 | ROLLBACK → exclusive lock released, id=1 = FREE | |
| T4 | READ id=1 → FREE (reads committed value) |
Transaction A never saw the uncommitted BOOKED value. Dirty read prevented.
Timeline showing non repeatable read still possible:
| Time | Transaction A | Transaction B |
|---|---|---|
| T1 | READ id=1 → FREE, shared lock acquired then released | |
| T2 | UPDATE id=1 → BOOKED, commits | |
| T3 | READ id=1 again → BOOKED |
Transaction A got different values for the same row in the same transaction. Non repeatable read.
java
@Transactional(isolation = Isolation.READ_COMMITTED)
public void bookingService(Long carId) {
Car car = carRepository.findById(carId).orElseThrow();
// safe from dirty reads — car status is always from a committed state
// but if you read car again later in this method, status might have changed
}Level 3: REPEATABLE READ
Locking strategy:
- Reading: Take a shared lock, hold it until the transaction ends
- Writing: Take an exclusive lock, hold it until the transaction ends
Problems it solves: Dirty read and non repeatable read.
Problems it allows:
- Phantom read — YES
Why does it solve non repeatable read?
Because the shared lock is held for the entire transaction. Once Transaction A reads a row and holds a shared lock on it, Transaction B cannot acquire an exclusive lock on that same row — it is blocked. So Transaction B cannot modify that row until Transaction A commits. No matter how many times Transaction A reads that row in the same transaction, it always gets the same value.
Timeline showing non repeatable read prevention:
| Time | Transaction A | Transaction B |
|---|---|---|
| T1 | READ id=1 → FREE, SHARED LOCK held | |
| T2 | UPDATE id=1 → BLOCKED (shared lock held by A) | |
| T3 | READ id=1 again → FREE (same value) | |
| T4 | Transaction A COMMITS → shared lock released | |
| T5 | Transaction B now acquires exclusive lock and updates |
Transaction A always read FREE. Non repeatable read prevented.
Why does it NOT solve phantom read?
Shared locks are placed on specific rows that were read. A new row inserted by another transaction does not have a shared lock on it. Transaction B can insert id=2 (a new row) and commit it, because there was no lock on a row that did not exist when Transaction A first read. When Transaction A runs the range query again, the new row falls within the range and appears.
java
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processBatch(Long userId) {
// read user data once
User user = userRepository.findById(userId).orElseThrow();
double balance = user.getBalance();
// ... multiple operations ...
// read user data again — guaranteed to get the same balance value
// even if time has passed, nobody can modify this row until this transaction ends
User userAgain = userRepository.findById(userId).orElseThrow();
// userAgain.getBalance() == balance — guaranteed
}Level 4: SERIALIZABLE
Locking strategy:
- Reading: Take a shared lock, hold it until transaction end
- Writing: Take an exclusive lock, hold it until transaction end
- Range queries: Apply a range lock — locks not just existing rows but also the gaps between them
Problems it solves: All three — dirty read, non repeatable read, and phantom read.
The key addition: Range Lock
When a transaction executes a range query like SELECT WHERE id > 0 AND id < 5, SERIALIZABLE does not just lock the rows that matched. It locks the entire range of values from 0 to 5. Any other transaction trying to insert a row with an id between 0 and 5 is blocked until the first transaction ends.
Timeline showing phantom read prevention:
| Time | Transaction A | Transaction B |
|---|---|---|
| T1 | SELECT WHERE id > 0 AND id < 5 → rows id=1, id=4 | |
| T1 | RANGE LOCK: shared lock on id=1, id=4 AND the range 0..5 | |
| T2 | INSERT id=2 → BLOCKED (range lock covers the gap) | |
| T3 | SELECT WHERE id > 0 AND id < 5 → still rows id=1, id=4 | |
| T4 | Transaction A COMMITS → range lock released | |
| T5 | Transaction B inserts id=2, commits |
Transaction A always got the same two rows. Phantom read prevented.
Trade off: Serializable has the lowest concurrency. Because range locks block insertions into ranges, many transactions that would have been able to run in parallel are now forced to wait. This is why serializable is used only when data integrity is critical and the cost of locking is acceptable.
java
@Transactional(isolation = Isolation.SERIALIZABLE)
public void generateFinancialReport(Long accountId) {
// absolutely no other transaction can change data in the range this query covers
// while this report is being generated
List<Transaction> txns = transactionRepository
.findByAccountIdAndDateBetween(accountId, startDate, endDate);
// guaranteed to see exactly the same rows for the duration of this transaction
}Concurrency vs Correctness Trade off
| Isolation Level | Dirty Read | Non Repeatable Read | Phantom Read | Concurrency |
|---|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible | Highest |
| READ COMMITTED | Prevented | Possible | Possible | High |
| REPEATABLE READ | Prevented | Prevented | Possible | Medium |
| SERIALIZABLE | Prevented | Prevented | Prevented | Lowest |
Higher isolation means fewer concurrency problems but lower throughput because transactions wait for each other more often.
Where Does the Locking Code Actually Live?
You might wonder: when I write a SELECT query in Spring, where does the code that takes a shared lock come from?
The answer is: it lives in the database's transaction manager (not the application's PlatformTransactionManager).
In your application you only write:
java
@Transactional(isolation = Isolation.READ_COMMITTED)
public User findUser(Long id) {
return userRepository.findById(id).orElseThrow();
// translates to: SELECT * FROM users WHERE id = ?
}When Spring sends this SELECT to the database with READ_COMMITTED isolation:
- The database's internal transaction manager applies the locking rules
- It acquires a shared lock, executes the read, and releases the lock immediately
- All of this is invisible to your application code
Your application code specifies the isolation level. The database enforces it. Spring acts as the bridge, passing your isolation preference to the database connection.
Choosing the Right Isolation Level: A Decision Framework
When asked in an interview or when making a real architecture decision, use this reasoning:
Is non repeatable read a problem for this operation?
- If NO → use
READ_COMMITTED(high concurrency, protects from dirty reads) - If YES → go to step 2
- If NO → use
Is phantom read a problem for this operation?
- If NO → use
REPEATABLE_READ(protects from non repeatable reads too) - If YES → use
SERIALIZABLE(protects from all three, but lowest concurrency)
- If NO → use
Is the data read only and approximate values acceptable?
- If YES → consider
READ_UNCOMMITTEDfor maximum throughput (analytics, dashboards)
- If YES → consider
For most transactional business operations, you will choose between READ_COMMITTED and REPEATABLE_READ.
Complete Example: Isolation in a Booking System
java
@Service
public class CabBookingService {
@Autowired
private CabRepository cabRepository;
/**
* READ_COMMITTED: good for initial availability check.
* We accept that the status might change after we read it.
* The actual booking below uses a stronger isolation.
*/
@Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true)
public List<Cab> getAvailableCabs() {
return cabRepository.findByStatus("AVAILABLE");
}
/**
* REPEATABLE_READ: we read the cab status, validate, then update.
* We must ensure the status does not change between our read and our update.
* Non repeatable read would cause us to update a cab that was already booked.
*/
@Transactional(isolation = Isolation.REPEATABLE_READ)
public BookingResult bookCab(Long cabId, Long userId) {
// First read: acquire shared lock, held until transaction ends
Cab cab = cabRepository.findById(cabId).orElseThrow();
if (!"AVAILABLE".equals(cab.getStatus())) {
return BookingResult.failure("Cab not available");
}
// Between this read and the update, NO other transaction can change
// this cab's status because we hold a shared lock on this row
// Convert to exclusive lock for update
cab.setStatus("BOOKED");
cab.setUserId(userId);
cabRepository.save(cab);
return BookingResult.success(cab);
// shared lock released on commit
}
/**
* SERIALIZABLE: generating a report of all bookings in a date range.
* We need consistent counts throughout the report generation.
* No new bookings should appear while we are counting.
*/
@Transactional(isolation = Isolation.SERIALIZABLE, readOnly = true)
public BookingReport generateReport(LocalDate from, LocalDate to) {
// Range lock applied — no new rows in this date range can be inserted
// until this transaction ends
List<Booking> bookings = bookingRepository.findByDateBetween(from, to);
return new BookingReport(bookings);
}
}Summary
| Concept | Key Point |
|---|---|
| Isolation Level | Controls how one transaction's changes are visible to others |
| Dirty Read | Reading uncommitted data from another transaction |
| Non Repeatable Read | Same row reads return different values within one transaction |
| Phantom Read | Same range query returns different row counts within one transaction |
| Shared Lock | Multiple readers allowed; no writers allowed |
| Exclusive Lock | One writer only; no readers or other writers allowed |
| READ UNCOMMITTED | No locks; fastest; all three problems possible |
| READ COMMITTED | Shared lock released on read; prevents dirty reads |
| REPEATABLE READ | Shared lock held till end; prevents dirty and non repeatable reads |
| SERIALIZABLE | Range lock added; prevents all three; lowest concurrency |
Interview Questions
Q1: What is an isolation level and why does it matter?
An isolation level defines how the changes made by one transaction are visible to other transactions running concurrently. It matters because without proper isolation, parallel transactions can interfere with each other, causing incorrect data reads. The choice of isolation level is a trade off between correctness and performance (concurrency).
Q2: What are the three concurrency problems in database transactions?
Dirty read: reading uncommitted data from another transaction that might roll back. Non repeatable read: reading the same row twice in one transaction and getting different values because another transaction committed a change. Phantom read: running the same range query twice and getting different row counts because another transaction inserted or deleted rows.
Q3: What is the difference between READ COMMITTED and REPEATABLE READ?
READ COMMITTED acquires a shared lock when reading but releases it immediately after the read. This prevents dirty reads but allows non repeatable reads, because another transaction can update and commit between your two reads. REPEATABLE READ holds the shared lock until the transaction ends, preventing any other transaction from modifying the locked row during your transaction. This prevents both dirty reads and non repeatable reads, but phantom reads are still possible.
Q4: How does SERIALIZABLE prevent phantom reads?
SERIALIZABLE applies a range lock in addition to row level shared locks. When a transaction runs a range query, the database locks not just the rows that match but also the gaps within the range. This prevents other transactions from inserting new rows into that range, so the row count remains consistent across multiple reads of the same query within the transaction.
Q5: What is a shared lock and what is an exclusive lock?
A shared lock (read lock) is acquired when reading. Multiple transactions can hold shared locks on the same row simultaneously, allowing parallel reads. An exclusive lock (write lock) is acquired when writing. Only one transaction can hold an exclusive lock, and it blocks all other transactions from both reading and writing that row.
Q6: Can a transaction acquire an exclusive lock if another transaction holds a shared lock?
No. An exclusive lock can only be acquired when no other transaction holds any lock (shared or exclusive) on the row. If a shared lock is held, exclusive lock requests are blocked until the shared lock is released.
Q7: What is the default isolation level in Spring Boot?
Isolation.DEFAULT, which delegates to the database's configured default. Most relational databases use READ_COMMITTED as the default, but this varies. MySQL InnoDB uses REPEATABLE_READ as its default. Always verify the default for your specific database and set the isolation level explicitly when your application requires specific behavior.
Q8: Why does READ UNCOMMITTED exist if it causes all three concurrency problems?
READ UNCOMMITTED offers the highest concurrency — no transaction is ever blocked waiting for a lock. It is useful for purely read only scenarios where approximate data is acceptable, such as analytics dashboards or background reporting where occasional stale or dirty data does not affect business decisions.
Q9: Where is the locking logic actually implemented?
The locking logic lives in the database's transaction manager, not in your application code or Spring's PlatformTransactionManager. When Spring executes a SQL query, it tells the database connection which isolation level to use. The database then applies the appropriate locking strategy internally. Your application code only specifies the isolation level through @Transactional(isolation = ...) — the database enforces it.
Q10: When should you choose REPEATABLE READ over READ COMMITTED?
Choose REPEATABLE READ when your transaction reads a row and later makes a decision or performs an update based on that reading. If another transaction could change the row between your first read and your decision, you would be acting on stale data. Examples include booking systems (read availability, then book), inventory management (read stock count, then decrement), and any optimistic or pessimistic concurrency control scenario where row consistency within a transaction is required.