What this usually means
Spring's @Transactional annotation works through AOP proxies. The proxy intercepts the method call, starts a transaction, and decides commit or rollback based on the outcome. The most common reason rollback fails is that Spring, by default, only rolls back on unchecked exceptions (RuntimeException and Error) and not on checked exceptions. Another frequent cause is self-invocation — calling a @Transactional method from another method within the same class bypasses the proxy entirely, so no transaction management happens. Other causes include transaction manager misconfiguration, wrong propagation settings, or the method being private/final (Spring proxies can't override them).
The first ten minutes — establish facts before touching code.
- 1Check exception type: Is your exception a RuntimeException or a checked exception? If checked, Spring won't roll back by default.
- 2Enable transaction debug logging: add 'logging.level.org.springframework.transaction=DEBUG' to application.properties. Check logs for 'Creating new transaction' and 'Initiating transaction commit/rollback'.
- 3Verify proxy is active: Inject the service into itself (self-reference) or add @EnableTransactionManagement and ensure no internal calls bypass it.
- 4Confirm transaction manager bean: ensure a PlatformTransactionManager (e.g., DataSourceTransactionManager or JpaTransactionManager) is configured and matches your data source.
- 5Test with a simple RuntimeException: throw new RuntimeException() inside a @Transactional method and see if rollback occurs. If not, the issue is likely proxy bypass.
- 6Check propagation and rollbackFor attributes: @Transactional(rollbackFor = Exception.class) will roll back on any exception. Also ensure propagation is not REQUIRES_NEW that commits independently.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchapplication.properties / application.yml: check logging.level.org.springframework.transaction
- searchService class source: look for private or final methods with @Transactional
- searchCaller code: check if the @Transactional method is called from another method in the same class (self-invocation)
- searchException class hierarchy: determine if your custom exception extends RuntimeException or Exception
- searchTransaction manager configuration: verify DataSourceTransactionManager or JpaTransactionManager bean
- searchSpring AOP proxy logs: add 'logging.level.org.springframework.aop=DEBUG' to see proxy creation
- searchDatabase connection pool logs: check if connections are being committed prematurely
Practical causes, not theory. These are the things you will actually find.
- warningChecked exception thrown: Spring defaults to rollback only on RuntimeException and Error. Use rollbackFor = Exception.class to override.
- warningSelf-invocation (proxy bypass): calling @Transactional method within same class. The proxy is not applied. Solution: inject self or refactor into separate class.
- warningMethod is private or final: Spring CGLIB proxies can't override final methods; JDK proxies require interface methods. Ensure method is public and not final.
- warningTransaction manager misconfigured: e.g., using JpaTransactionManager but actual data source is JDBC, or no transaction manager bean defined.
- warningIncorrect propagation: e.g., REQUIRES_NEW will suspend the current transaction and create a new one; the new one commits independently. NESTED can also cause partial commits.
- warningException caught and swallowed: the method catches the exception and does not rethrow it. Then no exception reaches the transaction interceptor.
- warningMultiple transaction managers: if multiple @Transactional with different managers, the wrong one might be used. Specify transactionManager attribute explicitly.
Concrete fix directions. Pick the one that matches your root cause.
- buildAdd rollbackFor = Exception.class to @Transactional for checked exceptions: @Transactional(rollbackFor = Exception.class)
- buildRefactor self-invocation: move @Transactional method to a separate @Service bean and inject it, or use AopContext.currentProxy() (requires exposeProxy=true)
- buildMake method public and non-final: ensure it can be proxied by Spring AOP
- buildEnsure transaction manager is correctly configured: verify bean definition and data source alignment
- buildSet propagation to REQUIRED (default) to participate in existing transactions; avoid REQUIRES_NEW unless intentional
- buildDo not catch exceptions inside transactional method; if you must catch, rethrow the same exception or a RuntimeException
- buildSpecify transactionManager attribute if multiple managers exist: @Transactional(transactionManager = "primaryTransactionManager")
A fix you cannot prove is a guess. Close the loop.
- verifiedEnable transaction debug logging and observe 'Completing transaction' line — if it says 'commit' after an exception, rollback didn't happen.
- verifiedWrite a unit test that calls the transactional method and expects a rollback: check that no data persists.
- verifiedInject the service into itself (circular reference) and call the method from the injected instance to force proxy usage.
- verifiedUse an integration test with a real database; after the test, verify rollback by querying for the data.
- verifiedCheck transaction status programmatically: TransactionSynchronizationManager.getCurrentTransactionStatus().isRollbackOnly() after the exception.
Things that make this bug worse or harder to find.
- warningAdding @Transactional on private methods expecting it to work; Spring ignores it for private/final methods.
- warningUsing REQUIRES_NEW thinking it helps with rollback scoping; it creates independent transactions that commit separately.
- warningCatching exception and not rethrowing; the transaction interceptor never sees the exception.
- warningForgetting to enable transaction management in XML or Java config (e.g., @EnableTransactionManagement).
- warningAssuming rollback happens on any exception without setting rollbackFor explicitly.
- warningTesting transactional behavior without real database or in-memory database that mimics transaction behavior.
PaymentService not rolling back after failed charge
Timeline
- 09:15Deploy new PaymentService with @Transactional on charge() method
- 09:30First alert: duplicate payment entries created despite exception in charge()
- 09:40Check logs: 'Initiating transaction commit' seen after SQLException
- 09:45Realize SQLException is checked — Spring default does not rollback on checked exceptions
- 09:50Fix: add rollbackFor = Exception.class to @Transactional
- 10:00Redeploy, test with failing charge — rollback now works, no duplicate entries
We were processing credit card charges in a PaymentService. The charge() method was annotated with @Transactional. When the external payment gateway threw a PaymentGatewayException (which extends Exception, not RuntimeException), the method caught it and logged an error. But the transaction still committed, leaving orphan payment records in the database.
I checked the logs and saw 'Creating new transaction' and then 'Initiating transaction commit' despite the exception. That's when it clicked: Spring's default rollback policy only applies to unchecked exceptions. Because PaymentGatewayException was a checked exception, the transaction committed. I also noticed the method caught the exception and didn't rethrow it — another common pitfall.
The fix was two-fold: first, change the @Transactional annotation to @Transactional(rollbackFor = Exception.class). Second, I removed the catch block and let the exception propagate (or rethrow it as a RuntimeException). After redeploying, I verified with a test that the transaction rolled back correctly. No more duplicate payments.
Root cause
Default @Transactional does not roll back on checked exceptions. The method caught the exception and did not rethrow it.
The fix
Added rollbackFor = Exception.class to @Transactional and removed the catch block so the exception propagates to the transaction interceptor.
The lesson
Always specify rollbackFor explicitly if you expect rollback on checked exceptions. Never swallow exceptions in transactional methods.
Spring uses AOP proxies to wrap @Transactional beans. When you call a method on the bean, the proxy intercepts the call, starts a transaction (if not already present), invokes the actual method, and then decides commit or rollback based on the outcome. The decision depends on the exception type and the rollbackFor attribute. If no exception occurs, or if the exception is not in the rollback-for list, the proxy commits.
The proxy is only active when the method is called from outside the class. Self-invocation — one method in the same class calling another @Transactional method — bypasses the proxy. This is the number one cause of 'why is my transaction not rolling back' in codebases where developers call @Transactional methods internally.
By default, Spring rolls back only for RuntimeException and Error. This is rooted in the EJB tradition where checked exceptions indicate business rule violations that don't warrant a rollback. However, in many applications, checked exceptions like SQLException or custom business exceptions should trigger a rollback. To change this, use @Transactional(rollbackFor = Exception.class) or specify a more specific exception class.
You can also use rollbackForClassName or noRollbackFor to fine-tune. Note that if you catch the exception inside the method and don't rethrow, the transaction interceptor never sees it, so no rollback happens regardless of the rollbackFor setting.
Self-invocation occurs when a method within the same class calls another method annotated with @Transactional. Since the call goes through 'this', not the proxy, no transactional interceptor is invoked. The transaction is not started, and any database operations run outside any transaction or with unexpected behavior.
Solutions: 1) Refactor the @Transactional method into a separate @Service bean and inject it. 2) Use AopContext.currentProxy() to obtain the proxy and call the method on it (requires exposing the proxy via @EnableAspectJAutoProxy(exposeProxy = true)). 3) Use AspectJ weaving (compile-time or load-time) which does not rely on proxies.
If multiple transaction managers are defined (e.g., one for each data source), Spring uses the primary one unless you specify @Transactional(transactionManager = "beanName"). If the wrong manager is used, it might not be properly configured for the data source, leading to no transaction or incorrect commit/rollback behavior.
Propagation settings also affect rollback. For example, REQUIRES_NEW suspends the current transaction and creates a new one; the new transaction commits independently regardless of the outer transaction's outcome. Use REQUIRED (default) to participate in the existing transaction and roll back together.
The most effective way to debug is to enable transaction logging: add 'logging.level.org.springframework.transaction=DEBUG' to your properties. You'll see lines like 'Creating new transaction', 'Participating in existing transaction', 'Initiating transaction commit', and 'Initiating transaction rollback'. Look for the commit line after an exception — that's your smoking gun.
You can also programmatically check the transaction status inside the method using TransactionSynchronizationManager.getCurrentTransactionStatus().isRollbackOnly(). If it's true, the transaction is marked for rollback. Another useful technique is to set a breakpoint in the TransactionInterceptor's invokeWithinTransaction method to see the exception handling logic.
Frequently asked questions
Why doesn't @Transactional roll back on SQLException?
Because SQLException is a checked exception, and by default Spring only rolls back on unchecked exceptions (RuntimeException and Error). To roll back on SQLException, use @Transactional(rollbackFor = SQLException.class) or @Transactional(rollbackFor = Exception.class).
Does @Transactional work on private methods?
No. Spring uses proxies (JDK dynamic or CGLIB) that only intercept public methods. For @Transactional to work, the method must be public and not final. If you use CGLIB proxies, protected or package-private methods might work, but it's not recommended.
What is self-invocation and how do I fix it?
Self-invocation is when one method inside a class calls another @Transactional method of the same class. The call goes through 'this', bypassing the proxy. Fix: extract the transactional method into a separate bean and inject it, or use AopContext.currentProxy() with exposeProxy=true.
I added rollbackFor but still no rollback. What else could be wrong?
Check if the exception is being caught inside the method and not rethrown. Also verify that the transaction manager is correctly configured and that the method is called from outside the class (no self-invocation). Enable DEBUG logging for transactions to see the decision.
Does REQUIRES_NEW propagation affect rollback behavior?
Yes. REQUIRES_NEW suspends the current transaction and creates a new independent transaction. That new transaction commits or rolls back independently. If the outer transaction rolls back, the inner one (if already committed) stays committed. Use REQUIRED to participate in the same transaction.