Transaction Propagation
Decide what a transactional method does when it is called inside an existing transaction: join it, start a new one, or refuse to run.
@Transactional(propagation = Propagation.REQUIRED) // default: join or start
@Transactional(propagation = Propagation.REQUIRES_NEW) // separate transaction
@Transactional(propagation = Propagation.MANDATORY) // fail if nonePropagation answers one question: when a @Transactional method is called and a transaction is already running, what should happen? You set it with @Transactional(propagation = Propagation.X). The default, REQUIRED, is right most of the time, but the others solve specific problems.
| Propagation | Transaction already running | No transaction running |
|---|---|---|
REQUIRED | Joins it | Starts one |
REQUIRES_NEW | Suspends it and starts a separate one | Starts one |
MANDATORY | Joins it | Throws |
SUPPORTS | Joins it | Runs without one |
NOT_SUPPORTED | Suspends it, runs without one | Runs without one |
NEVER | Throws | Runs without one |
NESTED | Runs inside it using a savepoint | Starts one |
REQUIRED and the rollback-only trap
With REQUIRED, the inner method shares the outer transaction. If the inner method throws an unchecked exception, Spring marks the whole shared transaction rollback-only, even if the outer method catches the exception and carries on. When the outer method then returns, the commit fails with UnexpectedRollbackException. Catching an exception from a joined transactional call does not rescue the transaction.
REQUIRES_NEW for work that must survive
REQUIRES_NEW commits or rolls back independently. Use it for an audit record or a failed-attempt counter that must be kept even when the business operation rolls back. The cost: the outer transaction is suspended but keeps its connection, and the new one takes a second connection from the pool. If every request does this, a pool of ten connections stalls as soon as ten requests each hold an outer connection and wait for a second one; they stay stuck until the pool's connection timeout fails them. The inner transaction also cannot see uncommitted rows written by the outer one, and if it updates a row the outer transaction has already locked, it waits on that lock until a timeout ends it.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String action, String detail) {
auditLog.save(new AuditEntry(action, detail));
}The others
MANDATORY documents and enforces that a method must only run as part of a larger operation, which catches callers that forgot to start one. NOT_SUPPORTED marks a slow step, such as building a report file, that must never run inside a transaction; the caller's transaction is suspended, but its connection is not returned to the pool while it waits. NESTED rolls back to a savepoint on failure while keeping the outer work; it is reliable with plain JDBC, but with JPA rolling back to a savepoint does not undo changes already held in the persistence context, so avoid it there.
Like every transaction attribute, propagation only applies when the call goes through the Spring proxy. A REQUIRES_NEW method called from inside its own class simply joins the current transaction.
Example
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.time.Instant;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
class AuditEntry {
private Long id;
private String action;
private String detail;
private Instant at;
protected AuditEntry() {
}
AuditEntry(String action, String detail) {
this.action = action;
this.detail = detail;
this.at = Instant.now();
}
}
interface AuditRepository extends JpaRepository<AuditEntry, Long> {
}
public class AuditService {
private final AuditRepository entries;
AuditService(AuditRepository entries) {
this.entries = entries;
}
// Commits on its own, so the entry is kept even if the caller's transaction rolls back.
(propagation = Propagation.REQUIRES_NEW)
public void record(String action, String detail) {
entries.save(new AuditEntry(action, detail));
}
}
When to use it
- Keep a record of every failed payment attempt even though the payment itself rolls back.
- Guarantee that ledger lines are only ever written in the same transaction as the balance they explain.
- Explain an UnexpectedRollbackException in the logs after an inner service call failed and was caught.
More examples
Why catching a joined failure still rolls back
Both methods use <code>REQUIRED</code>, so they share one physical transaction. The exception passes through <code>LoyaltyService</code>'s proxy, which marks the shared transaction rollback-only before the catch block runs. Two honest fixes exist: make <code>addPoints</code> validate without throwing and return a result, or give it <code>REQUIRES_NEW</code> if the points really are a separate unit of work whose failure the order can ignore.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class LoyaltyService {
@Transactional
public void addPoints(long customerId, int points) {
if (points <= 0) {
throw new IllegalArgumentException("points must be positive");
}
// update the customer's points
}
}
@Service
public class CheckoutService {
private final LoyaltyService loyalty;
CheckoutService(LoyaltyService loyalty) {
this.loyalty = loyalty;
}
@Transactional
public void checkout(long customerId, int points) {
// save the order here
try {
loyalty.addPoints(customerId, points);
} catch (IllegalArgumentException e) {
// Too late: addPoints joined this transaction and Spring has already
// marked it rollback-only. Returning normally now makes the commit throw
// UnexpectedRollbackException and the order is not saved.
}
}
}
MANDATORY to enforce a caller's transaction
<code>MANDATORY</code> turns an unwritten rule into a check. A developer who later calls <code>post</code> from a scheduled job without a transaction gets an <code>IllegalTransactionStateException</code> on the first run in development, instead of ledger lines that silently commit without their matching balance changes in production.
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.math.BigDecimal;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Entity
class LedgerLine {
@Id
@GeneratedValue
private Long id;
private long accountId;
private BigDecimal amount;
protected LedgerLine() {
}
LedgerLine(long accountId, BigDecimal amount) {
this.accountId = accountId;
this.amount = amount;
}
}
interface LedgerRepository extends JpaRepository<LedgerLine, Long> {
}
@Service
public class LedgerWriter {
private final LedgerRepository lines;
LedgerWriter(LedgerRepository lines) {
this.lines = lines;
}
// A ledger line on its own is meaningless: it must be written in the same
// transaction as the balance change. Calling this without one throws
// IllegalTransactionStateException.
@Transactional(propagation = Propagation.MANDATORY)
public void post(long accountId, BigDecimal amount) {
lines.save(new LedgerLine(accountId, amount));
}
}
Keeping a slow step out of the transaction
<code>NOT_SUPPORTED</code> suspends the caller's transaction for the duration of the method. Be aware that suspension does not hand the connection back to the pool: the caller's transaction still owns it. The real win comes from structuring the caller so that the transactional query finishes first and the slow step runs after it, and this annotation makes sure nobody later wraps the slow step back inside one.
import java.nio.file.Files;
import java.nio.file.Path;
import java.io.IOException;
import java.io.UncheckedIOException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ReportWriter {
// Suspends any caller transaction while the file is written, so the
// caller's connection and locks are not tied up by disk I/O.
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public Path write(String csv) {
try {
Path file = Files.createTempFile("report-", ".csv");
Files.writeString(file, csv);
return file;
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
Discussion