“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;
}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);
}
}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 { ... }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); }
}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
}
}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
}
}
}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;
}
}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
rollbackForon 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
@Transactionalon 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");
}
}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.