Most Spring Boot developers use @Transactional without understanding isolation levels. Read phenomena, deadlocks, and ghost rows are all caused by choosing the wrong isolation level for the job.
JOptimize Team
Every Spring Boot developer uses @Transactional. Very few understand what it actually does beyond "makes things atomic". Isolation levels, propagation modes, read phenomena — these aren't academic concepts. They're the explanation for real bugs: inconsistent read counts, phantom records appearing, and mysterious deadlocks under load.
This guide explains what each isolation level does, when to use each, and the transaction mistakes that cause subtle production bugs.
At its core, @Transactional tells Spring to:
RuntimeException is thrownBut this is just the beginning. The annotation also controls:
Without isolation, concurrent transactions see each other's in-progress changes. Three read phenomena describe the problems:
Dirty Read: Transaction A reads data that Transaction B has modified but not yet committed. If B rolls back, A has read data that never existed.
Non-repeatable Read: Transaction A reads a row, then Transaction B updates and commits that row. If A reads the same row again, it gets a different value — inconsistent within the same transaction.
Phantom Read: Transaction A queries rows matching a condition ("WHERE amount > 1000"). Transaction B inserts a new row matching that condition and commits. If A queries again, it sees a new row that wasn't there before — a "phantom".
READ_UNCOMMITTED (not supported by PostgreSQL) — reads uncommitted data from other transactions. Allows all three read phenomena. Almost never appropriate.
READ_COMMITTED (PostgreSQL default) — only reads committed data. Prevents dirty reads. Allows non-repeatable reads and phantom reads. Appropriate for most operations.
REPEATABLE_READ (MySQL default) — reads are consistent within a transaction. Prevents dirty reads and non-repeatable reads. Allows phantom reads (though PostgreSQL's implementation prevents them too). Appropriate for operations that read the same data multiple times.
SERIALIZABLE — full isolation. Transactions execute as if they were serial (one at a time). Prevents all three read phenomena. High performance cost due to locking/MVCC overhead. Appropriate for financial calculations and inventory.
// READ_COMMITTED (default) — fine for most operations @Transactional(isolation = Isolation.READ_COMMITTED) public OrderDto createOrder(OrderRequest req) { // If another transaction updates product price between our read and commit, // we might use a stale price — usually acceptable for order creation Product product = productRepo.findById(req.getProductId()).orElseThrow(); Order order = Order.create(product, req.getQuantity()); return OrderMapper.toDto(orderRepo.save(order)); } // REPEATABLE_READ — for operations that read and then use the same data twice @Transactional(isolation = Isolation.REPEATABLE_READ) public void processPayment(Long orderId) { Order order = orderRepo.findById(orderId).orElseThrow(); // First read validateOrder(order); // ... external payment call ... // Second read of the same order must return the same state Order orderForUpdate = orderRepo.findById(orderId).orElseThrow(); orderForUpdate.markAsPaid(); orderRepo.save(orderForUpdate); } // SERIALIZABLE — for financial operations where phantom reads matter @Transactional(isolation = Isolation.SERIALIZABLE) public void transferFunds(Long fromAccountId, Long toAccountId, BigDecimal amount) { // If another transaction inserts a new debit between our reads, // READ_COMMITTED would miss it — SERIALIZABLE prevents this Account from = accountRepo.findById(fromAccountId).orElseThrow(); Account to = accountRepo.findById(toAccountId).orElseThrow(); if (from.getBalance().compareTo(amount) < 0) { throw new InsufficientFundsException(); } from.debit(amount); to.credit(amount); accountRepo.save(from); accountRepo.save(to); } // READ_ONLY — optimization for read-heavy operations @Transactional(readOnly = true) public List<OrderSummaryDto> getOrderHistory(Long customerId) { // Hibernate skips dirty checking, DB may use read replica return orderRepo.findSummariesByCustomerId(customerId); }
Propagation defines what happens when a @Transactional method calls another @Transactional method:
// REQUIRED (default) — join existing transaction, or start new one @Transactional(propagation = Propagation.REQUIRED) // Default public void processOrder(Order order) { // If called within a transaction, joins it // If called without a transaction, starts one orderRepo.save(order); inventoryService.reserveStock(order); // Joins this transaction } // REQUIRES_NEW — always start a new transaction, suspend existing one @Transactional(propagation = Propagation.REQUIRES_NEW) public void writeAuditLog(AuditEntry entry) { // This ALWAYS runs in its own transaction // Even if the caller transaction rolls back, the audit log is committed auditRepo.save(entry); } // MANDATORY — must be called within an existing transaction @Transactional(propagation = Propagation.MANDATORY) public void updateInventory(Long productId, int quantity) { // Throws if no active transaction — enforces that caller must manage the tx inventoryRepo.updateStock(productId, quantity); } // NOT_SUPPORTED — suspend existing transaction and run without one @Transactional(propagation = Propagation.NOT_SUPPORTED) public void callExternalService(ExternalRequest req) { // DB transaction should not be held open during slow external calls externalApi.process(req); }
By default, @Transactional only rolls back on RuntimeException and Error, not on checked exceptions:
// DANGEROUS: IOException is checked — no rollback! @Transactional public void saveAndSendEmail(Order order) throws IOException { orderRepo.save(order); // Saved to DB emailService.sendEmail(order); // Throws IOException // Order is committed even though email sending failed! } // CORRECT: explicitly rollback on checked exceptions @Transactional(rollbackFor = {IOException.class, Exception.class}) public void saveAndSendEmail(Order order) throws IOException { orderRepo.save(order); emailService.sendEmail(order); // Now causes rollback } // OR: catch and rethrow as RuntimeException @Transactional public void saveAndSendEmail(Order order) { orderRepo.save(order); try { emailService.sendEmail(order); } catch (IOException e) { throw new RuntimeException("Email failed", e); // Triggers rollback } }
This is one of the most common and most surprising Spring bugs:
@Service public class OrderService { @Transactional public void processOrder(Long orderId) { // This works — called through Spring proxy } public void batchProcess(List<Long> orderIds) { orderIds.forEach(id -> this.processOrder(id)); // PROBLEM! // 'this' bypasses the Spring proxy — @Transactional is IGNORED! // processOrder runs without a transaction } } // Fix: inject self through the proxy @Service @RequiredArgsConstructor public class OrderService { @Autowired @Lazy // Breaks circular dependency private OrderService self; public void batchProcess(List<Long> orderIds) { orderIds.forEach(id -> self.processOrder(id)); // Goes through proxy } @Transactional public void processOrder(Long orderId) { ... } }
Understanding Spring Boot transactions means knowing when to use each isolation level (READ_COMMITTED for most operations, SERIALIZABLE for financial calculations), how propagation works when methods call each other, that rollback only happens for RuntimeException by default, and that @Transactional on private methods or through self-invocation is silently ignored. Getting these right prevents the class of bugs that only appear under concurrent load in production.
JOptimize detects self-invocation issues, @Transactional on @Async, @Transactional on private methods, and long-running transactions spanning external calls.
Understand your transactions. Prevent the subtle bugs.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.