Transactions: Propagation, Isolation and Rollback
Definition
A transaction groups several database changes so they all succeed or all fail. @Transactional starts one when a method begins, commits when it ends normally, and rolls back on an unchecked exception.
Why it exists
Without transactions, a failure halfway through leaves data half updated. Doing begin, commit and rollback by hand in every method is repetitive and easy to get wrong.
How it works
How @Transactional works
Spring wraps the bean in a proxy. A call from outside goes to the proxy, which starts (or joins) a transaction, calls your method, then commits, or rolls back if the method throws.
Everything that confuses people about @Transactional follows from this proxy model.
Pitfall 1: self-invocation
public boolean outer() {
return inner(); // direct call on 'this', never passes through the proxy
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean inner() {
return TransactionSynchronizationManager.isActualTransactionActive();
}
Calling outer() returns false: inner() ran with no transaction because the call bypassed the proxy (verified by a test). Fixes: move inner to another bean, or inject the bean into itself / use TransactionTemplate for programmatic control.
Pitfall 2: visibility and final methods
Proxies can only intercept public methods on non-final classes (CGLIB). Annotating a private method does nothing. Put @Transactional on the public service method, not on repositories' callers' internals, and not on @Controllers.
Pitfall 3: rollback rules
By default, Spring rolls back only on unchecked exceptions (RuntimeException) and Error. A checked exception commits.
@Transactional
public void saveThenThrowChecked() throws Exception {
orders.save(newOrder());
throw new Exception("checked"); // the order IS committed
}
@Transactional(rollbackFor = Exception.class)
public void saveThenThrowCheckedWithRollback() throws Exception {
orders.save(newOrder());
throw new Exception("checked"); // rolled back
}
Also: if you catch the exception inside the method and swallow it, the proxy never sees it and commits. And an exception thrown in an inner REQUIRED method marks the shared transaction rollback-only, so the outer method's commit later fails with UnexpectedRollbackException even if the outer code caught the exception.
Propagation
What happens when a transactional method is called while a transaction already exists:
| Propagation | Behaviour |
|---|---|
REQUIRED (default) | Join the existing transaction, or create one if none |
REQUIRES_NEW | Suspend the current one, run in a brand-new independent transaction |
NESTED | Savepoint inside the current transaction (JDBC only; roll back to the savepoint without losing the outer work) |
SUPPORTS | Join if one exists, otherwise run without |
NOT_SUPPORTED | Suspend the current transaction, run without |
MANDATORY | Must be called inside a transaction, else exception |
NEVER | Must not be called inside a transaction, else exception |
Typical use of REQUIRES_NEW: write an audit record that must survive even if the main transaction rolls back.
@Service
class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(String message) {
repository.save(new AuditLog(message));
}
}
@Service
class OrderCommandService {
@Transactional
public void placeAndFail() {
newOrder();
audit.log("attempted order"); // committed independently
throw new IllegalStateException("payment failed"); // order rolled back, audit row stays
}
}
A test confirms: after placeAndFail(), there are 0 orders and 1 audit row. Be aware that REQUIRES_NEW takes a second database connection while the first is held; under load and with a small pool this can deadlock the pool.
Isolation levels
Isolation controls what concurrent transactions can see of each other's uncommitted or newly committed changes.
| Anomaly | Meaning |
|---|---|
| Dirty read | Reading another transaction's uncommitted data |
| Non-repeatable read | Same row read twice returns different values (another transaction committed an update) |
| Phantom read | Same query returns different sets of rows (another transaction inserted/deleted) |
| Level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
READ_UNCOMMITTED | possible | possible | possible |
READ_COMMITTED | prevented | possible | possible |
REPEATABLE_READ | prevented | prevented | possible (prevented in MySQL InnoDB via gap locks; PostgreSQL's snapshot isolation also avoids it) |
SERIALIZABLE | prevented | prevented | prevented |
Databases differ in their defaults and implementation: PostgreSQL and Oracle default to READ_COMMITTED, MySQL InnoDB defaults to REPEATABLE_READ. PostgreSQL never allows dirty reads (its READ_UNCOMMITTED behaves as READ_COMMITTED). Higher isolation costs throughput, and may raise serialization failures that the application must retry.
@Transactional(isolation = Isolation.REPEATABLE_READ)
public Report monthlyReport() { ... }
Most applications stay at the default and protect specific critical sections with locking or constraints.
Example code
Doing something after commit
Sending an email or publishing an event inside the transaction fires even if the transaction later rolls back. Use:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void onOrderPlaced(OrderPlacedEvent event) { ... }
This still loses the event if the application crashes right after commit; for reliable publishing use the outbox.
Programmatic transactions
TransactionTemplate tx = new TransactionTemplate(transactionManager);
Long id = tx.execute(status -> repository.save(new PurchaseOrder(c, "NEW")).id);
Useful when you need several transactions in one method, or fine control, and it avoids the proxy pitfalls.
System design considerations
- Keep transactions short and size the connection pool for the concurrency you allow
- Never call external services inside a transaction when you can avoid it
- Combine REQUIRES_NEW carefully, since it uses a second connection
readOnly, timeouts and sizing
@Transactional(readOnly = true)is a hint: Hibernate skips dirty checking (faster, less memory) and some drivers/pools route to replicas. It does not forbid writes on every database.timeoutbounds the transaction duration.- Keep transactions short. Never do slow remote calls (HTTP, messaging) inside a database transaction: you hold a connection and locks while waiting. Do the remote call before or after, or use the outbox pattern.
When to use it, and when not
Use it when
- A use case changes several rows or tables that must succeed or fail together
- You need a consistent read snapshot
- You run an after-commit side effect (TransactionalEventListener)
Think twice when
- The method makes slow remote calls while holding a connection and locks
- You call the transactional method from the same class (proxy bypass)
- The change spans several services, where a saga is needed instead
Common mistakes
- Self-invocation, private or final methods.
- Swallowing exceptions inside the transactional method.
- Expecting checked exceptions to roll back.
- Remote calls inside transactions.
REQUIRES_NEWin loops, exhausting the connection pool.- Assuming
readOnlyprevents writes. - Overly broad transactions spanning an entire request.
Interview questions
1. How does @Transactional work internally?
Spring AOP creates a proxy; its TransactionInterceptor obtains a connection and transaction through the PlatformTransactionManager, invokes your method, then commits or rolls back based on the outcome.
2. Why does @Transactional not work on an internal method call?
Self-invocation bypasses the proxy, so no interceptor runs. Move the method to another bean or use TransactionTemplate.
3. Which exceptions trigger rollback by default?
RuntimeException and Error. Checked exceptions commit unless you set rollbackFor.
4. Explain REQUIRED vs REQUIRES_NEW.
REQUIRED joins the caller's transaction (a failure rolls back both). REQUIRES_NEW suspends the caller's transaction and uses a separate one that commits or rolls back independently.
5. What are the isolation levels and which anomalies do they prevent?
See the table: read uncommitted, read committed, repeatable read, serializable, with dirty reads, non-repeatable reads and phantoms respectively eliminated as you go up; actual guarantees depend on the database engine.
6. What does readOnly = true do?
A hint that disables Hibernate flushing/dirty checking and may enable driver or routing optimisations. It is not a security feature.
7. What is UnexpectedRollbackException?
The outer transaction tried to commit but an inner participating transaction had already marked it rollback-only. It shows that an exception crossed a REQUIRED boundary even though someone caught it.
8. Where should @Transactional go?
On the public methods of the service layer that represent a business operation, not on controllers or repositories' low-level calls.
9. What happens to lazy loading outside a transaction?
It fails with LazyInitializationException (see N+1 and lazy loading).
Check yourself
1Which exceptions roll a @Transactional method back by default?
2What does Propagation.REQUIRES_NEW do?
3What does readOnly = true mainly provide?
Key takeaways
@Transactionalis proxy-based: external, public calls only.- Default rollback is on runtime exceptions; use
rollbackForfor checked ones. - Propagation decides joining versus new transactions;
REQUIRES_NEWcosts a connection. - Isolation trades correctness for concurrency; know the anomalies and your database's defaults.
- Keep transactions short and free of remote calls.
Next: Locking.
Next topicOptimistic and pessimistic lockingFinished this page? Track your progress (saved only in your browser).