skip to content

What does TransactionStatus.isRollbackOnly() report, and how does it relate to UnexpectedRollbackException?

level: seniorimportance: should knowfreq 35%

answer

  1. isRollbackOnly = read side of the flag
  2. inner REQUIRED rollback marks shared tx
  3. 'silently rolled back' message
  4. outer commit -> UnexpectedRollbackException
  5. fix: REQUIRES_NEW / NESTED, don't swallow

basics

~10 s

isRollbackOnly() returns true if the transaction has been marked rollback-only (so it can no longer commit). If an inner method sets that flag and the outer tries to commit, Spring throws UnexpectedRollbackException.

solid answer

~40 s

isRollbackOnly() lets you check whether the current transaction is already doomed — it returns true once setRollbackOnly() was called (by your code) or once a participating inner transaction marked it. It's the read side of the rollback-only flag. The classic scenario: an inner @Transactional(REQUIRED) method throws, Spring marks the *shared* transaction rollback-only, the caller catches the exception and proceeds, then the outer commit runs. Because the transaction is rollback-only, Spring cannot honor the commit — it rolls back and raises org.springframework.transaction.UnexpectedRollbackException to signal 'you asked to commit but it was already marked for rollback.' Checking isRollbackOnly() before doing more work, or restructuring with REQUIRES_NEW/NESTED so the inner failure is isolated, avoids the surprise. It's a common source of confusing production errors.

code

java · 21 lines
java
@Service
class OrderService {
    @Transactional // REQUIRED (default) -> one physical transaction
    public void placeOrder(Order o) {
        saveOrder(o);
        try {
            auditService.record(o); // inner @Transactional throws -> marks rollback-only
        } catch (RuntimeException ex) {
            log.warn("audit failed, continuing", ex); // swallow
        }
        // ...method returns -> Spring commits the outer tx here
        // Because it is rollback-only, commit throws UnexpectedRollbackException.
    }
}

@Service
class AuditService {
    // FIX: isolate so the inner failure does not doom the outer transaction
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(Order o) { /* ... may throw ... */ }
}

go deeper

for a junior

Know isRollbackOnly() reports whether the transaction can still commit.

for a middle

Link a swallowed inner exception to UnexpectedRollbackException at outer commit.

for a senior

Explain global rollback-only marking under REQUIRED and how REQUIRES_NEW/NESTED avoids it.

for a principal

Design service boundaries and propagation to make failure isolation explicit and diagnosable, weighing connection cost.

**The read side of the flag.** `setRollbackOnly()` *sets* the rollback-only flag; `boolean isRollbackOnly()` *reads* it. It returns `true` if the transaction has been marked rollback-only through any path — your own `setRollbackOnly()` call, or (importantly) an inner participating transaction that rolled back and thereby marked the shared transaction. `TransactionStatus` also exposes `isCompleted()` (already committed or rolled back) and `isNewTransaction()`. **Global vs local rollback-only.** Under `PROPAGATION_REQUIRED`, when an inner logical transaction rolls back, Spring does not physically roll back immediately; it sets the **global** rollback-only flag on the shared physical transaction. That is what `isRollbackOnly()` surfaces to the outer scope. (Internally Spring also tracks a 'local rollback only' concept, but the externally visible effect is that the whole transaction is doomed.) **UnexpectedRollbackException.** `org.springframework.transaction.UnexpectedRollbackException` is thrown at **commit time** when the caller requests a commit but the transaction was already marked rollback-only. The message is typically: *'Transaction silently rolled back because it has been marked as rollback-only.'* The word *silently* is the key clue — no new exception occurred at commit; the doom was set earlier. **The canonical failing pattern:** 1. Outer `@Transactional` method calls inner `@Transactional` method (both `REQUIRED`, so one physical transaction). 2. Inner throws a `RuntimeException`. 3. Spring's interceptor for the inner scope marks the shared transaction rollback-only (it can't physically roll back — it's not the new/outermost transaction). 4. Outer method **catches** the exception (e.g., to log and continue) and returns normally. 5. Outer commit runs → transaction is rollback-only → `UnexpectedRollbackException`. The developer is surprised because 'I handled the exception!' — but handling it doesn't clear the rollback-only mark. **How to avoid / fix:** - **Isolate the inner call**: annotate the inner method `@Transactional(propagation = Propagation.REQUIRES_NEW)` so it runs in its own physical transaction; its rollback doesn't touch the outer. Or `Propagation.NESTED` (savepoint) so only the inner work is undone. - **Don't swallow-and-continue** across a transactional boundary in the same physical transaction; let the exception propagate so the outer rolls back cleanly. - **Check `isRollbackOnly()`** before continuing if you must, and abort the outer work yourself. - Be aware `noRollbackFor` on the inner can prevent the marking in the first place if the exception is genuinely non-fatal. **Gotchas:** - Even a *caught* exception leaves the mark set — catching is not clearing. - `REQUIRES_NEW` isolates rollback but has cost: separate connection, possible connection-pool exhaustion under deep nesting. - With JPA, an inner failure that triggers rollback-only can also render the shared `EntityManager`/persistence context unusable. - The exception surfaces at the boundary, often far from the real cause — enable transaction debug logging (`org.springframework.transaction` / `AbstractPlatformTransactionManager`) to trace the marking. **When to check it.** Framework/utility code and long programmatic flows benefit from checking `isRollbackOnly()` to short-circuit further expensive work on a doomed transaction; ordinary application code usually relies on propagation choices instead.

  • Why does catching the inner exception in the outer method NOT prevent UnexpectedRollbackException under PROPAGATION_REQUIRED?
    The inner scope already marked the shared physical transaction rollback-only before the exception propagated out. Catching the exception doesn't clear that flag, so the outer commit is still forced to roll back.
  • Name two ways to prevent the inner failure from dooming the outer transaction.
    Run the inner call in PROPAGATION_REQUIRES_NEW (separate physical transaction) or PROPAGATION_NESTED (savepoint), or configure the inner method with noRollbackFor for genuinely non-fatal exceptions so it never sets rollback-only.

saying these in an interview costs you the question

  • Believing that catching the inner exception clears the rollback-only mark
  • Thinking UnexpectedRollbackException means a new error occurred at commit (it means the tx was already doomed)
  • Assuming REQUIRED gives per-method rollback isolation

context