@Transactional is one short annotation that hides a lot of non-obvious machinery. As long as everything works, you don't have to think about it. When it stops working, you discover that the annotation doesn't behave the way it seems. Let's start from scratch: what a transaction is, how Spring turns it on, and where the most common pitfalls are.
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. That's verbose and easy to get wrong — forget the rollback, forget to close the connection.
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. You mark a method with the annotation, and Spring itself opens a transaction before the method, commits after it completes successfully, 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
To understand the pitfalls, you need to know the key fact: @Transactional works through a proxy.
A proxy is a wrapper object. When you mark a bean with the annotation, Spring puts into the container not your object itself, but a wrapper around it. 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".
Call → [Proxy: open transaction] → your method → [Proxy: commit / rollback]
This is convenient, but it creates several pitfalls: everything hinges on the call going through the proxy. If the call bypasses the wrapper, there will be no transaction.
Pitfall 1: calling through this (self-invocation)
The most common one. When a method calls another method of the same class through this (explicitly or implicitly), the call goes directly, bypassing the proxy. The annotation on the second method won't fire.
@Service
public class OrderService {
public void processBatch(List<Order> orders) {
orders.forEach(this::processOne); // call through this — bypasses the proxy
}
@Transactional
public void processOne(Order order) {
// the transaction will NOT open, because it was called through this
}
}
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 {
public void process(Order order) {
save(order);
}
@Transactional // ignored — the method is private
private void save(Order order) { ... }
}
The rule is simple: put @Transactional only on public methods. For the same reason the annotation won't work on a final method or a final class — the proxy cannot override it.
Pitfall 3: a swallowed exception
A rollback happens only if the exception is thrown out of the method to the outside, up to 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 something goes wrong inside a transaction and it requires a rollback, the exception must 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.
@Transactional
public void process() throws IOException {
repo.save(...);
throw new IOException("..."); // the transaction WILL COMMIT, not roll back
}
This surprises many people. There are 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
}
}
}
Works on top of JDBC and PostgreSQL (via SAVEPOINT).
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 (the isolation level) determines how much parallel transactions "see" each other's uncommitted changes. The higher the level, the fewer strange effects from concurrency, but the more expensive it is in terms of performance.
@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). To start with, it's enough to know that the default value is sufficient for most tasks, and that changing the level should be a deliberate decision — details about the levels themselves are in the article on ACID in PostgreSQL.
One practical warning: at high levels (for example SERIALIZABLE) the database may reject one of the conflicting concurrent transactions with an error. That's why such code must be able to retry the operation, otherwise you'll 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;
- the connection pool may route the query to a read replica, if one is configured.
A cheap improvement: put readOnly = true on any method that only reads.
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: either commit or rollback.
@Transactionalremoves the manual boilerplate: Spring itself opens, commits, and rolls the transaction back.- It works through a proxy — the call must go through the wrapper, otherwise there will be no transaction.
- It won't fire on: a call through
this(self-invocation),private/finalmethods, an exception swallowed bytry/catch. - Put
@Transactionalonly onpublicmethods. - By default the rollback happens only on
RuntimeException/Error; on checked exceptions the transaction commits. Fixed viarollbackFor. - Propagation:
REQUIRED(the default, join/open),REQUIRES_NEW(a separate transaction),NESTED(savepoint). - Change Isolation deliberately; at high levels the code must be able to retry the operation on a conflict.
readOnly = true— for methods that only read; a cheap win with JPA.- Don't wrap multiple databases / a database + a queue into one transaction — use Outbox and Saga.
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.