@Transactional in Spring: Propagation, Isolation, and the Six Ways It Silently Does Nothing
All seven propagation values with runnable proof and transaction-manager logs, the rollback rules, and why catching an inner failure still ends in UnexpectedRollbackException. Plus six ways @Transactional is present, correct, and inert — two of which commit work the code was trying to abandon — and why Propagation.NESTED cannot be used with JpaTransactionManager at all. Spring Boot 4.1.1, JDK 25.
Spring Boot 4.1.1 · Spring Framework 7.0.9 · Hibernate ORM · H2 · JDK 25. Every row of the propagation matrix below was produced by calling the method and asking the transaction manager what it did.@Transactional is one annotation with twelve attributes, and the interesting part is that it can be present, correct, on a public method of a real Spring bean, and still do nothing at all. Not fail — do nothing. The method runs, the data is written, the annotation is inert, and there is no log line to find because from Spring’s point of view nothing went wrong.
There are six distinct ways to arrive there, two of which are worse than doing nothing: they start a transaction and then commit work the code was actively trying to abandon.
Part
For you if
Covers
1 — Beginner
you annotate service methods and move on
what the annotation does, logical vs physical transactions, the log to turn on
2 — Intermediate
you have seen UnexpectedRollbackException
all seven propagations with real output, rollback rules, why catching the exception does not help
3 — Advanced
a transaction is not doing what you wrote
the six silent failures, NESTED on JPA, isolation settings that are ignored
Versions this was verified against. Spring Boot 4.1.1 (GA), Spring Framework 7.0.9, H2 in-memory, Eclipse Temurin JDK 25.0.4.1 LTS, with Spring Boot’s default JpaTransactionManager. Behaviour claims are pinned by 19 contract tests so that a future Spring version changing one of them fails a build rather than quietly making this article wrong.
Companion code: sdjpa4-demo, module transactions/. Six captured transcripts, regenerated by scripts/run-all.sh.
Part 1 — What the annotation actually does
@Transactional is an AOP proxy. When a method carrying it is called through the proxy, an interceptor:
asks the PlatformTransactionManager for a transaction, according to the propagation rule;
invokes the target method;
on a normal return, commits;
on a RuntimeException or Error, rolls back;
on a checked Exception, commits and rethrows.
Step 5 is not a typo, and it is where Part 3 starts.
Logical and physical transactions
The distinction that makes propagation comprehensible:
A logical transaction is one @Transactional scope — one annotated method call.
A physical transaction is one real database transaction: one connection, one BEGIN, one COMMIT.
REQUIRED maps many logical scopes onto one physical transaction. REQUIRES_NEW gives each scope its own. Every other propagation value is a variation on that theme.
The transaction name is the tell
TransactionSynchronizationManager is public API and answers this anywhere in application code:
TransactionSynchronizationManager.isActualTransactionActive(); // a PHYSICAL one?
TransactionSynchronizationManager.getCurrentTransactionName(); // whose scope started it?
TransactionSynchronizationManager.isCurrentTransactionReadOnly();
TransactionSynchronizationManager.getCurrentTransactionIsolationLevel();
The name is the most useful and least known of these. A method that joined its caller’s transaction reports the caller’s name; a method that started its own reports its own. That single string distinguishes joining from starting without reading any documentation, and it is what makes the table in Part 2 readable.
Creating new transaction with name [...OuterService.serializableScope]:
PROPAGATION_REQUIRED,ISOLATION_SERIALIZABLE
Participating in existing transaction
Initiating transaction commit
Creating new transaction versus Participating in existing transaction answers most questions people ask about propagation, in the transaction manager’s own words.
Method visibility.@Transactional works on public methods, and since Spring 6.0 also on protected and package-private ones when the proxy is class-based — which is Spring Boot’s default. It never works on private methods. With interface-based proxies the method must be public and declared on the interface.
Part 2 — Seven propagations, and the exception from nowhere
The matrix
Each inner method called twice: once from a @Transactional caller and once from a plain one.
PROPAGATION CALLER ACTIVE TRANSACTION NAME / OUTCOME
----------------------------------------------------------------------------
REQUIRED inside @Transactional True inTransaction
REQUIRED no transaction True required
REQUIRES_NEW inside @Transactional True requiresNew
REQUIRES_NEW no transaction True requiresNew
NESTED inside @Transactional -- NestedTransactionNotSupportedException
NESTED no transaction True nested
SUPPORTS inside @Transactional True inTransaction
SUPPORTS no transaction False supports
NOT_SUPPORTED inside @Transactional False notSupported
NOT_SUPPORTED no transaction False notSupported
MANDATORY inside @Transactional True inTransaction
MANDATORY no transaction -- IllegalTransactionStateException
NEVER inside @Transactional -- IllegalTransactionStateException
NEVER no transaction False never
Read the name column. REQUIRED inside a transaction reports inTransaction — the caller’s method — because it joined. REQUIRES_NEW reports its own name, because it started a second physical transaction.
Four things in that table are worth saying out loud:
SUPPORTS with no transaction is not “no writes”. The method still runs and still writes; the write simply lands on an auto-commit connection with no rollback available. It is a poor default for anything that mutates.
NOT_SUPPORTED suspends rather than declines. The caller’s transaction is set aside and restored afterwards, and its connection is held open for the duration.
REQUIRES_NEW needs two connections at once. The outer transaction keeps its connection while the inner one takes another. A pool sized to the number of request threads will deadlock under load; size it to exceed concurrent threads by at least one per nesting level.
MANDATORY is an assertion. Put it on a helper that must never be called outside a transaction and a silent correctness bug becomes a loud exception.
Does the inner work survive the caller’s rollback?
REQUIRED inner, outer rolls back rows: 0 inner work rolled back
REQUIRES_NEW inner, outer rolls back rows: 1 inner work SURVIVED
That is the entire reason REQUIRES_NEW exists. Audit rows, outbox entries and “we tried and it failed” records need to survive the failure that produced them, and only an independent physical transaction can do that.
UnexpectedRollbackException
REQUIRED inner throws, outer catches it
outcome : UnexpectedRollbackException
message : Transaction silently rolled back because it has been marked as rollback-only
rows : 0
REQUIRES_NEW inner throws, outer catches it
outcome : returned normally
The sequence behind the first row:
The outer method starts a transaction and calls an inner REQUIRED method.
The inner method throws. Its interceptor sees a RuntimeException — but it is participating in the caller’s transaction, so it cannot roll back on its own. It sets rollback-only on the shared transaction and rethrows.
The outer method catches the exception and returns normally, believing it has handled the failure.
The outer interceptor tries to commit. The transaction is marked rollback-only, so it rolls back and throws UnexpectedRollbackException.
If you must catch and continue, the inner call has to be REQUIRES_NEW. No amount of exception handling in the caller fixes a REQUIRED inner scope, because the damage is a flag set on a transaction the caller shares. This is the single most useful thing to know about propagation, and it is why UnexpectedRollbackException shows up in production with a stack trace pointing at a commit rather than at the code that failed.
What triggers a rollback
By default: RuntimeException and Error. Not checked exceptions.
@Transactional(rollbackFor = Exception.class) // roll back on checked too
@Transactional(noRollbackFor = NotFoundException.class) // and the reverse
The default is inherited from EJB and surprises almost everyone once.
Spring 7 lets you change that default globally.@EnableTransactionManagement gained a rollbackOn() attribute taking a new RollbackOn enum with two constants, RUNTIME_EXCEPTIONS (the historical behaviour) and ALL_EXCEPTIONS:
Note where it lives: on the configuration annotation, not on @Transactional. Disassembling org.springframework.transaction.annotation.Transactional in spring-tx 7.0.9 shows twelve attributes and no rollbackOn among them — per-method control is still rollbackFor/noRollbackFor. If checked exceptions committing has ever bitten you, this is the switch to flip once rather than the annotation to remember on every method.
Part 3 — Six silent failures, and two things that are not true
Row 0 below is the control: the same annotated method reached through the proxy reports actualTransactionActive=true. The mechanism works. These six do not use it.
A call the object makes to itself never leaves it, so the proxy is not in that call path. Same class, same annotation, no transaction. The fix is to move the method to another bean; the alternatives (self-injection, AopContext.currentProxy()) work and are worse. This is the identical mechanism described at length in the Spring AOP article.
2. A private method
A class-based proxy advises by overriding, and private methods cannot be overridden.
3. A checked exception commits
@Transactional
public void checkedExceptionCommits(String id) throws Exception {
accounts.save(new Account(id, 999));
throw new Exception("checked -- this does NOT trigger rollback");
}
The row is still there afterwards. The exception propagated to the caller and the transaction committed on the way out.
4. Swallowing the exception
@Transactional
public void swallowsException(String id) {
accounts.save(new Account(id, 555));
try { throw new IllegalStateException("something went wrong"); }
catch (RuntimeException ex) { /* handled */ }
}
Nothing propagates, so the interceptor sees a normal return and commits. The write survives the failure the code appeared to handle.
Failures 3 and 4 are the dangerous pair. The other four fail to start a transaction, which usually shows up as a data problem eventually. These two start one and commit work the code was trying to abandon. A half-finished operation that the code explicitly decided to discard is now durable, and every downstream consumer will treat it as intentional.
5. Called from @PostConstruct
The proxy does not exist while the bean is still initialising, so there is nothing to intercept the call. Move the work to an ApplicationReadyEvent listener.
Drop it into the method you believe is transactional. If it prints false, you have one of these six, and no amount of reasoning about propagation will help.
NESTED does not work with JPA
NESTED is described everywhere as “uses savepoints, so the inner scope can roll back without taking the outer transaction with it”. That description is accurate and, on a stock Spring Boot JPA application, unreachable.
Attempt 1 — a normal Spring Boot JPA application:
NestedTransactionNotSupportedException:
Transaction manager does not allow nested transactions by default -
specify 'nestedTransactionAllowed' property with value 'true'
A clear message naming the fix. So do what it says.
Attempt 2 — setNestedTransactionAllowed(true):
NestedTransactionNotSupportedException:
JpaDialect does not support savepoints - check your JPA provider's capabilities
A different message, from a second check, pointing at your JPA provider rather than at your configuration. It is not the provider. JpaTransactionManager obtains the savepoint manager from the object the JpaDialect returns when it begins the transaction, and Hibernate’s does not implement one — confirmed by disassembling JpaTransactionManager$JpaTransactionObject, where the second check is getEntityManagerHolder().getSavepointManager() == null. No configuration clears it.
There is a trap inside the trap. Constructing JpaTransactionManager by hand to set that flag also discards the JpaDialect Spring Boot would have supplied from the Hibernate vendor adapter, leaving the no-op DefaultJpaDialect. That produces the same second message for an entirely different reason — and sends you off investigating your database instead of your @Bean method. I lost a build cycle to exactly this before disassembling the class settled it.
Attempt 3 — the same propagation on a JDBC transaction manager:
Two rows visible inside the nested scope; one after it rolls back; one after the outer transaction commits — and the survivor is the outer row. Savepoint semantics working exactly as advertised.
A savepoint is a JDBC concept. DataSourceTransactionManager holds the JDBC connection and can issue one; JpaTransactionManager holds an EntityManager and cannot. The reference documentation does say NESTED works with JDBC resource transactions. What it does not say is that the JPA path fails twice, and that the second message blames your database.
Use REQUIRES_NEW instead. It solves most of what people reach for NESTED to solve, at the cost of a second connection. The genuine difference: REQUIRES_NEW commits the inner work even if the outer transaction later fails, where NESTED would have discarded it.
The inner method declares @Transactional(isolation = READ_UNCOMMITTED) and gets the default, because it joined an existing physical transaction whose isolation was fixed when it began. The declaration is not rejected and nothing is logged.
The same is true of readOnly and timeout on a participating scope. All three are properties of a physical transaction, and a participating scope does not have one of its own. Set validateExistingTransaction=true on the transaction manager and a mismatched declaration becomes an exception instead of a silent no-op; it is off by default.
The long tail
readOnly = true is a hint, not enforcement — and a write inside one may silently never flush, which is arguably a seventh silent failure: chapter 6
Marking rollback-only yourself with TransactionAspectSupport.currentTransactionStatus(), and what the caller then sees: chapter 3
The four isolation levels, what each prevents, and which database defaults to which: chapter 6
Should you reach for the exotic propagations at all? Mostly no. REQUIRED is the default because it is right almost always, and a service layer where every method is REQUIRED and transactions are started at one well-defined boundary is a service layer nobody has to reason about.
REQUIRES_NEW earns its place for work that must survive the caller’s failure — audit and outbox rows — and costs you a connection each time. MANDATORY earns its place as an assertion. NESTED you cannot have on JPA. SUPPORTS, NOT_SUPPORTED and NEVER are, in my experience, almost always a sign that the work belongs somewhere else. If you are choosing between propagation values to fix a bug, the bug is usually the transaction boundary, not the value.
Further reading
Companion project — runnable, with every transcript above under docs/output/
No Comments yet!