Read-only Transactions
Mark query methods with @Transactional(readOnly = true) so Hibernate skips dirty checking and the database can route and optimise the work.
@Transactional(readOnly = true)
public List<Dto> query(...) { ... }
// class default read-only, writes opt back in
@Transactional(readOnly = true) class Service { @Transactional void write() {} }@Transactional(readOnly = true) tells Spring that a method only reads. It is a hint, not a security boundary, but with JPA it changes real behaviour and is worth adding to every query method in a service.
What the flag does
- Hibernate stops flushing the session for that transaction, and Spring also marks the session read-only, so entities are loaded without keeping the snapshot copies used for dirty checking. Reading 10,000 rows needs noticeably less memory and no comparison work at the end.
- The JDBC connection is marked with
Connection.setReadOnly(true). The PostgreSQL driver then starts the transaction as read-only, so an accidentalUPDATEfails instead of committing. - Routing: a data source that sends read-only work to a replica can use the flag to decide where the query goes.
Spring Data's built-in repository methods such as findById and findAll already run read-only. Your own service methods do not, unless you say so.
A common pattern
Annotate the service class with @Transactional(readOnly = true) and override it with a plain @Transactional on the methods that write. New query methods are then read-only without anyone remembering to add the annotation, and every write method is visibly marked.
@Service
@Transactional(readOnly = true)
public class ProductQueries {
public List<ProductSummary> search(String term) { ... }
@Transactional
public void rename(long id, String name) { ... }
}Mistakes to avoid
The flag only takes effect when the method starts the transaction. A read-only method called from inside a read-write transaction joins it and the flag is ignored. The opposite case is the dangerous one: a write method with REQUIRED called from inside a read-only transaction joins the read-only one. With JPA the change is never flushed, the method returns normally and the update quietly disappears.
Do not use read-only transactions to fetch entities you plan to change and save in a later request. Load, change and commit in one read-write method.
Read-only does not mean consistent across statements. Two queries in the same read-only transaction at the default READ COMMITTED isolation can see different data if another transaction commits in between. When a report must see one snapshot, ask for isolation = Isolation.REPEATABLE_READ as well.
Example
import jakarta.persistence.Entity;
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 Product {
private Long id;
private String name;
private BigDecimal price;
protected Product() {
}
void rename(String newName) {
this.name = newName;
}
}
record ProductSummary(Long id, String name, BigDecimal price) {
}
interface ProductRepository extends JpaRepository<Product, Long> {
// Spring Data maps each row onto the record's constructor: no entities are loaded.
List<ProductSummary> findByNameContainingIgnoreCase(String term);
}
(readOnly = true)
public class ProductService {
private final ProductRepository products;
ProductService(ProductRepository products) {
this.products = products;
}
public List<ProductSummary> search(String term) {
return products.findByNameContainingIgnoreCase(term);
}
public void rename(long id, String newName) {
products.findById(id).orElseThrow().rename(newName);
}
}
When to use it
- Cut memory use on a search endpoint that loads thousands of entities just to map them to DTOs.
- Send product listing queries to a read replica while checkout writes go to the primary.
- Produce a daily sales summary whose count and revenue figures come from the same snapshot.
More examples
A consistent read for a report
Without the isolation level, an order committed between the two queries would be counted in one total and missing from the other. <code>REPEATABLE_READ</code> on PostgreSQL takes one snapshot for the whole transaction. The timeout stops a heavy report holding that snapshot open for minutes, which on PostgreSQL also delays vacuum cleaning up old row versions.
import java.math.BigDecimal;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
record DailyTotals(long orders, BigDecimal revenue) {
}
@Service
public class ReportService {
private final JdbcClient jdbc;
ReportService(JdbcClient jdbc) {
this.jdbc = jdbc;
}
// Both queries see the same snapshot, so the totals always agree.
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ, timeout = 20)
public DailyTotals today() {
long count = jdbc.sql("select count(*) from orders where created_at >= current_date")
.query(Long.class)
.single();
BigDecimal revenue = jdbc.sql("select coalesce(sum(total), 0) from orders where created_at >= current_date")
.query(BigDecimal.class)
.single();
return new DailyTotals(count, revenue);
}
}
Routing read-only transactions to a replica
<code>LazyConnectionDataSourceProxy</code> delays choosing a connection until it is actually used, and <code>setReadOnlyDataSource</code> sends read-only transactions to the replica pool. The <code>app.db.*</code> values come from environment variables in production. Remember replication lag: a client that writes and then immediately reads through a read-only method may not see its own write yet.
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
@Configuration
public class DataSourceConfig {
@Bean
@Primary
DataSource dataSource(@Value("${app.db.primary-url}") String primaryUrl,
@Value("${app.db.replica-url}") String replicaUrl,
@Value("${app.db.username}") String username,
@Value("${app.db.password}") String password) {
HikariDataSource primary = pool("primary", primaryUrl, username, password);
HikariDataSource replica = pool("replica", replicaUrl, username, password);
// Fetches a real connection only when the first statement runs, by which
// time the transaction's read-only flag is known.
LazyConnectionDataSourceProxy proxy = new LazyConnectionDataSourceProxy(primary);
proxy.setReadOnlyDataSource(replica);
return proxy;
}
private static HikariDataSource pool(String name, String url, String username, String password) {
HikariDataSource ds = new HikariDataSource();
ds.setPoolName(name);
ds.setJdbcUrl(url);
ds.setUsername(username);
ds.setPassword(password);
return ds;
}
}
The silent lost update
<code>VisitCounter.count</code> is correct on its own, and its tests pass. Called from <code>showProfile</code> it joins a read-only transaction, Hibernate never flushes, and the visit count never moves. Fix it by annotating <code>showProfile</code> with a plain <code>@Transactional</code>, or by giving <code>count</code> <code>REQUIRES_NEW</code> if the counter really is independent of the read.
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Entity
class Customer {
@Id
private Long id;
private int visits;
protected Customer() {
}
void countVisit() {
visits++;
}
}
interface CustomerRepository extends JpaRepository<Customer, Long> {
}
@Service
class VisitCounter {
private final CustomerRepository customers;
VisitCounter(CustomerRepository customers) {
this.customers = customers;
}
@Transactional
public void count(long customerId) {
customers.findById(customerId).orElseThrow().countVisit();
}
}
@Service
@Transactional(readOnly = true)
public class ProfileService {
private final VisitCounter counter;
ProfileService(VisitCounter counter) {
this.counter = counter;
}
// BUG: count() joins this read-only transaction, so its change is never flushed.
public void showProfile(long customerId) {
counter.count(customerId);
}
}
Discussion