Transaction propagation and isolation, in depth
The data-access module introduced @Transactional — commit on return, roll back on an unchecked exception.
This lesson goes deeper into the two settings that decide how transactions behave under real conditions:
propagation (what happens when a transactional method calls another) and isolation (what one
transaction sees of another's uncommitted work). These are where transaction behaviour gets subtle, and
where production bugs hide. This opens the concurrency module because everything in it rests on
understanding transactions properly.
Propagation: transactions calling transactions
Propagation decides what happens when a @Transactional method is called from within another
transaction. The default — REQUIRED — is the one to understand first:
REQUIRED(default) — join the caller's transaction if one exists, otherwise start a new one. So ifserviceA(transactional) callsserviceB(transactional), they run in one transaction: ifBfails, the whole thing rolls back, includingA's work. This is usually what you want, and why it is the default.
The others, and when they matter:
REQUIRES_NEW— always start a new, independent transaction, suspending the caller's. The inner transaction commits or rolls back on its own, regardless of the outer. Use it when an operation must persist even if the caller rolls back — the classic case is an audit log: you want the "attempted X" record saved even when X fails and rolls back. Put the audit write in aREQUIRES_NEWmethod.NESTED,SUPPORTS,MANDATORY,NEVER— rarer;SUPPORTS(join if present, else non-transactional),MANDATORY(must be called within a transaction, else error),NEVER(must not be).
The trap: assuming a called method rolls back independently when it is actually REQUIRED and joined the
outer transaction — so its "committed" work is undone when the outer fails. And the reverse: expecting the
outer to roll back the inner's work when the inner is REQUIRES_NEW (it will not — the inner already
committed independently). Know that the default is "one transaction for the whole call tree" and reach for
REQUIRES_NEW deliberately, for the audit-log-style case.
Isolation: what one transaction sees of another
Isolation decides how visible one transaction's uncommitted changes are to another running concurrently. It is a trade-off between correctness and concurrency, expressed as levels, each preventing more anomalies at more cost:
| Level | Prevents | The anomaly it allows |
|---|---|---|
READ_UNCOMMITTED |
(little) | Dirty reads — seeing another transaction's uncommitted changes |
READ_COMMITTED |
dirty reads | Non-repeatable reads — a row you read changes if you read it again |
REPEATABLE_READ |
+ non-repeatable reads | Phantom reads — new rows appear in a repeated range query |
SERIALIZABLE |
everything | (slowest — transactions effectively run one at a time) |
The anomalies, concretely: a dirty read sees data that may be rolled back (reading a lie); a
non-repeatable read means reading the same row twice in one transaction gives different values (someone
committed a change between); a phantom read means a WHERE-range query run twice returns different rows
(someone inserted a matching row). Higher isolation prevents more of these but reduces concurrency (more
locking, more conflicts).
The default is the database's default — for PostgreSQL and most, READ_COMMITTED, which is the sensible
balance for typical web apps: no dirty reads, decent concurrency. You set it only when a specific operation
needs more:
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void reconcile() { ... } // needs consistent repeated reads within the transaction
Raise isolation for the rare operation that genuinely needs stronger guarantees (a financial reconciliation
reading the same data multiple times); leave the default for everything else. Do not raise isolation
globally to "be safe" — SERIALIZABLE everywhere cripples concurrency and causes serialization-failure
retries.
Isolation is not the whole answer to the lost update
A crucial point that sets up the next lesson: READ_COMMITTED does not prevent the lost-update problem.
Two transactions each read a value, each compute a new value, each write — the second overwrites the first,
and no isolation level below SERIALIZABLE stops it by itself, because each write is individually valid.
Raising isolation to SERIALIZABLE would (at the cost of failed transactions to retry), but the better,
targeted tools are locking (optimistic @Version or pessimistic) — the next lesson. So: isolation
handles read anomalies (dirty/non-repeatable/phantom reads); the lost update on concurrent writes needs
locking, not just isolation. Confusing the two — thinking READ_COMMITTED protects you from lost updates —
is a real source of silent data corruption under load.
Keep transactions short, and mind the boundaries
Two practical reinforcements from the data-access lesson, sharpened by concurrency:
- Short transactions. A transaction holds a database connection (and any locks) for its whole duration. A transactional method that calls a slow external API holds all that across the call — under load, that exhausts the connection pool and increases lock contention. Do slow, non-database work outside the transaction.
- The proxy and self-invocation (from
@Transactional's mechanics): propagation only applies across bean boundaries. AREQUIRES_NEWmethod called from another method of the same class does not get a new transaction — the proxy is bypassed. Call across beans.
The mental model to carry into the module: propagation controls how nested transactional calls combine (default: one transaction), isolation controls what concurrent transactions see of each other (default: READ_COMMITTED, raise rarely), and neither alone stops the lost update — that needs locking.
Check your work
Propagation. How a transactional call combines with an existing transaction. REQUIRED (default): join
the caller's, or start one — one transaction for the call tree, all-or-nothing. REQUIRES_NEW: independent
transaction (commits regardless of the caller) — for audit-log-style writes that must survive a caller
rollback.
Isolation. What concurrent transactions see of each other's uncommitted work: READ_UNCOMMITTED (dirty
reads) → READ_COMMITTED (default; no dirty reads) → REPEATABLE_READ (no non-repeatable reads) →
SERIALIZABLE (all, slowest). Raise rarely and per-operation; do not globally SERIALIZABLE.
Isolation ≠ lost-update protection. READ_COMMITTED does not stop the lost update on concurrent writes;
that needs locking (next lesson), not higher isolation. Confusing them causes silent corruption.
Keep it short + boundaries. Short transactions (hold connection/locks briefly; slow work outside); the proxy means propagation applies only across bean boundaries (self-invocation bypasses it).
Practice
- Have transactional
serviceAcall transactionalserviceB(bothREQUIRED); makeBthrow and confirmA's work also rolls back — one transaction. - Change
BtoREQUIRES_NEW, make the outer fail afterBcommitted, and confirmB's work survives — the audit-log case. - Set an operation to
REPEATABLE_READand reason about which read anomaly it now prevents versus theREAD_COMMITTEDdefault. - Reason (or demonstrate with two sessions) why
READ_COMMITTEDstill allows a lost update on concurrent read-modify-write — motivating the locking lesson. - Put a slow call inside a transaction and reason about connection-pool/lock impact under load; move it outside.
- Call a
REQUIRES_NEWmethod via self-invocation and observe it does not get a new transaction; fix by moving across beans.
Official documentation
- Spring — Transaction propagation —
REQUIRED,REQUIRES_NEW, and the rest. - Spring — @Transactional isolation — Setting isolation levels.
- PostgreSQL — Transaction isolation — The anomalies and levels, from the database's side.
Next: optimistic and pessimistic locking, and the lost-update race.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship