Definition
- Flush mode MANUAL – Hibernate sets
FlushMode.MANUALso it does not auto-flush pending changes before queries or at commit, avoiding unnecessary database writes. - Dirty-checking skip – Hibernate can skip taking entity snapshots and comparing them at flush time, saving CPU and heap memory when loading thousands of rows for reports.
- JDBC hint – Spring calls
connection.setReadOnly(true), which the JDBC spec defines as a hint that drivers may use for optimization (some databases reject writes on read-only connections). - Replica routing – an
AbstractRoutingDataSourcecan checkTransactionSynchronizationManager.isCurrentTransactionReadOnly()and route read-only transactions to a replica. - Hint, not guarantee – most databases and drivers do not enforce read-only at the connection level; writes inside a read-only transaction silently do nothing (Hibernate skips the flush) rather than throwing an error.
Rajesh visits the National Bank of India just to check his balance on account SAV2001 and download a statement. He is not changing anything. The bank tells the clerk: "this is a read-only visit." The clerk does not bother re-checking every ledger page for edits before Rajesh leaves (that is skipping dirty-checking), and the bank sends Rajesh to the fast "enquiry only" counter instead of the busy "transactions" counter (that is read replica routing). But the clerk is trusting Rajesh: if he somehow scribbles on a page, the system does not stop him because read-only is a polite instruction, not a locked vault.
Understanding the Concepts
What does @Transactional(readOnly = true) do?
When you write @Transactional(readOnly = true) on a service method, Spring sets the readOnly flag on the transaction definition. Spring then passes this flag down to the underlying resources: the JDBC connection and the Hibernate Session. The result is three optimizations: flush mode set to MANUAL, dirty-checking skipped, and a hint for read-replica routing.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class BalanceInquiryService {
private final AccountRepository accountRepo;
public BalanceInquiryService(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
@Transactional(readOnly = true)
public BigDecimal getBalance(String accountId) {
Account account = accountRepo.findById(accountId);
return account.getBalance();
}
}
Why use readOnly = true?
There are three reasons to use the readOnly flag on query methods.
First, performance. Hibernate normally keeps a snapshot of every loaded entity so it can detect changes at flush time. For a report that loads 10,000 transaction rows, that means 10,000 snapshots in memory. With readOnly, Hibernate skips those snapshots entirely, saving both heap memory and the CPU time spent comparing them.
Second, read-replica routing. In production, heavy read traffic (statements, reports, balance checks) can be offloaded to database replicas, keeping the primary free for writes. The readOnly flag is the signal that a routing DataSource uses to pick the replica.
Third, intent documentation. Marking a method as readOnly makes it clear to other developers that this method does not modify data. It serves as a lightweight contract.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional(readOnly = true)
public class StatementService {
private final TransactionHistoryRepository historyRepo;
public StatementService(TransactionHistoryRepository historyRepo) {
this.historyRepo = historyRepo;
}
// Loads 10,000 rows without dirty-checking snapshots
public List<TransactionHistory> getAnnualStatement(String accountId, int year) {
return historyRepo.findByAccountIdAndYear(accountId, year);
}
}
How does flush mode MANUAL work?
In a normal read-write transaction, Hibernate sets the flush mode to FlushMode.AUTO. This means Hibernate automatically flushes (writes) pending entity changes to the database before each query to ensure the query sees the latest state, and again at commit time. Flushing involves generating SQL UPDATE and INSERT statements for every dirty entity.
When readOnly is true, Spring sets the flush mode to FlushMode.MANUAL. This means Hibernate will not auto-flush at all. Since the transaction is read-only, there should be no pending changes to flush, so nothing is lost. The benefit is avoiding the overhead of checking whether a flush is needed before every query.
package com.bank.service;
import org.hibernate.Session;
import org.hibernate.FlushMode;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class FlushModeDemo {
@PersistenceContext
private EntityManager em;
@Transactional(readOnly = true)
public void showFlushMode() {
Session session = em.unwrap(Session.class);
System.out.println("Flush mode: " + session.getHibernateFlushMode());
}
@Transactional
public void showWritableFlushMode() {
Session session = em.unwrap(Session.class);
System.out.println("Flush mode: " + session.getHibernateFlushMode());
}
}
Flush mode: MANUAL
Flush mode: AUTO
How does dirty-checking get skipped?
Dirty-checking is the process where Hibernate compares the current state of each loaded entity against its original snapshot to determine what UPDATE statements to generate. In a read-write transaction, Hibernate creates a snapshot (a copy of all field values) for every entity loaded from the database. At flush time, it walks every entity, compares current values to the snapshot, and generates SQL for any differences.
In a read-only transaction, Hibernate can skip taking these snapshots entirely. Since the flush mode is MANUAL and no flush will happen, there is no need to track changes. This is significant for report queries that load thousands of entities.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
public class ReportService {
private final AccountRepository accountRepo;
public ReportService(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
// Loads 50,000 accounts: readOnly saves 50,000 snapshots in memory
@Transactional(readOnly = true)
public BigDecimal getTotalDeposits() {
List<Account> all = accountRepo.findByType("SAVINGS");
return all.stream()
.map(Account::getBalance)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
How does read-replica routing work?
Many production systems use an AbstractRoutingDataSource that picks the database connection based on the current transaction's readOnly flag. Spring exposes the flag through TransactionSynchronizationManager.isCurrentTransactionReadOnly().
package com.bank.config;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.support.TransactionSynchronizationManager;
public class ReadReplicaRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
boolean readOnly = TransactionSynchronizationManager
.isCurrentTransactionReadOnly();
return readOnly ? "REPLICA" : "PRIMARY";
}
}
package com.bank.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
import java.util.Map;
@Configuration
public class DataSourceConfig {
@Bean
public DataSource dataSource(
DataSource primaryDataSource,
DataSource replicaDataSource) {
ReadReplicaRoutingDataSource routing = new ReadReplicaRoutingDataSource();
routing.setTargetDataSources(Map.of(
"PRIMARY", primaryDataSource,
"REPLICA", replicaDataSource));
routing.setDefaultTargetDataSource(primaryDataSource);
return routing;
}
}
With this setup, every @Transactional(readOnly = true) method automatically queries the replica, and every writable @Transactional method queries the primary. Priya's balance enquiry at National Bank of India goes to the replica, while Rajesh's transfer goes to the primary.
Is readOnly a guarantee or only a hint?
ReadOnly is a hint and an optimization, not a hard guarantee. At the JDBC level, Spring calls connection.setReadOnly(true), but the JDBC specification says this is only a hint that drivers may use for optimization. Many drivers ignore it entirely.
With Hibernate, if you modify an entity inside a read-only transaction, Hibernate does not flush the change because the flush mode is MANUAL. The write silently does nothing rather than throwing an error. This is a dangerous trap for junior developers who expect either a successful save or an exception.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class SilentWriteDemo {
private final AccountRepository accountRepo;
public SilentWriteDemo(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
// WRONG: modifying entity inside readOnly transaction
@Transactional(readOnly = true)
public void buggyDeposit(String accountId, BigDecimal amount) {
Account account = accountRepo.findById(accountId);
account.credit(amount);
accountRepo.save(account);
// The save() appears to work, but Hibernate never flushes the change.
// The database balance remains unchanged. No error is thrown.
System.out.println("Save called, but nothing written to DB");
}
}
Save called, but nothing written to DB
What are common mistakes with readOnly?
Mistake 1: writing inside a readOnly method
// WRONG: write operation silently does nothing
@Transactional(readOnly = true)
public void updateStatus(String accountId, String status) {
Account account = accountRepo.findById(accountId);
account.setStatus(status);
accountRepo.save(account);
// Change is never flushed to the database
}
// CORRECT: remove readOnly for write methods
@Transactional(rollbackFor = Exception.class)
public void updateStatus(String accountId, String status) {
Account account = accountRepo.findById(accountId);
account.setStatus(status);
accountRepo.save(account);
// Change is flushed and committed
}
Mistake 2: not using readOnly on query methods
// WRONG: unnecessary dirty-checking on a pure read
@Transactional
public BigDecimal getBalance(String accountId) {
return accountRepo.findById(accountId).getBalance();
}
// CORRECT: readOnly skips dirty-checking
@Transactional(readOnly = true)
public BigDecimal getBalance(String accountId) {
return accountRepo.findById(accountId).getBalance();
}
What about PostgreSQL-specific behavior?
PostgreSQL respects SET TRANSACTION READ ONLY and will reject any write statement (INSERT, UPDATE, DELETE) with an error. Some JDBC drivers (like the PostgreSQL driver) translate connection.setReadOnly(true) into this SQL command, which provides a true database-level guarantee. This behavior is driver-specific and not part of the JDBC specification.
-- PostgreSQL enforces read-only at the database level
BEGIN;
SET TRANSACTION READ ONLY;
UPDATE account SET balance = 0 WHERE id = 'SAV2001';
-- ERROR: cannot execute UPDATE in a read-only transaction
What should a senior engineer know?
- ReadOnly saves significant memory on report queries. For Priya's annual statement loading 10,000 rows, Hibernate skips 10,000 entity snapshots, each potentially holding a dozen field copies.
- The performance benefit scales with the number of entities loaded. For a single-row lookup, the savings are negligible. For a bulk report, they are substantial.
- Combine readOnly with
@QueryHints(@QueryHint(name = HINT_FETCH_SIZE, value = "500"))for large reports to stream rows instead of loading them all into memory. - In Spring Data JPA repositories, derived query methods (findByStatus, findAll) run inside the repository's default transaction, which is readOnly by default when called from a readOnly service method.
- Never rely on readOnly as a safety mechanism to prevent accidental writes. Use it as an optimization and a documentation signal.
package com.bank.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.QueryHints;
import jakarta.persistence.QueryHint;
import java.util.List;
import static org.hibernate.jpa.HibernateHints.HINT_FETCH_SIZE;
public interface TransactionHistoryRepository
extends JpaRepository<TransactionHistory, Long> {
@QueryHints(@QueryHint(name = HINT_FETCH_SIZE, value = "500"))
List<TransactionHistory> findByAccountIdAndYear(String accountId, int year);
}
Visual Overview
@Transactional(readOnly = true)
│
▼
┌──────────────────────────────────────────────┐
│ AOP Proxy │
│ 1. connection.setReadOnly(true) │
│ 2. Hibernate FlushMode → MANUAL │
│ 3. Skip dirty-checking snapshots │
│ 4. AbstractRoutingDataSource → REPLICA │
└──────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Read Replica Database │
│ SELECT balance FROM account WHERE id='SAV2001'│
└──────────────────────────────────────────────┘
@Transactional (writable, default)
│
▼
┌──────────────────────────────────────────────┐
│ AOP Proxy │
│ 1. connection.setReadOnly(false) │
│ 2. Hibernate FlushMode → AUTO │
│ 3. Dirty-checking enabled (snapshots kept) │
│ 4. AbstractRoutingDataSource → PRIMARY │
└──────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Primary Database │
│ UPDATE account SET balance = ... WHERE id=... │
└──────────────────────────────────────────────┘
Key Takeaway
The readOnly flag on @Transactional tells Spring and Hibernate that this transaction will only read data and will not modify anything. Hibernate uses this to set the flush mode to MANUAL, which means it will not auto-flush changes before queries or at commit, and to skip the dirty-checking snapshots it normally keeps for every loaded entity. For Priya's annual statement at National Bank of India that loads 10,000 transaction rows, this saves 10,000 snapshots in memory and the CPU time to compare them.
The second major benefit is read-replica routing. An AbstractRoutingDataSource can check TransactionSynchronizationManager.isCurrentTransactionReadOnly() and return the replica connection for read-only transactions and the primary connection for writable ones. Marking query methods as readOnly means all the heavy statement and report queries automatically go to the replica, keeping the primary free for Rajesh's transfers and Priya's deposits.
The important nuance is that readOnly is a hint, not a guarantee. At the JDBC level, Spring calls setReadOnly(true) on the connection, but the spec says drivers may ignore it. With Hibernate, if you modify an entity inside a readOnly method, the change silently does nothing because flush mode is MANUAL. No error is thrown. This confuses junior developers who expect either a save or an exception. Never rely on readOnly as a safety mechanism to prevent writes; use it as an optimization and a documentation signal.
Remember one sentence: readOnly = true skips dirty-checking and enables replica routing, but it is an optimization hint, not a write-prevention lock, because writes silently do nothing instead of failing.
---