Skip to content

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:

TimeTransaction ATransaction BDB State (id=123)
T1BEGINBEGINstatus = FREE
T2UPDATE status to BOOKEDstatus = BOOKED (uncommitted)
T3READ id=123 → gets BOOKED
T4ROLLBACKstatus = FREE (reverted)
T5Proceeds 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:

TimeTransaction ADB State (id=1)
T1BEGINstatus = FREE
T2READ id=1 → status = FREE
T3(Transaction B updates and COMMITS)status = BOOKED
T4READ 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:

TimeTransaction ADB State
T1BEGINRows: id=1, id=4
T2SELECT WHERE id > 0 AND id < 5 → 2 rows
T3(Transaction B inserts id=2 and COMMITS)Rows: id=1, id=2, id=4
T4SELECT 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 RequestExclusive Lock Request
Shared Lock HeldAllowedBlocked
Exclusive Lock HeldBlockedBlocked
No Lock HeldAllowedAllowed

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.SERIALIZABLE

There 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:

TimeTransaction ATransaction B
T1UPDATE id=1 → BOOKED, acquires EXCLUSIVE lock
T2READ id=1 → BLOCKED (exclusive lock held by B)
T3ROLLBACK → exclusive lock released, id=1 = FREE
T4READ id=1 → FREE (reads committed value)

Transaction A never saw the uncommitted BOOKED value. Dirty read prevented.

Timeline showing non repeatable read still possible:

TimeTransaction ATransaction B
T1READ id=1 → FREE, shared lock acquired then released
T2UPDATE id=1 → BOOKED, commits
T3READ 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:

TimeTransaction ATransaction B
T1READ id=1 → FREE, SHARED LOCK held
T2UPDATE id=1 → BLOCKED (shared lock held by A)
T3READ id=1 again → FREE (same value)
T4Transaction A COMMITS → shared lock released
T5Transaction 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:

TimeTransaction ATransaction B
T1SELECT WHERE id > 0 AND id < 5 → rows id=1, id=4
T1RANGE LOCK: shared lock on id=1, id=4 AND the range 0..5
T2INSERT id=2 → BLOCKED (range lock covers the gap)
T3SELECT WHERE id > 0 AND id < 5 → still rows id=1, id=4
T4Transaction A COMMITS → range lock released
T5Transaction 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 LevelDirty ReadNon Repeatable ReadPhantom ReadConcurrency
READ UNCOMMITTEDPossiblePossiblePossibleHighest
READ COMMITTEDPreventedPossiblePossibleHigh
REPEATABLE READPreventedPreventedPossibleMedium
SERIALIZABLEPreventedPreventedPreventedLowest

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:

  1. 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
  2. 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)
  3. Is the data read only and approximate values acceptable?

    • If YES → consider READ_UNCOMMITTED for maximum throughput (analytics, dashboards)

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

ConceptKey Point
Isolation LevelControls how one transaction's changes are visible to others
Dirty ReadReading uncommitted data from another transaction
Non Repeatable ReadSame row reads return different values within one transaction
Phantom ReadSame range query returns different row counts within one transaction
Shared LockMultiple readers allowed; no writers allowed
Exclusive LockOne writer only; no readers or other writers allowed
READ UNCOMMITTEDNo locks; fastest; all three problems possible
READ COMMITTEDShared lock released on read; prevents dirty reads
REPEATABLE READShared lock held till end; prevents dirty and non repeatable reads
SERIALIZABLERange 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.