Skip to main content

@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.
PartFor you ifCovers
1 — Beginneryou annotate service methods and move onwhat the annotation does, logical vs physical transactions, the log to turn on
2 — Intermediateyou have seen UnexpectedRollbackExceptionall seven propagations with real output, rollback rules, why catching the exception does not help
3 — Advanceda transaction is not doing what you wrotethe 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:
  1. asks the PlatformTransactionManager for a transaction, according to the propagation rule;
  2. invokes the target method;
  3. on a normal return, commits;
  4. on a RuntimeException or Error, rolls back;
  5. 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.

The log to turn on

logging:
  level:
    org.springframework.orm.jpa.JpaTransactionManager: DEBUG
    org.springframework.transaction.interceptor: TRACE
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:
  1. The outer method starts a transaction and calls an inner REQUIRED method.
  2. 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.
  3. The outer method catches the exception and returns normally, believing it has handled the failure.
  4. The outer interceptor tries to commit. The transaction is marked rollback-only, so it rolls back and throws UnexpectedRollbackException.
one PHYSICAL transaction outer() REQUIRED, starts it inner() REQUIRED joins, then throws setRollbackOnly() on the SHARED transaction outer() catches it, returns normally the code believes it recovered commit() → UnexpectedRollbackException thrown far from the original cause The catch never had a chance: the flag was set on a transaction the caller SHARES, before the exception was ever caught. With REQUIRES_NEW the inner scope owns its own physical transaction, its rollback is contained, and the same catch behaves the way the code reads.
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:

@EnableTransactionManagement(rollbackOn = RollbackOn.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.
{
    "0-control-through-the-proxy": "actualTransactionActive=true",
    "1-self-invocation":           "actualTransactionActive=false",
    "2-private-method":            "actualTransactionActive=false",
    "3-checked-exception": {
        "threw": "Exception", "rowSurvived": true,
        "verdict": "COMMITTED despite the exception"
    },
    "4-swallowed-exception": {
        "rowSurvived": true,
        "verdict": "COMMITTED -- the exception never reached the interceptor"
    },
    "5-called-from-post-construct": { "transactionActiveDuringPostConstruct": false },
    "6-created-with-new":          "actualTransactionActive=false"
}

1. Self-invocation

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.

6. An object created with new

No container, no proxy, no transaction.

The one-line check

TransactionSynchronizationManager.isActualTransactionActive()
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:
{
  "rowsVisibleInsideNestedScope": 2,
  "nestedScopeThrew": "nested scope fails",
  "rowsAfterNestedRollback": 1,
  "rowsAfterOuterCommit": 1,
  "surviving": ["outer-row"],
  "transactionManager": "DataSourceTransactionManager (not JpaTransactionManager)"
}
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.

Isolation settings that are silently ignored

  @Transactional(isolation = SERIALIZABLE) [outer]   isolation=SERIALIZABLE
    REQUIRED inner joining it                        isolation=SERIALIZABLE

  plain @Transactional [outer]                       isolation=default
    inner declaring READ_UNCOMMITTED                 isolation=default
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

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.