in

Spring @Transactional Pitfalls: 8 Common Mistakes and Fixes

@Transactional is not an annotation, it is a proxy — and most bugs come from forgetting that. Self-invocation, no rollback on checked exceptions, REQUIRES_NEW exhausting the connection pool, what readOnly really does and transaction behavior that misleads in tests, with code.

Diagram showing how the Spring @Transactional proxy wraps method calls

@Transactional is the most written and least understood annotation in Spring. A large share of the data inconsistencies we see in production — half-finished orders, errors that didn't roll back, exhausted connection pools — come down to the same root cause: forgetting that the annotation is a proxy. This article explains the mechanism first, then the eight pitfalls we meet most often in production and the fix for each, with code.

The mechanism first: not an annotation, a proxy

Spring wraps a bean carrying @Transactional in a proxy (a CGLIB subclass or a JDK dynamic proxy). The code calling the method actually calls the proxy; the proxy opens the transaction, runs the real method, and commits or rolls back depending on the outcome. Four consequences follow from that one sentence:

  • A transaction starts only when the call goes through the proxy.

  • The proxy can only wrap externally visible methods; private methods are not wrapped.

  • The rollback decision is made based on the type of exception leaving the method.

  • The transaction is bound to the thread making the call.

Pitfall 1: Self-invocation — calling from within the same class

@Service
public class OrderService {

    public void processAll(List<Request> requests) {
        for (Request r : requests) {
            process(r);              // called via this, not the proxy: NO TRANSACTION
        }
    }

    @Transactional
    public void process(Request r) { ... }
}

Calling process from processAll means this.process(); the proxy is not involved and @Transactional never runs. The code runs without errors — just without a transaction. The fix is to move the method with the transaction boundary into a separate bean; the quick fix is a programmatic boundary with TransactionTemplate:

@Service
public class OrderService {
    private final TransactionTemplate tx;

    public void processAll(List<Request> requests) {
        for (Request r : requests) {
            tx.executeWithoutResult(status -> process(r));   // each request in its own transaction
        }
    }
}

Pitfall 2: Checked exceptions don't roll back

The default rule: only RuntimeException and Error trigger a rollback. If an IOException or your own checked PaymentException is thrown, the transaction is committed.

@Transactional(rollbackFor = Exception.class)      // roll back on checked exceptions too
public void takePayment(Order o) throws PaymentException { ... }

Standardize one of two options as a team rule: either derive business exceptions from RuntimeException, or write rollbackFor = Exception.class on every @Transactional. In projects that mix the two, nobody knows which error gets rolled back.

Pitfall 3: Propagation choices and UnexpectedRollbackException

Propagation

Behavior

Typical use

REQUIRED (default)

Join if one exists, otherwise start a new one

Almost all service methods

REQUIRES_NEW

Suspend the current one, start a new transaction on a new connection

Records that must survive a failed main operation: audit log, error log

NESTED

Savepoint within the same transaction

Partial rollback; JDBC-based managers only

SUPPORTS

Join if one exists, otherwise run without

Read-only helpers

MANDATORY

Throw if none exists

A guarantee that "this method is never called on its own"

NOT_SUPPORTED / NEVER

Run outside a transaction / throw if one exists

Long-running external calls

The most common scenario is the outer method catching the inner method's exception:

@Transactional
public void placeOrder(Request r) {
    repository.save(...);
    try {
        inventoryService.reserve(r);   // @Transactional(REQUIRED): joins the SAME transaction
    } catch (OutOfStock e) {
        notificationService.warn(r);   // "I handled the error" — or so it seems
    }
}                                      // commit attempted → UnexpectedRollbackException

Because reserve joined the same transaction, the exception it threw marked the transaction rollback-only; even though the outer method swallowed the error, Spring throws UnexpectedRollbackException at commit time. For the error to be genuinely handled, either the inner call must be REQUIRES_NEW or the inner method must return a result object instead of throwing.

The cost of REQUIRES_NEW: the suspended transaction doesn't release its connection, and the new transaction takes a second one. A service calling REQUIRES_NEW inside a loop deadlocks at pool-size concurrent requests. Increasing the pool size doesn't solve it; it postpones the symptom.

Pitfall 4: readOnly is a hint, not a lock

@Transactional(readOnly = true) doesn't prevent writes; it tells Hibernate to set the flush mode to MANUAL (no dirty-checking cost) and passes a read-only hint to the driver/database. You see the real gain in two places: memory and CPU on large listing queries, and on data sources that route to read replicas. It's a setting that belongs on read-only services; it's not a security measure to rely on.

Pitfall 5: @Async, @Scheduled and new threads

A transaction is bound to a thread. If you call an @Async method from a @Transactional one, the async method runs on another thread, in another transaction (or none at all); even if the main transaction rolls back, the async work has been done. The same applies to CompletableFuture.supplyAsync, parallel streams and manually started virtual threads. The right pattern: trigger the async work after the transaction commits.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void on(OrderPlaced e) {
    mail.send(e);                      // runs only if the commit actually happened
}

Pitfall 6: External calls inside a transaction

Making an HTTP call, publishing a message or sending an e-mail while a transaction is open creates two problems: the connection is held in the pool for the external system's response time, and if the transaction rolls back after the external action, it cannot be undone ("the e-mail went out, the order doesn't exist"). The rule: the transaction boundary ends with the database operations; side effects go to an AFTER_COMMIT listener or an outbox table.

Pitfall 7: Methods the proxy can't see

  • private and final methods can't be wrapped by CGLIB; the annotation is silently ignored. Since Spring 6, protected and package-visible methods are supported on class-based proxies, but the safest rule is to keep transaction boundaries public.

  • Calls made in constructors and @PostConstruct run before the proxy is fully set up; don't expect a transaction.

  • Put the annotation on the class, not the interface; a CGLIB proxy may not see interface annotations.

Pitfall 8: @Transactional in tests, surprise in production

Putting @Transactional on a @SpringBootTest class runs the test in its own transaction and rolls it back at the end. Two side effects: lazy collections load in the test but throw LazyInitializationException in production, and because Hibernate never flushes, constraint violations (unique, not null) are invisible in the test. In integration tests, either don't use @Transactional and clean the data, or force the write with TestEntityManager.flush().

Code review checklist

  1. Is a @Transactional method called from within the same class?

  2. Is rollbackFor set on methods that throw checked exceptions?

  3. If an inner service call's exception is caught, what is its propagation?

  4. Is there a REQUIRES_NEW inside a loop?

  5. Is there an HTTP/message/e-mail call inside a transaction?

  6. Is an @Async method called directly from a transactional method?

  7. Do read-only services have readOnly = true?

  8. Do integration tests verify flush behavior?

Frequently asked questions

Should @Transactional go on the repository or the service?

The service. A transaction boundary represents a business rule ("place order" is all or nothing); repository methods run inside that boundary. Spring Data repositories already run their own methods transactionally, but the business-rule boundary lives in the service layer.

Is class-level @Transactional a good idea?

It is a practical default for service classes; mark read-only methods separately with readOnly = true. Don't put it on controllers or components.

How long can a transaction last?

It should be in the order of milliseconds. Transactions lasting seconds are the main cause of lock contention and connection exhaustion; set an upper bound with the timeout attribute and split long jobs into chunks.

Conclusion

The pitfalls of @Transactional come not from the annotation itself but from the invisibility of the proxy model. A team that knows whether the call goes through the proxy, which exception triggers a rollback, what propagation costs in connections, and where the thread boundary is, avoids all of these mistakes.

To review the transaction boundaries and data-consistency risks of your existing application, see our software design and architecture service.