What is UnexpectedRollbackException in Spring and when do you see the message 'Transaction silently rolled back because it has been marked as rollback-only'?
answer
- commit sees rollback-only flag
- inner REQUIRED joins outer tx
- swallowed RuntimeException marks rollback-only
- thrown at commit, not at failure
- 'silently rolled back'
basics
~20 sIt is thrown when your method finishes normally and tries to commit, but the transaction was already marked rollback-only (usually because an inner @Transactional method caught an exception). Spring refuses to commit and rolls back instead.
solid answer
~40 sUnexpectedRollbackException is a Spring runtime exception thrown at commit time. The classic cause: an outer @Transactional method calls an inner @Transactional method with propagation REQUIRED, so they share one physical transaction. The inner method throws a RuntimeException; Spring's transaction interceptor marks the shared transaction rollback-only and re-throws. The outer method then catches that exception, swallows it, and returns normally, expecting a commit. When Spring tries to commit the outer boundary it sees the rollback-only flag, refuses, rolls back, and throws UnexpectedRollbackException with the message 'Transaction silently rolled back because it has been marked as rollback-only'. It signals 'you thought you committed, but you didn't.'
code
java · 24 lines@Service
class OrderService {
private final InventoryService inventory;
OrderService(InventoryService inventory) { this.inventory = inventory; }
@Transactional // starts physical tx T
public void placeOrder(Order order) {
saveOrder(order);
try {
inventory.reserve(order); // inner @Transactional joins T, throws, marks T rollback-only
} catch (RuntimeException ex) {
log.warn("reserve failed, continuing", ex); // swallowed!
}
// method returns normally -> Spring tries commit -> UnexpectedRollbackException
}
}
@Service
class InventoryService {
@Transactional // propagation REQUIRED (default) -> participates in T
public void reserve(Order order) {
throw new IllegalStateException("out of stock");
}
}go deeper
Know the message and the one-line cause: a swallowed inner failure marked the shared transaction rollback-only.
Explain propagation REQUIRED joining one physical tx and the rollback-only flag mechanics.
Trace the interceptor sequence and connect it to real batch/try-catch code patterns.
Frame it as a transaction-boundary design smell and prescribe REQUIRES_NEW vs restructuring per consistency requirements.
## What it is `org.springframework.transaction.UnexpectedRollbackException` is an unchecked exception (subclass of `TransactionException`) thrown by Spring's transaction infrastructure **when a commit is attempted on a transaction that has already been flagged rollback-only**. The full message is usually: *'Transaction silently rolled back because it has been marked as rollback-only.'* ## Background terms - **`@Transactional`**: an annotation that wraps a method in a transaction. Spring implements it with an AOP proxy — a `TransactionInterceptor` runs before and after your method: it starts/joins a transaction on entry and commits or rolls back on exit. - **Propagation `REQUIRED`** (the default): if a transaction already exists, the method **joins** it; otherwise a new one is started. Joining means both methods run inside **one physical database transaction**. - **Rollback-only flag**: a boolean on the transaction. Once set, the transaction can only ever roll back — committing it is forbidden. - **Default rollback rule**: Spring rolls back automatically on `RuntimeException` and `Error`, but **commits** on checked exceptions (unless configured otherwise via `rollbackFor`). ## The exact mechanism 1. Outer `@Transactional` method starts physical transaction T. 2. It calls an inner `@Transactional` method (propagation REQUIRED). The inner method **joins** T — no new transaction; it becomes a *participating* transaction. 3. Inner method throws a `RuntimeException`. 4. The inner `TransactionInterceptor` sees a rollback-worthy exception. Because it did not *start* T (it only participated), it cannot physically roll back yet — instead it calls `setRollbackOnly()` on T, marking it rollback-only, and re-throws the exception. 5. The outer method **catches** that exception in a try/catch and swallows it (or logs and continues), then returns normally. 6. The outer `TransactionInterceptor`, which *owns* T, sees a normal return and attempts `commit()`. 7. Spring's `AbstractPlatformTransactionManager` checks the rollback-only flag, sees it set, performs an actual rollback, and throws `UnexpectedRollbackException`. So the surprise is: the caller *thought* it recovered from the inner failure and committed its own work, but Spring silently discarded everything and told the caller after the fact. ## Why 'silently' From the outer method's viewpoint, nothing looked wrong — it handled the inner exception. The rollback was decided invisibly (the flag), hence 'silently rolled back'. The exception is Spring's way of not letting a caller believe a commit succeeded when it didn't. ## Common triggers - Outer method catches exceptions from an inner `@Transactional` bean method and continues. - A `try/catch` around a repository/save call that itself participates in the transaction. - Batch loops: process N items, catch failures per item and keep going, all inside one outer transaction — the first failed item poisons the whole transaction. ## How to avoid it (preview) - Don't swallow exceptions from inner transactional methods; let them propagate. - Make the inner method run in its own physical transaction with `@Transactional(propagation = Propagation.REQUIRES_NEW)` so its rollback doesn't taint the outer one. - Restructure so the loop/catch is *outside* any shared transaction, or give each unit of work its own transaction. ## Key gotcha Self-invocation (calling an inner `@Transactional` method via `this.method()`) bypasses the proxy, so the inner `@Transactional` **does nothing** — different but frequently confused failure mode.
- At what point is UnexpectedRollbackException actually thrown — when the inner method fails, or later?Later — at commit time on the outer/owning transaction boundary. The inner failure only sets the rollback-only flag; the exception surfaces when the owner tries to commit and Spring refuses.
- If the inner method threw a checked exception instead of a RuntimeException, would you still get it?Not by default — Spring only marks rollback-only for RuntimeException/Error unless the inner method uses rollbackFor for that checked type. A plain swallowed checked exception wouldn't poison the transaction under default rules.
saying these in an interview costs you the question
- Thinking the exception is thrown at the moment the inner method fails, not at commit.
- Believing catching the inner exception fully recovers the transaction.
- Assuming inner and outer are separate transactions under REQUIRED.