Fix: Spring Boot @Transactional Not Rolling Back — Transaction Committed Despite Exception
Part of: Database Errors
Quick Answer
How to fix Spring @Transactional not rolling back — checked vs unchecked exceptions, self-invocation proxy bypass, rollbackFor, transaction propagation, and nested transactions.
The Problem
A Spring @Transactional method doesn’t roll back when an exception is thrown:
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
inventoryService.deductStock(order); // Throws a checked exception
// Order is saved but stock not deducted — partial state in DB
}
}Or a rollback is expected but the data remains committed:
@Transactional
public void transfer(String from, String to, BigDecimal amount) {
accountRepository.debit(from, amount);
accountRepository.credit(to, amount); // Throws RuntimeException
// Both operations committed — debit happened, credit failed
// Transaction should have rolled back the debit too
}Or @Transactional on a private method is silently ignored:
@Transactional
private void saveData(Data data) { // Annotation has no effect
repository.save(data);
}Why This Happens
Spring’s @Transactional works through a proxy mechanism. When a bean is annotated with @Transactional, Spring wraps it in a proxy (either JDK dynamic proxy or CGLIB subclass) that intercepts method calls, starts a transaction before the method executes, and commits or rolls back based on the outcome. The actual method is invoked on the target object behind the proxy. Any scenario that circumvents the proxy or changes how the proxy evaluates the outcome breaks transaction management.
The proxy’s default rollback rule is narrow: it only rolls back for unchecked exceptions (RuntimeException and Error). Checked exceptions — anything extending Exception directly — are treated as “expected” outcomes and cause a commit. This design dates back to Spring’s early alignment with EJB conventions, but it trips up almost every developer who encounters it for the first time. A method that throws IOException or a custom BusinessException will commit the transaction, leaving partially written data in the database.
Self-invocation is the other major trap. When a method inside a class calls another @Transactional method on the same instance using this.method(), the proxy is never involved. The call goes directly to the target object, so the annotation is invisible. The same applies to @Transactional on private methods — CGLIB cannot subclass private methods, and JDK dynamic proxies only intercept interface methods. In either case, the annotation is silently ignored without any warning in the logs. Swallowing exceptions in try-catch blocks prevents the proxy from seeing the failure, so it commits the transaction even when the method’s internal state is broken.
Fix 1: Add rollbackFor for Checked Exceptions
By default, Spring only rolls back on RuntimeException and Error:
// WRONG — IOException is a checked exception, won't trigger rollback
@Transactional
public void processFile(String path) throws IOException {
Record record = fileService.parse(path); // May throw IOException
repository.save(record);
// If IOException is thrown, record IS committed (no rollback)
}
// CORRECT — specify rollbackFor
@Transactional(rollbackFor = Exception.class)
public void processFile(String path) throws Exception {
Record record = fileService.parse(path);
repository.save(record);
// Now IOException causes a rollback
}
// Or specify exact exception types
@Transactional(rollbackFor = { IOException.class, ValidationException.class })
public void processFile(String path) throws IOException {
// ...
}
// Alternatively — wrap in a RuntimeException to trigger default rollback
@Transactional
public void processFile(String path) {
try {
Record record = fileService.parse(path);
repository.save(record);
} catch (IOException e) {
throw new RuntimeException("File processing failed", e); // Unchecked — rolls back
}
}Default rollback rules:
- Rolls back:
RuntimeException,Error(and subclasses) - Does NOT roll back:
Exception,IOException,SQLException, and any checked exceptions
Fix 2: Fix Self-Invocation (Proxy Bypass)
The most common cause of @Transactional being silently ignored — calling an annotated method from within the same class:
@Service
public class UserService {
@Transactional
public void createUserWithProfile(UserDto dto) {
User user = userRepository.save(new User(dto));
this.createProfile(user); // WRONG — bypasses proxy, no transaction on createProfile
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createProfile(User user) {
profileRepository.save(new Profile(user));
// This runs in the OUTER transaction, not a new one
// The REQUIRES_NEW annotation is completely ignored
}
}Fix — inject the bean into itself (Spring provides a self-reference):
@Service
public class UserService {
@Autowired
private UserService self; // Spring injects the proxy, not 'this'
@Transactional
public void createUserWithProfile(UserDto dto) {
User user = userRepository.save(new User(dto));
self.createProfile(user); // Goes through proxy — @Transactional honored
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createProfile(User user) {
profileRepository.save(new Profile(user));
// Now runs in its own transaction
}
}Fix — extract the inner method to a separate service:
// Cleaner solution — separate services
@Service
public class UserService {
@Autowired
private ProfileService profileService;
@Transactional
public void createUserWithProfile(UserDto dto) {
User user = userRepository.save(new User(dto));
profileService.createProfile(user); // Different bean — proxy works
}
}
@Service
public class ProfileService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createProfile(User user) {
profileRepository.save(new Profile(user));
}
}Fix — use ApplicationContext to get the proxy:
@Service
public class UserService implements ApplicationContextAware {
private ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext ctx) {
this.applicationContext = ctx;
}
@Transactional
public void createUserWithProfile(UserDto dto) {
User user = userRepository.save(new User(dto));
// Get the proxied bean from context
UserService proxy = applicationContext.getBean(UserService.class);
proxy.createProfile(user);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createProfile(User user) {
profileRepository.save(new Profile(user));
}
}Fix 3: @Transactional on Public Methods Only
Spring AOP only intercepts public methods. Move @Transactional to public methods or use AspectJ weaving for private methods:
// WRONG — @Transactional on private method is ignored
@Transactional
private void saveInternal(Data data) {
repository.save(data);
}
// CORRECT — public method
@Transactional
public void save(Data data) {
repository.save(data);
}
// If you need private transactional logic — call through a public method
public void processData(Data data) {
validateData(data); // Private — OK (no transaction annotation)
saveData(data); // Public @Transactional method
}
@Transactional
public void saveData(Data data) {
repository.save(data);
}AspectJ weaving (compile-time) — allows @Transactional on private methods:
<!-- pom.xml — enable AspectJ load-time weaving -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>// application.properties
spring.aop.proxy-target-class=true // Use CGLIB proxy (default in Spring Boot)
// spring.aop.auto=true // Enable AOP auto-proxyFix 4: Understand Transaction Propagation
Propagation controls how transactions behave when methods call each other:
@Service
public class OrderService {
@Transactional // Default: REQUIRED — joins existing transaction or creates new one
public void placeOrder(Order order) {
orderRepository.save(order);
notificationService.sendConfirmation(order); // Joins THIS transaction
}
}
@Service
public class NotificationService {
// REQUIRED (default) — joins the caller's transaction
@Transactional
public void sendConfirmation(Order order) {
notificationRepository.save(new Notification(order));
// If this throws, the entire outer transaction rolls back
// Including the orderRepository.save() above
}
// REQUIRES_NEW — always starts a new, independent transaction
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendConfirmationIndependently(Order order) {
notificationRepository.save(new Notification(order));
// If this throws, only THIS transaction rolls back
// The outer orderRepository.save() is NOT rolled back
}
// NOT_SUPPORTED — suspends any existing transaction, runs without one
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void logAction(String message) {
auditLog.append(message);
// Runs outside any transaction — won't be rolled back
}
}Propagation types:
| Propagation | Behavior |
|---|---|
REQUIRED (default) | Join existing or create new |
REQUIRES_NEW | Always create new, suspend existing |
SUPPORTS | Join if exists, run without if not |
NOT_SUPPORTED | Run without transaction, suspend existing |
MANDATORY | Must have existing transaction, else throw |
NEVER | Must NOT have transaction, else throw |
NESTED | Nested within existing (savepoint) |
Fix 5: Don’t Swallow Exceptions
A try-catch that doesn’t rethrow prevents Spring from knowing the method failed:
// WRONG — exception swallowed, no rollback triggered
@Transactional
public void processOrder(Order order) {
try {
orderRepository.save(order);
paymentService.charge(order); // Throws PaymentException
} catch (PaymentException e) {
log.error("Payment failed", e);
// Exception swallowed — Spring commits the transaction!
// orderRepository.save() is committed even though payment failed
}
}
// CORRECT — rethrow or convert to unchecked exception
@Transactional(rollbackFor = PaymentException.class)
public void processOrder(Order order) throws PaymentException {
try {
orderRepository.save(order);
paymentService.charge(order);
} catch (PaymentException e) {
log.error("Payment failed", e);
throw e; // Rethrow — Spring sees the exception and rolls back
}
}
// Or mark for rollback manually without rethrowing
@Transactional
public void processOrder(Order order) {
orderRepository.save(order);
try {
paymentService.charge(order);
} catch (PaymentException e) {
log.error("Payment failed: {}", e.getMessage());
// Manually mark for rollback without throwing
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}Fix 6: Hibernate vs EclipseLink Flush Behavior
JPA providers differ in when they flush changes to the database, which affects how rollback behaves. Hibernate and EclipseLink handle this differently, and the distinction matters when you debug a rollback that seems to partially commit:
// Hibernate (default in Spring Boot) — auto-flush before query execution
@Transactional
public void updateAndQuery() {
User user = userRepository.findById(1L).orElseThrow();
user.setName("Alice");
// Hibernate auto-flushes this change before the next query
List<User> users = userRepository.findByName("Alice");
// users contains the updated record — flush happened
throw new RuntimeException("rollback");
// Rollback reverses the flush — DB still has old name
}
// EclipseLink — does NOT auto-flush before JPQL queries by default
@Transactional
public void updateAndQuery() {
User user = entityManager.find(User.class, 1L);
user.setName("Alice");
// EclipseLink does NOT flush automatically here
TypedQuery<User> q = entityManager.createQuery(
"SELECT u FROM User u WHERE u.name = 'Alice'", User.class);
List<User> users = q.getResultList();
// users may be EMPTY — in-memory change not flushed to DB yet
}Force flush behavior consistently across providers:
// Explicit flush — works the same on Hibernate and EclipseLink
@Transactional
public void updateWithExplicitFlush() {
User user = userRepository.findById(1L).orElseThrow();
user.setName("Alice");
entityManager.flush(); // Forces SQL to hit the database now
// Both Hibernate and EclipseLink will have flushed at this point
}
// Configure flush mode in application.properties
// Hibernate
spring.jpa.properties.org.hibernate.flushMode=COMMIT
// EclipseLink
spring.jpa.properties.eclipselink.persistence-context.flush-mode=COMMITWhy this matters: If you set flush mode to
COMMITin Hibernate, changes are only flushed at commit time. A rollback at any point before commit means nothing hits the database. With the defaultAUTOmode, Hibernate flushes before queries, so a rollback must undo SQL statements that already ran against the database (within the transaction). Both produce the same final result for the application, butAUTOgenerates more SQL round-trips and can produce confusing log output during debugging.
Fix 7: H2 vs MySQL Transaction Isolation Defaults
Transaction isolation levels vary by database. Tests that use H2 (in-memory) may behave differently from production running MySQL or PostgreSQL:
// H2 default isolation: READ_COMMITTED
// MySQL InnoDB default isolation: REPEATABLE_READ
// PostgreSQL default isolation: READ_COMMITTED
// This means a test using H2 might see different visibility than MySQL production
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void consistentRead() {
List<User> firstRead = userRepository.findAll();
// Another thread inserts a new user here
List<User> secondRead = userRepository.findAll();
// REPEATABLE_READ: firstRead.size() == secondRead.size() on MySQL
// H2 under READ_COMMITTED: secondRead might see the new row
}Testcontainers approach — use the real database in tests:
@Testcontainers
@SpringBootTest
class TransactionIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void setProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private OrderService orderService;
@Test
void rollbackOnCheckedExceptionWorks() {
assertThrows(PaymentException.class, () ->
orderService.processOrder(new Order("item-1", 100))
);
// Verify rollback happened on the real MySQL engine
assertEquals(0, orderRepository.count());
}
}@Transactional in tests with Testcontainers — a common pitfall:
// In tests, @Transactional auto-rolls back after each test
// This can MASK rollback bugs because the test itself triggers a rollback
@SpringBootTest
@Transactional // Auto-rollback after each test
class OrderServiceTest {
@Test
void testRollback() {
// This test will always see a clean DB after execution
// regardless of whether your service-level rollback works
// because the TEST-level transaction rolls back everything
}
}
// Better approach — don't use @Transactional in the test itself
@SpringBootTest
class OrderServiceTest {
@AfterEach
void cleanup() {
orderRepository.deleteAll(); // Manual cleanup instead
}
@Test
void testRollbackActuallyWorks() {
// No test-level transaction — your service must handle its own
assertThrows(RuntimeException.class, () ->
orderService.failingOperation()
);
// If service-level rollback is broken, data persists
assertEquals(0, orderRepository.count());
}
}Fix 8: Verify Transaction is Active
Debug whether a transaction is actually active when you expect one:
@Service
public class DiagnosticsService {
@Transactional
public void checkTransaction() {
boolean active = TransactionSynchronizationManager.isActualTransactionActive();
String txName = TransactionSynchronizationManager.getCurrentTransactionName();
System.out.println("Transaction active: " + active);
System.out.println("Transaction name: " + txName);
// If active = false inside @Transactional, the proxy isn't being used
// (self-invocation or wrong bean scope)
}
}Enable transaction logging:
# application.properties
logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.orm.jpa=DEBUG
# Output shows:
# Creating new transaction with name [com.example.UserService.createUser]
# Participating in existing transaction
# Rolling back JPA transaction on EntityManager
# Committing JPA transaction on EntityManagerFix 9: @Transactional on Spring Tests
In tests, @Transactional auto-rolls back after each test — useful for isolation:
@SpringBootTest
@Transactional // Each test method rolls back automatically
class UserServiceTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void createUser_savesToDatabase() {
userService.createUser(new UserDto("[email protected]"));
Optional<User> user = userRepository.findByEmail("[email protected]");
assertThat(user).isPresent();
// After this test, the transaction rolls back — DB is clean for next test
}
@Test
@Commit // Override — commit this test's data (use carefully)
void createUser_persistsAfterCommit() {
userService.createUser(new UserDto("[email protected]"));
// This data STAYS in the database — other tests may see it
}
}Fix 10: Kubernetes Readiness and Long Transactions
In a Kubernetes environment, long-running transactions can interact poorly with readiness probes and rolling deployments. If a pod is shut down while a transaction is in progress, the transaction may or may not roll back depending on how the shutdown signal is handled:
// Graceful shutdown — Spring Boot 2.3+ handles this
// application.properties
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
// This gives in-flight transactions up to 30 seconds to finish
// before the pod is killed// For long-running batch transactions, check for shutdown signals
@Transactional
public void processBatch(List<Order> orders) {
for (Order order : orders) {
if (Thread.currentThread().isInterrupted()) {
// Pod is shutting down — let transaction roll back
throw new RuntimeException("Shutdown in progress — rolling back batch");
}
processOrder(order);
}
}Note: If a Kubernetes pod is terminated with SIGKILL (after the grace period), the database connection drops. Whether the transaction rolls back depends on the database engine. MySQL and PostgreSQL both roll back transactions associated with closed connections, but the rollback happens asynchronously on the database side. Data that was already flushed and committed in a sub-transaction (REQUIRES_NEW) stays committed.
Still Not Working?
@Transactional on interface vs implementation — annotating the interface method works with JDK dynamic proxies. If Spring uses CGLIB (the default in Spring Boot), annotate the implementation class, not the interface.
@EnableTransactionManagement missing — in pure Spring (non-Boot) apps, add @EnableTransactionManagement to a @Configuration class. Spring Boot auto-configures this.
Multiple DataSource beans — if you have more than one data source, you may need to specify which TransactionManager to use: @Transactional("myTransactionManager").
JPA lazy loading after transaction ends — accessing a lazy-loaded collection after the @Transactional method returns throws LazyInitializationException. Either fetch eagerly (JOIN FETCH), use @Transactional(readOnly = true) on the calling service, or use an OpenEntityManagerInViewFilter.
NESTED propagation not supported by all JPA providers — Propagation.NESTED uses database savepoints. Hibernate supports it on databases that implement savepoints (MySQL, PostgreSQL), but EclipseLink does not support NESTED propagation at all. If you use EclipseLink, switch to REQUIRES_NEW or restructure the logic.
Transaction silently committed after EntityManager.merge() — if you call entityManager.merge() outside a @Transactional context (or in a method where the proxy is bypassed), the EntityManager may open and close its own transaction per operation. Each merge() becomes a mini-transaction that commits immediately. Ensure the calling method is properly proxied.
For related Spring issues, see Fix: Spring Boot DataSource Failed to Configure, Fix: Spring Data JPA Query Not Working, Fix: Spring Boot Test Not Working, and Fix: Java Hibernate LazyInitializationException.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Spring Data JPA Query Not Working — @Query, Derived Methods, and N+1 Problems
How to fix Spring Data JPA query issues — JPQL vs native SQL, derived method naming, @Modifying for updates, pagination, projections, and LazyInitializationException.
Fix: Spring Boot Failed to Configure a DataSource
How to fix 'Failed to configure a DataSource: url attribute is not specified' in Spring Boot — adding database properties, excluding DataSource auto-configuration, H2 vs production DB setup, and multi-datasource configuration.
Fix: Spring Boot @Cacheable Not Working — Cache Miss Every Time or Stale Data
How to fix Spring Boot @Cacheable issues — @EnableCaching missing, self-invocation bypass, key generation, TTL configuration, cache eviction, and Caffeine vs Redis setup.
Fix: Hibernate LazyInitializationException — Could Not Initialize Proxy
How to fix Hibernate LazyInitializationException — loading lazy associations outside an active session, fetch join, @Transactional scope, DTO projection, and Open Session in View.