Spring Boot · Transactions

Spring @Transactional not rolling back: 9 causes and fixes

Why Spring @Transactional doesn’t roll back: checked exceptions, self-invocation, private methods, swallowed exceptions, propagation and more — with fixes.

By the JobSupport4U engineering team · Updated · 7 min read

An exception is thrown, yet the rows are still in the database. Or the opposite: everything looks fine and the transaction rolls back anyway. Spring’s @Transactional is simple to add and easy to misunderstand. These are the causes we see most often, roughly in order of how often they bite.

How @Transactional actually works

Spring wraps your bean in a proxy. When another bean calls a @Transactional method, the call goes through the proxy, which starts a transaction, calls your method, and then commits — or rolls back if a qualifying exception escapes the method. Almost every surprise below comes from one of two facts: the call didn’t go through the proxy, or no qualifying exception escaped.

The 9 most common causes

1. The exception is checked

By default Spring rolls back only for unchecked exceptions (RuntimeException and Error). A checked exception such as IOException escapes the method and the transaction commits. Tell Spring otherwise:

Java
@Transactional(rollbackFor = Exception.class)
public void importOrders(Path file) throws IOException {
  // any exception, checked or not, now rolls back
}

2. The method calls itself (self-invocation)

A call from one method to another in the same class uses this, not the proxy — so the annotation is ignored:

Java
@Service
public class OrderService {
  public void placeAll(List<Order> orders) {
    orders.forEach(this::place); // bypasses the proxy: no transaction per order
  }

  @Transactional
  public void place(Order order) { /* ... */ }
}

Move place to a separate bean, put @Transactional on the outer method if that is the boundary you really want, or manage the transaction explicitly with TransactionTemplate:

Java
transactionTemplate.executeWithoutResult(status -> {
  orderRepository.save(order);
  stockService.reserve(order);
});

3. The method is private

Proxies can’t intercept private methods, so @Transactional on a private method is silently ignored. Since Spring Framework 6, protected and package-private methods work with class-based proxies; private methods never do.

4. The exception is caught inside the method

If you catch the exception and return normally, the proxy sees success and commits. Rethrow it, or mark the transaction for rollback explicitly:

Java
try {
  paymentClient.charge(order);
} catch (PaymentException e) {
  log.warn("Payment failed for {}", order.id(), e);
  TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}

5. The object isn’t a Spring bean

An instance created with new OrderService() has no proxy, so no annotation on it does anything. Always inject the bean.

6. The wrong transaction manager is used

With more than one data source, @Transactional uses the primary transaction manager unless you name another — so work on the second database isn’t covered. Specify it: @Transactional(transactionManager = "reportingTransactionManager").

7. The database can’t roll it back

Some changes are outside transaction control: MySQL tables using the MyISAM engine don’t support transactions, and DDL statements (CREATE, ALTER) commit implicitly in databases such as MySQL and Oracle.

8. Propagation isn’t what you think

  • A method with propagation = REQUIRES_NEW runs in its own transaction and commits independently — it is not rolled back when the outer transaction fails.
  • If an inner REQUIRED method throws a runtime exception and the outer method catches it, the shared transaction is already marked rollback-only. The outer commit then fails with UnexpectedRollbackException — “Transaction silently rolled back because it has been marked as rollback-only”.

9. The work runs on another thread

Transactions are bound to the current thread. Work done in an @Async method, a CompletableFuture or a parallel stream runs outside the caller’s transaction, so it neither joins nor rolls back with it.

A note on tests

@Transactional on a Spring test class rolls back after every test by default. That is usually what you want — but it explains why data “disappears” after a test, and why code that only fails on commit (such as constraint violations at flush time) may pass in tests. Use @Commit or call flush() when you need to see commit-time behaviour.

How to see what’s really happening

Turn on transaction logging and watch where transactions begin, commit and roll back:

application.yml
logging:
  level:
    org.springframework.transaction.interceptor: TRACE
    org.springframework.orm.jpa.JpaTransactionManager: DEBUG

If you never see “Getting transaction for” your method, the call isn’t going through the proxy — causes 2, 3 or 5. If you see it but no rollback, check causes 1 and 4.

Checklist

  1. Is the exception unchecked, or is rollbackFor set?
  2. Is the method called from another bean, not from the same class?
  3. Is the method non-private, on a Spring-managed bean?
  4. Is the exception escaping the method, or being swallowed?
  5. Is the right transaction manager in play?
  6. Can the database roll this change back?
  7. Is propagation (REQUIRES_NEW, rollback-only) behaving as you expect?
  8. Is any of the work running on another thread?
Keep reading

More guides