Ch. 8 · Spring Boot

Spring Transaction Rollback Rules and Exceptions

Why Spring @Transactional commits on checked exceptions, how rollbackFor and noRollbackFor work, and why swallowed exceptions commit.

~6 min readintermediateupdated Oct 6, 2026

“Does @Transactional roll back when a method throws an exception?” Most candidates say yes. The accurate answer is “it depends on the exception type”, and that detail has caused real data corruption: a method throws a checked IOException halfway through, and the half-finished work commits. Interviewers ask this to check whether you know Spring’s default rollback rules, how to change them, and how swallowing exceptions or calling other transactional methods changes the outcome.

Before you start

You should know how @Transactional works through a proxy around a Spring bean (see transaction proxies and self-invocation), the difference between checked and unchecked exceptions in Java, and how a JPA repository saves data. Examples target Spring Boot 3.x.

The short answer

By default, Spring’s transaction interceptor rolls back when the method throws a RuntimeException or an Error, and commits when it throws a checked exception. This mirrors EJB conventions: checked exceptions were seen as expected business outcomes. Change it per method with rollbackFor (or noRollbackFor), or make your domain exceptions extend RuntimeException. Rollback is decided only by what escapes the proxy: if you catch an exception inside the method and return normally, the transaction commits, unless something already marked it rollback-only.

How it works

TransactionInterceptor wraps the method call. In pseudocode:

TransactionStatus status = txManager.getTransaction(definition);
try {
    Object result = invocation.proceed();          // your method
    txManager.commit(status);
    return result;
} catch (Throwable ex) {
    if (attribute.rollbackOn(ex)) {                 // RuntimeException or Error by default
        txManager.rollback(status);
    } else {
        txManager.commit(status);                   // checked exception: commit, then rethrow
    }
    throw ex;
}
java

rollbackOn consults the rules in the annotation. rollbackFor = PaymentException.class adds a rule that matches that type and its subclasses; the most specific matching rule wins.

@Service
class TransferService {

    @Transactional
    public void transfer(long from, long to, BigDecimal amount) throws LedgerUnavailableException {
        accounts.debit(from, amount);
        ledger.record(from, to, amount);   // throws checked LedgerUnavailableException
        accounts.credit(to, amount);
    }
}
java

If ledger.record throws the checked LedgerUnavailableException, the debit commits. Money leaves one account and arrives nowhere.

Step-by-step walkthrough

Step 1: Decide which failures mean “undo everything”

For most service methods, any exception that escapes means the operation did not complete, so it should roll back. The default only does that for unchecked exceptions.

Step 2: Make the rule explicit

Either declare the rule on the method:

@Transactional(rollbackFor = Exception.class)
public void transfer(long from, long to, BigDecimal amount) throws LedgerUnavailableException { ... }
java

or, more commonly in modern Spring code, make domain exceptions unchecked so the default does the right thing:

public class LedgerUnavailableException extends RuntimeException {
    public LedgerUnavailableException(String message, Throwable cause) { super(message, cause); }
}
java

Some teams set the rule globally by customizing the transaction attribute source, but a per-method annotation or unchecked exceptions are easier for readers to see.

Step 3: Use noRollbackFor for outcomes you want to keep

Sometimes an exception reports an outcome that should still be saved, such as recording a failed login attempt before throwing:

@Transactional(noRollbackFor = AccountLockedException.class)
public void login(String user, String password) {
    Account account = accounts.findByUsername(user).orElseThrow();
    if (!encoder.matches(password, account.passwordHash())) {
        account.recordFailedAttempt();             // must persist
        if (account.isLocked()) throw new AccountLockedException(user);
        throw new BadCredentialsException("Invalid credentials");   // rolls back the attempt count
    }
}
java

Here BadCredentialsException would roll back the failed-attempt counter, which defeats lockout. A cleaner design records the attempt in its own transaction (REQUIRES_NEW) so the rule is not subtle.

Step 4: Don’t swallow exceptions you expect to roll back

@Transactional
public void importBatch(List<Row> rows) {
    for (Row row : rows) {
        try {
            repository.save(toEntity(row));
        } catch (DataIntegrityViolationException ex) {
            log.warn("Skipping row {}", row.id());     // swallowed
        }
    }
}
java

The proxy sees a normal return and tries to commit. With JPA, the failure may have already marked the transaction rollback-only, so the commit throws UnexpectedRollbackException and nothing is saved. With plain JDBC it may commit partially. Either way, the code does not express its intent. If rows should be independent, give each its own transaction.

Worked scenario

An order service places orders and calls an inventory reservation in a different bean:

