Spring @Transactional(readOnly = true) with JPA: Engineering Trade-Offs
Marking a Spring service method read-only can skip Hibernate flushes, but it is a hint, not a security boundary. Learn where it works and where it fails.
16 Jul 2025, 23:15 UTC

The useful answer
If a service method only reads data, annotate it with @Transactional(readOnly = true). In Spring Framework 5.x and 6.x with JpaTransactionManager or HibernateTransactionManager, Spring tells the JDBC driver to mark the connection read-only, and Hibernate skips the persistence-context flush at commit—the step that synchronizes in-memory entity changes to the database. This avoids unnecessary UPDATE statements and can reduce lock contention on read-heavy endpoints.
The annotation is a transaction hint, not a write block. Some databases and drivers ignore Connection.setReadOnly(true), and code that explicitly calls EntityManager.persist, merge, or remove can still issue INSERT, UPDATE, or DELETE SQL. Use database-level grants when you need to enforce read-only access.
How the read-only flag moves through the stack
Spring wraps the target bean in an AOP proxy. When a client calls an annotated method, the transaction interceptor starts a transaction through the configured transaction manager. For JPA, that manager is typically JpaTransactionManager. It passes the read-only flag to the underlying JDBC connection via setReadOnly(true).
Inside the method, Hibernate can still load entities and perform dirty checking. At commit time, however, Hibernate does not flush the persistence context solely because the transaction is marked read-only. If the method modifies a managed entity, Hibernate may skip the UPDATE that a read-write transaction would normally emit.
Worked configuration
This example assumes Spring Boot 3.x (Spring Framework 6.x) with JPA and Hibernate. The repository interface is standard Spring Data JPA.
@Service
public class ProductCatalogService {
private final ProductRepository repository;
public ProductCatalogService(ProductRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public ProductPrice currentPrice(Long productId) {
return repository.findById(productId)
.map(ProductPrice::fromEntity)
.orElseThrow(() -> new ProductNotFoundException(productId));
}
}
Enable the transaction manager. Spring Boot autoconfigures JpaTransactionManager when a JPA EntityManagerFactory is present, but you can declare it explicitly:
@Configuration
public class TransactionConfig {
@Bean
PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
}
Application properties:
spring.datasource.url=jdbc:postgresql://db.example.com:5432/catalog
spring.datasource.username=catalog_reader
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=none
logging.level.org.springframework.transaction.interceptor=DEBUG
logging.level.org.hibernate.SQL=DEBUG
Setting spring.jpa.open-in-view=false keeps the persistence context tied to the transaction boundary instead of the HTTP request, which makes the read-only commit behavior easier to observe in logs.
Diagnostic: verify the flag is honored
Run this check against a non-production database where you can inspect logs and table rows.
- Deploy the service with the logging properties above.
- Call the read-only endpoint for a product whose price you know.
- Inside the annotated method, inject
TransactionSynchronizationManagerand assertisCurrentTransactionReadOnly()returnstrue. - Inspect the application log. A read-only transaction that performs no writes should emit SELECT statements but no UPDATE or INSERT statements for that call.
- Query the product row directly from the database before and after the call. The row should be unchanged.
Risk: Do not run this diagnostic against a production replica that accepts writes, because a misconfigured transaction manager could silently allow writes through.
Limits and common mistakes
Self-invocation bypasses the proxy
Calling a @Transactional(readOnly = true) method from another method in the same class—this.readOnlyMethod()—bypasses the Spring AOP proxy. The transaction interceptor never runs, so the read-only flag and transaction boundary are ignored. Fix this by calling the method through a different bean or by enabling AspectJ load-time weaving.
It does not block writes
Some JDBC drivers and databases treat setReadOnly(true) as advisory. Explicit calls to persist, merge, or remove may still issue data-modifying SQL. Treat the annotation as an optimization and intent signal, not as an authorization control.
JTA providers may ignore the hint
With JTA, the transaction manager delegates to the application server or standalone JTA provider. Some providers honor the read-only hint; others treat it as advisory or ignore it entirely. If you switch from JpaTransactionManager to a JTA provider, re-run the diagnostic above to confirm flush behavior did not change.
Default rollback rules still apply
Spring rolls back on RuntimeException and Error, but not on checked exceptions unless you configure rollbackFor. Marking a transaction read-only does not change this default.
Propagation creates separate transactions
Combining readOnly = true with Propagation.REQUIRES_NEW starts a distinct read-only transaction. An exception in the inner method rolls back only that inner transaction; it does not automatically mark the outer transaction for rollback.
Practical check after deployment
After deploying a change that adds readOnly = true to a read path, monitor the application log for a reduction in UPDATE statements on that path. Then run a direct SQL query against a known row before and after a read call to confirm the row is unchanged. If UPDATE statements still appear, check for self-invocation, explicit EntityManager write calls, or a JTA provider that ignores the hint.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.