Picture a bank account row in your database. Two requests land at the same instant — one reads the balance to show it on a screen, the other is halfway through moving money out of it. What should the first request see? The old balance? The new one? A half-finished number that never really existed?
That question is what a transaction isolation level answers. It is a dial that decides how much one in-flight transaction is allowed to see of another in-flight transaction that touches the same data. You meet it the moment your app has more than one user, because then more than one transaction runs at once, and they start stepping on each other.
Spring gives you a single, small lever over this dial. This article builds up what the dial actually does, then shows exactly what Spring controls — and, just as important, what it does not.
First, what a transaction guarantees on its own
A transaction is a group of database operations that either all take effect or none do. Inside one transaction, you can read a row, change it, read it again, and the outside world sees nothing until you commit.
That is a clean story when transactions run one after another. The trouble is that a real server runs many at once, and they overlap in time. While your transaction is open, other transactions are opening, writing, and committing against the very same rows.
Isolation is the rulebook for that overlap. So before we can talk about levels, we need to see the specific ways that overlap can go wrong.
The three ways concurrent reads go wrong
There are three classic anomalies. They are worth naming carefully, because the isolation levels are defined entirely in terms of which of these they forbid.
The first is a dirty read. Your transaction reads a row that another transaction has changed but not yet committed. If that other transaction then rolls back, you have read a value that never officially existed.
// Transaction B writes but has NOT committed:
UPDATE account SET balance = 500 WHERE id = 1; // was 100
// Transaction A reads right now and sees 500.
// Then B rolls back. A acted on a number that was never real.
The read was "dirty" because it saw uncommitted work. That is the most dangerous anomaly, and most systems forbid it by default.
The second is a non-repeatable read. Your transaction reads the same row twice and gets two different values, because another transaction committed a change in between.
// Transaction A:
SELECT balance FROM account WHERE id = 1; // reads 100
// Transaction B commits: balance is now 500.
SELECT balance FROM account WHERE id = 1; // reads 500 — same query, new answer
Nothing here is dirty; both values were committed. The problem is that A cannot trust a value to hold still for the length of its own transaction.
The third is a phantom read. This time you re-run a query that matches a set of rows, and the set changes size because another transaction inserted or deleted a matching row.
// Transaction A:
SELECT count(*) FROM account WHERE balance > 100; // 3 rows
// Transaction B inserts a new row with balance = 900 and commits.
SELECT count(*) FROM account WHERE balance > 100; // 4 rows — a "phantom" appeared
A non-repeatable read is about a row you already saw changing value. A phantom read is about new rows appearing (or vanishing) in a range you already queried. Keep that distinction in mind — the levels treat them separately.
The four isolation levels
Now the payoff. Each isolation level is just a promise about which of those three anomalies cannot happen. They stack: each stronger level forbids everything the weaker ones forbid, and one more.
- READ UNCOMMITTED — allows all three. You can even see uncommitted (dirty) data. Fastest, and almost never what you want.
- READ COMMITTED — forbids dirty reads. You only ever see committed data, but a row can still change between two reads. This is the default in PostgreSQL, Oracle, and SQL Server.
- REPEATABLE READ — also forbids non-repeatable reads. A row you have read will keep its value for the rest of your transaction. This is MySQL's default.
- SERIALIZABLE — forbids all three, phantoms included. The database behaves as if transactions ran one at a time, in some order. Safest, and slowest.
The pattern is a trade. As you climb, you rule out more surprises, but the database has to do more locking or more bookkeeping, so concurrency drops. Higher isolation buys correctness with throughput.
That is the whole concept. Everything Spring does sits on top of it.
Where Spring enters: one annotation attribute
You mark a method transactional with @Transactional. That is the annotation that tells Spring "wrap this method in a database transaction — begin before it runs, commit if it returns, roll back if it throws."
The isolation level is one attribute on that annotation:
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void transfer(Long from, Long to, BigDecimal amount) {
// every SQL statement in here runs at REPEATABLE_READ
}
Isolation is a Spring enum, and it mirrors the levels above one for one: READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE, plus one more we will get to — DEFAULT.
So from the developer's seat, choosing an isolation level in Spring is exactly this: pick a value for one attribute. That is the entire surface area. Which raises the real question — what does Spring do with it?
Spring sets the dial; the database does the work
Here is the part that surprises people. Spring does not implement isolation. It does not prevent a single dirty read on its own. All it does is pass your choice down to the database.
Under the hood, a Spring transaction runs on a JDBC Connection — the object that represents your app's open line to the database. That connection has a method for exactly this purpose:
// Roughly what Spring's transaction manager does when it opens the transaction:
connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
That single JDBC call is the whole mechanism. Spring translates your Isolation.REPEATABLE_READ into the matching JDBC constant and sets it on the connection before your method's SQL runs. From there, enforcing the level is entirely the database's job.
This is why the framing "what Spring controls" matters. Spring controls the request. It picks up your annotation, opens a connection, and dials the level in. The actual guarantees — what a dirty read or a phantom means in practice — come from the database engine, not from Spring.
When your transaction finishes, Spring is careful to set the connection's isolation back to what it was before, because that connection goes back into a pool and will be reused by the next transaction. You do not have to manage that reset; the transaction manager does it for you.
Isolation.DEFAULT, and why it is the sensible default
The extra enum value, Isolation.DEFAULT, means "do not call setTransactionIsolation at all — just use whatever the database is already configured to use."
@Transactional // isolation defaults to Isolation.DEFAULT
public void placeOrder(Order order) { ... }
If you write @Transactional with no isolation attribute, you get DEFAULT. So your transaction runs at the database's own default level — READ COMMITTED on Postgres, REPEATABLE READ on MySQL. This is the right choice the vast majority of the time. You only reach for an explicit level when a specific piece of logic genuinely needs a stronger guarantee.
The gotcha: isolation is ignored when you join an existing transaction
This is the trap the mechanism creates, and it catches people who assume the annotation always wins.
Spring transactions can nest by participating. With the default propagation, if a transactional method calls another transactional method, the inner one does not start a new transaction — it joins the one already running. And a transaction's isolation level is fixed the moment it begins, on that one connection. You cannot change it midway.
So consider this:
@Transactional(isolation = Isolation.READ_COMMITTED)
public void outer() {
inner(); // joins outer's transaction
}
@Transactional(isolation = Isolation.SERIALIZABLE)
public void inner() {
// runs at READ_COMMITTED — outer's level — NOT serializable
}
Because inner() joins outer()'s existing transaction, its SERIALIZABLE request is quietly ignored. The transaction is already open at READ COMMITTED, and that is what inner() gets. The annotation reads like a firm instruction, but on a participating call it is more of a wish.
Spring can be told to shout instead of ignore. If you set the transaction manager's validateExistingTransaction flag on, Spring will throw an exception when an inner method asks for an isolation level that conflicts with the transaction it is joining, rather than silently running at the outer level. By default that flag is off, so the silent case above is what you get out of the box.
The clean way to actually get a different isolation level is to make the inner method start its own transaction — propagation REQUIRES_NEW — which opens a fresh connection that Spring can dial independently. But that is a separate, heavier decision, because a brand-new transaction commits on its own and no longer shares the outer one's fate.
Each database reads the levels its own way
One more thing Spring does not smooth over: the four level names are a SQL standard, but databases implement them with real differences.
The sharpest example is REPEATABLE READ. The standard only requires it to stop non-repeatable reads, leaving phantoms allowed. But MySQL's InnoDB engine, at REPEATABLE READ, also blocks most phantom reads through its snapshot mechanism. PostgreSQL does not even offer a distinct REPEATABLE READ that permits phantoms — its REPEATABLE READ is a full snapshot that rules them out too.
@Transactional(isolation = Isolation.REPEATABLE_READ)
public BigDecimal report() {
// On MySQL and Postgres this behaves more strictly than
// the SQL standard's bare minimum. On another engine it may not.
}
The lesson is not to memorize every engine. It is to remember where the boundary sits: the same isolation value can mean different things on different databases, because Spring only forwards the name — the behavior belongs to the engine. When a level matters enough to set explicitly, check what your specific database promises for it.
There is also a plainer failure mode. Some databases or drivers do not support every level. If you ask for one the database cannot honor, the setTransactionIsolation call fails, and your transaction fails to start. Spring surfaces that as an exception rather than silently downgrading, which is the safe behavior — you would rather know than believe you have a guarantee you do not.
What to actually reach for
Put the pieces together and the practical picture is simple.
Most of the time, leave isolation alone. @Transactional with Isolation.DEFAULT runs at your database's default — usually READ COMMITTED — and that is a sound choice for ordinary create-read-update work.
Raise the level only for a specific transaction that genuinely needs a steadier view of the data: a multi-step calculation that must not see rows shift underneath it, or money-movement logic where a phantom row would corrupt a total. And when you do raise it, remember the two edges — it is silently ignored on a participating call, and its exact meaning depends on the database underneath.
Spring's role in all of this is narrow and worth stating plainly: it reads one annotation attribute, sets one value on one JDBC connection at the right moment, and restores it afterward. The isolation guarantees themselves live in the database. Spring just makes sure the right request reaches it.









