@Transactional is one short annotation that hides a lot of non-obvious machinery. As long as everything works you don't think about it; when it stops, the annotation turns out to behave differently than it seems. Let's start from scratch: what a transaction is, how Spring turns it on, and where the most common pitfalls are.
It is not the method that opens a transaction, but the wrapper around the bean. A call from outside lands in the wrapper and gets its BEGIN and COMMIT; a call to the same method from a neighbouring one goes straight through — the wrapper never learns about it, and there is no transaction.
What a transaction is
Imagine a money transfer: withdraw from account A and deposit into account B. These are two database operations, but for the business they must be indivisible: either both go through, or neither does. If the withdrawal succeeds but the deposit fails, the money is gone.
A transaction is a group of database operations that runs on an "all or nothing" basis. Inside a transaction:
- if everything succeeds, the changes are committed (commit);
- if an error occurs, all changes are rolled back (rollback), as if they never happened.
Without a transaction, each INSERT/UPDATE is saved on its own, and if something fails midway you're left with partially written data.
Why you need @Transactional
Transactions used to be managed by hand: open one, commit at the end, roll back in catch, close the connection in finally. Verbose, and easy to forget a step.
Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false);
accountRepo.withdraw(from, amount);
accountRepo.deposit(to, amount);
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.close();
}
@Transactional removes all this boilerplate: Spring opens a transaction before the annotated method, commits after it completes, and rolls back on error.
@Service
public class TransferService {
@Transactional
public void transfer(AccountId from, AccountId to, Money amount) {
accountRepo.withdraw(from, amount);
accountRepo.deposit(to, amount);
}
}
If an exception is thrown inside transfer, both changes are rolled back automatically.
How it works: the proxy
Every pitfall grows from a single root: @Transactional works through a proxy.
A proxy is a wrapper object: when you mark a bean with the annotation, Spring puts a wrapper around it into the container instead of your object. The wrapper intercepts method calls: before the method it opens a transaction, after it commits or rolls back, and it calls the actual method "inside".
You can build such a wrapper by hand in plain Java — Spring does the same automatically. Watch which calls get a BEGIN and a COMMIT:
live example
import java.lang.reflect.Proxy;
public class ProxyDemo {
interface Orders {
void processBatch();
void processOne(String id);
}
static class OrderService implements Orders {
public void processBatch() {
processOne("A-1");
}
public void processOne(String id) {
System.out.println(" saving order " + id);
}
}
static Orders wrap(Orders target) {
return (Orders) Proxy.newProxyInstance(
Orders.class.getClassLoader(),
new Class<?>[]{Orders.class},
(proxy, method, args) -> {
System.out.println(" BEGIN " + method.getName());
Object result = method.invoke(target, args);
System.out.println(" COMMIT " + method.getName());
return result;
});
}
public static void main(String[] args) {
Orders orders = wrap(new OrderService());
System.out.println("processOne through the proxy:");
orders.processOne("A-9");
System.out.println("processBatch through the proxy:");
orders.processBatch();
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The output shows the whole point. processOne called from the outside gets both a BEGIN and a COMMIT. The very same processOne called from inside processBatch gets neither: the order is saved, but the wrapper never learns about the call. Everything hinges on the call going through the proxy — bypass it, and there is no transaction.
Pitfall 1: calling through this (self-invocation)
The most common one — and exactly what the example above printed. When a method calls another method of the same class through this (explicitly or implicitly: a bare processOne(...) is also this), the call goes directly, bypassing the proxy. The annotation on the second method won't fire.
The fix: move the @Transactional method into a separate bean and call it as a dependency — then the call will go through the proxy.
@Service
public class OrderService {
private final OrderProcessor processor; // another bean
public void processBatch(List<Order> orders) {
orders.forEach(processor::processOne); // through the proxy — works
}
}
Pitfall 2: private methods
The proxy can only wrap what it sees from the outside. On a private method @Transactional is silently ignored — with no error, which makes it especially treacherous.
@Service
public class OrderService {
@Transactional // ignored — the method is private
private void save(Order order) { ... }
}
The rule is simple: put @Transactional on methods that are visible from outside the class. Since Spring 6 (Boot 3) a subclass-based proxy (CGLIB) also intercepts protected and package-visible methods, while an interface-based proxy still sees only public ones. A private method is never intercepted — nor is a final method or a method of a final class: the proxy inherits from the class and cannot override them. Rather than sorting out the subtleties, keep transactional methods public.
Pitfall 3: a swallowed exception
A rollback happens only if the exception leaves the method and reaches the proxy. If you caught it inside a try/catch and didn't rethrow it, Spring never learns about the failure and commits.
@Transactional
public void process(Order order) {
try {
repo.save(order);
externalCall(); // failed here
} catch (Exception e) {
log.warn("error", e); // caught and swallowed — there will be NO rollback
}
}
If you need a rollback, the exception has to be rethrown.
Default rollback: only unchecked
Even when an exception is thrown to the outside, a rollback doesn't happen for just any exception. By default Spring rolls the transaction back only on RuntimeException and Error (so-called unchecked exceptions).
On a checked exception (one that must be declared in throws — for example IOException) the transaction is committed by default. Spring's reasoning is this: a checked exception is part of the "normal" scenario, not a failure.
The whole rule is literally two catch blocks. Here they are in plain Java, together with the third case from the previous section:
live example
import java.io.IOException;
public class RollbackDemo {
interface Body {
void run() throws Exception;
}
static void inTransaction(String name, Body body) {
System.out.println("BEGIN " + name);
try {
body.run();
System.out.println(" COMMIT");
} catch (RuntimeException | Error e) {
System.out.println(" ROLLBACK: " + e.getMessage());
} catch (Exception checked) {
System.out.println(" COMMIT, even though it threw " + checked.getClass().getSimpleName());
}
}
public static void main(String[] args) {
inTransaction("unchecked", () -> {
throw new IllegalStateException("item is out of stock");
});
inTransaction("checked", () -> {
throw new IOException("file is unavailable");
});
inTransaction("swallowed", () -> {
try {
throw new IllegalStateException("item is out of stock");
} catch (RuntimeException swallowed) {
System.out.println(" caught it and kept quiet");
}
});
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Three runs, three outcomes, and only the first rolls back. Two ways to fix it:
- explicitly state what to roll back on:
@Transactional(rollbackFor = Exception.class); - in your own code, throw subclasses of
RuntimeException— then rollback works "out of the box".
Propagation — what to do with an already-open transaction
Propagation answers the question: a method was called inside an already-running transaction — should it join that one or open its own? There are seven modes; three matter in practice.
REQUIRED — the default
"If there's a transaction, join it; if not, open a new one." Everything runs in a single transaction with a shared commit/rollback. This is the default behavior, and 90% of methods get it.
@Transactional // REQUIRED
public void placeOrder(Order order) {
orderRepo.save(order);
auditService.log(order); // if log is also @Transactional — the same transaction
}
Consequence: if something fails at the end, everything rolls back, including the audit record.
REQUIRES_NEW — always its own transaction
The current transaction is suspended, a new and independent one is opened. It commits on its own, after which the original transaction continues.
@Transactional
public void process(Order order) {
orderRepo.save(order);
auditService.log(order); // REQUIRES_NEW — a separate transaction
throw new RuntimeException("..."); // the main one rolls back,
// but the audit record is already saved
}
class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(Order order) { ... }
}
Needed for independent side actions — audit, journal, notification: "the main task failed, but the fact of the attempt must remain".
NESTED — a savepoint inside a transaction
Creates a savepoint inside the current transaction. If the nested chunk fails, only the part up to the savepoint is rolled back, not the whole transaction.
@Transactional
public void processBatch(List<Order> orders) {
for (var order : orders) {
try {
processor.tryProcess(order); // NESTED
} catch (Exception e) {
log.warn("skipped: {}", order.id()); // one bad item doesn't wreck the whole batch
}
}
}
What matters is that the nested method really is NESTED. With a plain REQUIRED the caught exception would still mark the shared transaction rollback-only, and the outer method would fail at commit with UnexpectedRollbackException — the whole batch would go down.
NESTED works where the transaction manager knows about savepoints: DataSourceTransactionManager (JDBC on top of PostgreSQL) does, while JpaTransactionManager throws NestedTransactionNotSupportedException by default — a savepoint would roll back the connection but not the JPA entity cache.
The other four — briefly
MANDATORY— requires an already-open transaction, otherwise an error. "I must not be called outside a transaction."SUPPORTS— if there's a transaction, I'll join it; if not, I'll run without one.NOT_SUPPORTED— suspend the transaction and run without it.NEVER— if a transaction exists, throw an error.
Isolation — how transactions see one another
Isolation determines how much parallel transactions "see" each other's uncommitted changes. The higher the level, the fewer strange effects from concurrency — and the higher the cost.
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transfer(AccountId from, AccountId to, Money amount) { ... }
There are four levels plus DEFAULT (taken from the database settings — in PostgreSQL that's READ_COMMITTED). The default is enough for most tasks, and changing the level should be a deliberate decision — details are in the article on ACID in PostgreSQL.
One caveat: at high levels (SERIALIZABLE) the database may reject one of two conflicting transactions with an error. Such code must be able to retry, otherwise you get random failures under load.
readOnly = true — the "read only" marker
If a method only reads and writes nothing, mark the transaction as read-only:
@Transactional(readOnly = true)
public List<Order> findByCustomer(CustomerId id) {
return repo.findByCustomerId(id);
}
Why you'd want this (especially with JPA/Hibernate):
- Hibernate turns off change tracking — it doesn't compare objects "before and after" and doesn't issue extra queries;
- if the application is set up to route reads to a replica (usually through
AbstractRoutingDataSource), it is thereadOnlyflag that tells it the query can go there.
It's a cheap win, and worth putting on every read-only method.
Multiple databases, or a database plus Kafka — why "one transaction" won't work
Sometimes you want to wrap two databases, or a database and sending a message to a queue, into a single transaction. Technically Spring has mechanisms for this, but in practice it's not done:
- it's slow — you need synchronous coordination between systems;
- it's fragile — if one of the systems hangs, the transaction is left in an undefined state;
- many cloud databases don't support this mode.
The correct approach for such cases is the Outbox pattern (write an "outgoing" table into the same database, and a separate process then dispatches the messages) and Saga. Details are in Distributed Patterns.
In short
- A transaction is a group of database operations on an "all or nothing" basis;
@Transactionalremoves the manual boilerplate: Spring opens, commits, and rolls it back for you. - It works through a proxy: the call must go through the wrapper. A call through
this, aprivateorfinalmethod bypasses it — and there is simply no transaction there. - An exception swallowed by
try/catchnever reaches the proxy, so the transaction commits. - By default the rollback happens only on
RuntimeException/Error; on checked exceptions the transaction commits. Fixed viarollbackFor. - Propagation:
REQUIRED(the default),REQUIRES_NEW(a separate transaction for audit records),NESTED(a savepoint, and only where the manager supports one). - Change Isolation deliberately and retry the operation on a conflict;
readOnly = truegoes on every read-only method; two databases or a database plus a queue mean Outbox and Saga, not one transaction.
What to read next
- ACID and isolation levels in PostgreSQL — what the isolation levels actually guarantee.
- Spring Data JPA — how transactions relate to the JPA session and the OSIV problem.
- Spring Events —
@TransactionalEventListenerand events tied to transaction phases. - Spring AOP — how annotations like
@Transactionalturn a bean into a proxy.