@Transactional
Runs a method in a database transaction through a Spring proxy: commit on success, roll back on unchecked exceptions, plus propagation and read-only options.
@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.DEFAULT,
readOnly = false, timeout = -1, rollbackFor = {}, noRollbackFor = {})- Added in Spring Boot Spring Framework 1.2
Parameters
| Parameter | Type | Description |
|---|---|---|
$propagationoptional | Propagation= Propagation.REQUIRED | What happens when a transaction already exists: REQUIRED joins it, REQUIRES_NEW suspends it and starts a new one, plus MANDATORY, SUPPORTS, NOT_SUPPORTED, NEVER and NESTED. |
$isolationoptional | Isolation= Isolation.DEFAULT (the database's default) | Isolation level for a new transaction, such as READ_COMMITTED or SERIALIZABLE. |
$readOnlyoptional | boolean= false | Hint that the transaction does not write; Hibernate skips dirty checking and flushing. |
$timeoutoptional | int= -1 (database default) | Seconds before the transaction is marked for rollback. timeoutString accepts a placeholder. |
$rollbackForoptional | Class<? extends Throwable>[]= {} | Extra exception types that trigger rollback, typically checked exceptions. |
$noRollbackForoptional | Class<? extends Throwable>[]= {} | Exception types that must not trigger rollback. |
$value / transactionManageroptional | String= the primary transaction manager | Bean name of the transaction manager when there is more than one. |
$labeloptional | String[]= {} | Labels describing the transaction, available to the transaction manager. |
Return value
n/aWraps the method (or every method of the class) in a transaction managed by the PlatformTransactionManager.
Errors
UnexpectedRollbackException when an inner REQUIRED call marked the shared transaction rollback-only and the outer method tries to commit.
@Transactional on a bean method makes Spring open a transaction before the method runs and commit it when the method returns. Spring Boot enables this automatically when a DataSource or JPA starter is present; you need no @EnableTransactionManagement. Put it on service methods that make several changes which must succeed or fail together.
It works through a proxy
Other beans receive a proxy of your service, and the proxy starts the transaction. Three consequences catch everyone at least once:
- Self-invocation is not intercepted. Calling
this.save()from another method of the same class skips the proxy, so the annotation onsave()does nothing. Move the method to another bean, or annotate the outer method. - Private methods are never transactional. The proxy cannot override them, and Spring does not warn you.
- Objects you create with
neware not beans and get no transactions.
Rollback rules
By default only unchecked exceptions (RuntimeException and Error) roll back. A checked exception such as IOException thrown from a transactional method commits whatever was written before it. Add rollbackFor = Exception.class where that matters, or set @EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS) to change the default for the whole application. Catching an exception inside the method means the proxy never sees it, so nothing rolls back.
When an inner REQUIRED method throws and the outer method catches the exception and carries on, the shared transaction is already marked rollback-only, and the commit fails with UnexpectedRollbackException. Use REQUIRES_NEW on the inner method if its work really is independent, such as writing an audit row that must survive.
Read-only and timeouts
readOnly = true on query methods lets Hibernate skip dirty checking and flushing, and passes a read-only hint to the driver, which some setups use to route to a replica. A common layout is @Transactional(readOnly = true) on the class with plain @Transactional on the writing methods.
Production consequences
A transaction holds a pooled connection from the first query until commit. Calling a slow HTTP service or sending email inside one ties up a connection for seconds; under load the pool runs dry and unrelated requests time out. Keep transactions short, do remote calls before or after, and publish follow-up work with transactional events. Spring Data's built-in repository methods are already transactional, so a single save() needs nothing extra.
Example
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
class Account {
private Long id;
private BigDecimal balance;
protected Account() { }
void withdraw(BigDecimal amount) {
if (balance.compareTo(amount) < 0) {
throw new IllegalStateException("insufficient funds");
}
balance = balance.subtract(amount);
}
void deposit(BigDecimal amount) { balance = balance.add(amount); }
BigDecimal getBalance() { return balance; }
}
interface AccountRepository extends JpaRepository<Account, Long> { }
(readOnly = true)
public class TransferService {
private final AccountRepository accounts;
public TransferService(AccountRepository accounts) { this.accounts = accounts; }
public List<Account> all() {
return accounts.findAll(); // read-only: no dirty checking at the end
}
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = accounts.findById(fromId).orElseThrow();
Account to = accounts.findById(toId).orElseThrow();
from.withdraw(amount); // throws IllegalStateException: both changes roll back
to.deposit(amount);
// no save() calls: dirty checking writes both UPDATEs at commit
// concurrent transfers still need @Version or a pessimistic lock to avoid lost updates
}
}When to use it
- Transferring money between two accounts so that both balances change or neither does.
- Marking a whole query service read-only and opting individual write methods back in.
- Writing an audit record in REQUIRES_NEW so it survives even when the business operation rolls back.
Frequently asked questions
Why is my @Transactional method not rolling back?
Usually one of three things: the exception is checked (add rollbackFor), the exception is caught inside the method, or the method is called from the same class so the proxy is bypassed.
Should @Transactional go on the controller, service or repository?
On the service. It defines the unit of work. Repository methods are already transactional individually, and controllers should not hold transactions open while handling HTTP concerns.
Can I use jakarta.transaction.Transactional instead?
Spring honours it too, but its attributes differ (rollbackOn, dontRollbackOn, no readOnly). Use Spring's annotation for access to readOnly, timeout and propagation options.
More examples
Rolling back on a checked exception
Without rollbackFor, the checked ReportStorageException would let the insert commit, leaving a PUBLISHED row for a file that does not exist. JdbcClient takes part in the same transaction as JPA, since both use the same DataSource. The upload still runs while the transaction holds a connection; for large files, upload first and insert afterwards, deleting the file if the insert fails.
import java.io.IOException;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
class ReportStorageException extends Exception {
ReportStorageException(String message, Throwable cause) { super(message, cause); }
}
interface ReportStore {
void upload(String name, byte[] content) throws IOException;
}
@Service
public class ReportService {
private final JdbcClient jdbc;
private final ReportStore store;
public ReportService(JdbcClient jdbc, ReportStore store) {
this.jdbc = jdbc;
this.store = store;
}
@Transactional(rollbackFor = ReportStorageException.class)
public void publish(String name, byte[] pdf) throws ReportStorageException {
jdbc.sql("insert into report (name, status) values (:name, 'PUBLISHED')")
.param("name", name)
.update();
try {
store.upload(name, pdf);
} catch (IOException e) {
throw new ReportStorageException("upload failed for " + name, e);
}
}
}Self-invocation and how to fix it
If writeOne lived in InvoiceBatch and were called as this.writeOne(id), the annotation would be ignored. Moving it to a separate bean puts the proxy back in the call path.
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
class InvoiceWriter {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeOne(String invoiceId) {
// each invoice commits on its own; one failure does not undo the others
}
}
@Service
public class InvoiceBatch {
private final InvoiceWriter writer;
public InvoiceBatch(InvoiceWriter writer) { this.writer = writer; }
public int run(List<String> ids) {
int failed = 0;
for (String id : ids) {
try {
writer.writeOne(id); // goes through the proxy, so REQUIRES_NEW applies
} catch (RuntimeException e) {
failed++;
}
}
return failed;
}
}Rollback in tests
On a test class, @Transactional means roll back after each test rather than commit. Beware: code that runs in another thread or uses REQUIRES_NEW commits for real and is not cleaned up.
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.transaction.annotation.Transactional;
@SpringBootTest
@Transactional
class TagWriteTests {
@Autowired
JdbcClient jdbc;
@Test
void insertIsVisibleInsideTheTestTransaction() {
jdbc.sql("insert into tag (label) values ('draft')").update();
Integer count = jdbc.sql("select count(*) from tag where label = 'draft'")
.query(Integer.class).single();
assertThat(count).isEqualTo(1);
// rolled back after the test, so other tests start clean
}
}
Discussion