@Service
class OrderService {
    @Transactional
    public Order place(OrderRequest req) {
        Order order = orders.save(Order.from(req));
        try {
            inventory.reserve(order);           // @Transactional (REQUIRED) in another bean
        } catch (OutOfStockException ex) {      // unchecked
            order.markBackordered();
        }
        return order;
    }
}
java

The intent is “if out of stock, save the order as backordered”. In production every out-of-stock order fails with UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only.

inventory.reserve joins the outer transaction (propagation REQUIRED). When OutOfStockException escapes the inner proxy, the interceptor cannot roll back a transaction it does not own, so it marks the shared transaction rollback-only. The outer method catches the exception and returns normally, the outer proxy attempts a commit, finds the flag and rolls back with UnexpectedRollbackException.

Two fixes work. Annotate reserve with @Transactional(noRollbackFor = OutOfStockException.class) so it does not mark the transaction. Or better, make availability a return value rather than an exception: ReservationResult reserve(Order) returns RESERVED or OUT_OF_STOCK, and the order service decides. Exceptions then remain reserved for real failures.

Common mistake

  • “Any exception rolls back.” Checked exceptions commit by default.
  • Catching and logging inside the transactional method while expecting rollback.
  • Putting rollbackFor on an interface method in a class that is not proxied or on a private method. Annotations only work on public methods called through the proxy.
  • Catching an inner transactional exception and continuing, leading to UnexpectedRollbackException.
  • Calling TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() as a routine control-flow tool. It works but hides intent; prefer throwing.
  • Assuming @Transactional on a test method commits. In Spring tests, test-managed transactions roll back by default.

Verify the behavior

@SpringBootTest
class TransferRollbackTest {

    @Autowired TransferService transfers;
    @Autowired AccountRepository accounts;
    @MockitoBean LedgerClient ledger;

    @Test
    void checkedLedgerFailureRollsBackDebit() throws Exception {
        long from = accounts.save(new Account(new BigDecimal("100"))).getId();
        long to = accounts.save(new Account(BigDecimal.ZERO)).getId();
        doThrow(new LedgerUnavailableException("down", null)).when(ledger).record(anyLong(), anyLong(), any());

        assertThatThrownBy(() -> transfers.transfer(from, to, new BigDecimal("40")))
                .isInstanceOf(LedgerUnavailableException.class);

        assertThat(accounts.findById(from).orElseThrow().balance()).isEqualByComparingTo("100");
    }
}
java

The test class is not @Transactional, so the service’s own transaction really commits or rolls back. With the checked exception and no rollbackFor, the final assertion fails with a balance of 60, which demonstrates the default rule. Enabling logging.level.org.springframework.transaction.interceptor=trace logs each completion decision.

Follow-up questions

Why did Spring choose this default? It follows the EJB convention that checked exceptions are recoverable application exceptions. Spring’s own data access exceptions are unchecked for the same reason.

What is rollbackForClassName? The same rule using class names as strings, useful when the class is not on the compile path.

Does the rule apply to exceptions thrown at commit time? Commit failures (for example a constraint violation at flush) surface as TransactionSystemException or a data access exception after the method returns; the transaction is already rolled back.

How do reactive transactions differ? TransactionalOperator and @Transactional on reactive methods roll back on error signals with the same default rules.

Interview exercise

A method saves an invoice, then generates a PDF by calling a library that throws a checked IOException. Product says that if the PDF fails, the invoice must not exist. The method is annotated @Transactional and declares throws IOException. What happens today, and what do you change?

Answer and reasoning

Today the IOException is checked, so the interceptor commits the invoice and rethrows; the invoice exists without a PDF, violating the requirement. Change it with @Transactional(rollbackFor = IOException.class), or wrap it in an unchecked InvoiceRenderingException at the library boundary. Then question the design: generating a PDF inside a database transaction holds a connection during slow I/O (see keeping transactions short). A sturdier approach saves the invoice as PENDING_DOCUMENT, renders the PDF after commit, and marks it complete or retries. The reasoning: know the default rule, make rollback explicit, and keep non-database work out of the transaction when possible.

Continue learning

More in Spring Boot

read ✓Spring Boot · mid

Spring Transaction Duration and Remote Calls

Why long @Transactional methods exhaust the Hikari pool and hold locks, and how to keep remote calls, files and email outside the transaction.

~7 min readread →
read ✓Spring Boot · mid

Spring Transactions and Proxy Invocation

Why @Transactional is ignored on self-invocation and private methods in Spring Boot, how the proxy works, and four ways to fix it.

~7 min readread →
esc