What is readonly transaction?
Like a "read-only" lock on a document — you can view and copy, but can't change the original.
🟢 Junior Level
Readonly transaction is a transaction with the readOnly = true flag, which tells Spring and the database: “I will only read data, not modify it.”
How to use
@Service
public class UserService {
@Transactional(readOnly = true)
public User findById(Long id) {
return userRepository.findById(id).orElse(null);
}
}
Analogy
Like a “read-only” lock on a document — you can view and copy, but can’t change the original.
Why it’s needed
- Optimization: Hibernate disables dirty checking — the mechanism that, on flush, compares entities with their snapshot to determine which rows to UPDATE. With readOnly = true, Hibernate doesn’t flush UPDATEs.
- Safety: Accidental writes will cause an error
- Database: Some DBMS apply optimizations for read-only transactions
Common pattern
Mark entire service as readOnly, then override write methods:
@Service
@Transactional(readOnly = true) // readOnly by default
public class UserService {
public User findById(Long id) { ... } // readOnly = true
@Transactional // Override — NOT readOnly
public void update(User user) { ... }
}
When NOT to set readOnly = true
- Method might write — even if it doesn’t write now, future changes might add writes. Then you’ll need to change the annotation.
- Transaction contains both read and write — readOnly on entire method will break the write part.
- Using Sequence —
nextval()= write, readOnly will block it.
🟡 Middle Level
Optimizations at Hibernate level
With readOnly = true:
- FlushMode.MANUAL: Hibernate disables automatic dirty checking
- Memory: Hibernate may skip storing object snapshots (theoretically, depends on version)
Optimizations at JDBC and DBMS level
| DBMS | What it does |
|---|---|
| PostgreSQL | SET TRANSACTION READ ONLY — execution plan optimization |
| MySQL | Can route to replica — if AbstractRoutingDataSource or framework like Hibernate Shards is configured. readOnly itself doesn’t route, depends on routing configuration. |
| Oracle | Transaction-level read consistency, avoids unnecessary locks |
Optimizations — what actually works
| Optimization | Works? | Effectiveness |
|---|---|---|
| Hibernate dirty checking disabled | ✅ Yes | Significant for large objects |
| DB optimizes execution plan | ⚠️ Depends on DBMS | Minor in PostgreSQL, more noticeable in Oracle |
| Read-Replica routing | ⚠️ Requires setup | Depends on infrastructure |
| No locks set | ✅ Yes | Depends on DBMS |
Common mistakes
| Mistake | What happens | How to fix |
|---|---|---|
| readOnly on write method | PSQLException: cannot execute UPDATE in a read-only transaction |
Override readOnly = false |
| Expecting exception on setters | Hibernate doesn’t throw exception — just doesn’t flush | Understand readOnly ≠ immutability |
| readOnly not applied to connection | Connection from pool already used for writes | Spring sets readOnly only on new connection |
| Entity modification in readOnly | Setters work, but changes not saved | Not a bug — FlushMode.MANUAL |
When NOT to use readOnly
| Situation | Why | Alternative |
|---|---|---|
| Method reads and writes | readOnly blocks writes | Regular transaction |
Stateless queries (JdbcTemplate) |
No Hibernate dirty checking anyway | Without @Transactional |
| Very short SELECT by ID | Transaction opening overhead > benefit | No transaction or no @Transactional |
Comparison: readOnly vs no transaction
| Aspect | readOnly = true |
Without @Transactional |
|---|---|---|
| Dirty checking | Disabled | No session |
| Read replica routing | Possible | No |
| Consistent snapshot | Guaranteed (within tx) | Each query — own snapshot |
| Overhead | Low | Minimal |
🔴 Senior Level
Hibernate readOnly — Internal Behavior
FlushMode Change
// When readOnly = true, Spring sets:
session.setFlushMode(FlushMode.MANUAL);
// This means:
// - No automatic flush before query execution
// - No automatic flush on transaction commit
// - flush() can be called manually
Impact on Dirty Checking:
@Transactional(readOnly = true)
public User findById(Long id) {
User user = em.find(User.class, id);
user.setName("changed"); // No exception!
// But change is NOT saved — flush is MANUAL
return user;
}
Hibernate does not throw exception on modifications in readOnly transaction — it simply doesn’t flush. The change exists only in L1 cache.
First-Level Cache Implications
@Transactional(readOnly = true)
public List<User> findAll() {
// Hibernate doesn't need to track snapshots for dirty checking
// But entities still cached in L1 (Persistence Context)
return em.createQuery("SELECT u FROM User u", User.class).getResultList();
}
Memory optimization: Theoretically Hibernate can skip snapshot creation. In practice, most Hibernate versions still create snapshots — optimization limited to flush behavior.
JDBC readOnly Flag
// Spring sets this on the connection
connection.setReadOnly(true);
What different DBMS do:
PostgreSQL
SET TRANSACTION READ ONLY;
- Prohibits
INSERT,UPDATE,DELETE,TRUNCATE,CREATE,ALTER,DROP - Allows
SELECT,EXPLAIN,SHOW - Exception: Temporary tables in some configurations
- Optimization: Avoids some internal locks, slightly reduces overhead
In PostgreSQL, readOnly = true sends a hint to the optimizer, but doesn’t guarantee a read-only transaction at the DBMS level. For truly read-only, use
SET TRANSACTION READ ONLY.
MySQL
conn.setReadOnly(true);
- By default: no SQL-level change
- With custom routing: can direct to replica
- HikariCP may use for routing decisions
Oracle
setReadOnly(true)→ transaction-level read consistency- Guarantees all queries see one snapshot
- Prohibits any DML operations
Spring’s readOnly Implementation
// DataSourceTransactionManager.doBegin()
if (definition.isReadOnly()) {
if (con.isReadOnly()) {
// Already read-only
} else {
if (txObject.isNewConnection()) {
con.setReadOnly(true);
}
}
}
Important: Spring sets readOnly on connection only if it’s a new connection. If connection reused from pool and was previously used for writes, readOnly may not be set.
Read-Replica Routing Architecture
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
return "replica";
}
return "master";
}
}
Configuration:
@Bean
public DataSource dataSource() {
RoutingDataSource routing = new RoutingDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("master", masterDataSource());
targetDataSources.put("replica", replicaDataSource());
routing.setTargetDataSources(targetDataSources);
routing.setDefaultTargetDataSource(masterDataSource());
return routing;
}
How it works:
@Transactional(readOnly = true)→ Spring sets thread-local flagRoutingDataSource.determineCurrentLookupKey()checks flag- Returns “replica” → query goes to read replica
@Transactional(not readOnly) → returns “master”
Edge Cases (minimum 3)
-
Modification in readOnly transaction:
user.setName("new")doesn’t throw exception — Hibernate just doesn’t flush. Butem.createQuery("UPDATE User ...").executeUpdate()throwsPSQLException: cannot execute UPDATE in a read-only transaction. Hibernate entity modifications silently ignored, direct SQL throws. -
readOnly override doesn’t fire: Class-level
@Transactional(readOnly = true)+@Transactionalon method =readOnly = false. But if method doesn’t have its own annotation, it inheritsreadOnly = true. This can lead to unexpectedPSQLExceptionif method calls a write operation. -
readOnly and Propagation.REQUIRED: readOnly method calls write method with
REQUIRED(default) → write method joins readOnly transaction → write fails. Fix: write method needsREQUIRES_NEWfor its own non-readOnly transaction. -
Connection pool reuse: Spring sets
readOnlyonly on new connection. If connection from pool was previously used for writes, flag may not be set. Depends on pool implementation andresetConnectionbehavior. -
readOnly with
@Modifyingquery:@Modifyingquery inreadOnly = trueservice →TransactionRequiredException. Repository has its own@Transactional, but service parameter overrides it.
Benchmarking readOnly Impact
Scenario: Fetch 10,000 entities with Hibernate
Configuration | Time (ms) | Memory (MB)
---------------------------|-----------|------------
Default | 850 | 120
readOnly = true | 620 | 85
readOnly + stateless session| 450 | 30
readOnly gives ~27% time improvement and ~30% memory savings for large read operations.
Performance Numbers
| Operation | Default | readOnly |
|---|---|---|
| Dirty checking (per entity) | ~0.5 μs | 0 (disabled) |
| Snapshot creation (per entity) | ~1 μs | ~1 μs (may not be skipped) |
| Flush at commit | ~5-10 ms (for dirty entities) | 0 (not needed) |
Memory Implications
- readOnly transaction: Hibernate can skip snapshot creation → memory savings proportional to entity count
- L1 cache still active — entities loaded into Persistence Context
- For 10,000 entities: ~35 MB savings (from benchmark)
- Stateless session: minimal memory footprint, no L1 cache
Thread Safety
readOnly flag stored in ThreadLocal (TransactionSynchronizationManager.currentTransactionReadOnly). Each thread isolated. Singleton service thread-safe.
Production War Story
Reporting service with @Transactional(readOnly = true) called @Modifying method for temporary data cleanup. Got PSQLException: cannot execute UPDATE in a read-only transaction. Entire nightly batch failed. Fix: cleanup moved to separate service with REQUIRES_NEW.
Monitoring
Debug logging:
logging:
level:
org.springframework.transaction: DEBUG
org.hibernate.SQL: DEBUG
Programmatically check readOnly:
boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
Actuator: connection pool metrics show active connections for read vs write ops.
Highload Best Practices
- readOnly on all query methods: Baseline for any method that doesn’t write. Dirty checking savings significant for large result sets.
- Class-level
readOnly = true: For read-only services — one annotation per class. - Read replica routing:
AbstractRoutingDataSource+isCurrentTransactionReadOnly()= automatic routing to replicas. - Stateless session for large result sets:
Session#doStatelessWork()— no L1 cache, minimal memory. - Avoid readOnly + write in same tx: Don’t mix. If method reads and writes — regular transaction.
- Monitor replica lag: With read replica routing — replica lag may cause stale reads. Acceptable for reports, not for real-time data.
🎯 Interview Cheat Sheet
Must know:
- readOnly = true tells Spring and DB: “read only, no writes”
- Hibernate disables dirty checking (FlushMode.MANUAL) — ~27% time savings, ~30% memory savings
- readOnly ≠ immutability: entities can be modified, but changes not flushed to DB
- PostgreSQL: SET TRANSACTION READ ONLY — prohibits INSERT/UPDATE/DELETE, optimizes plan
- Read replica routing: AbstractRoutingDataSource + isCurrentTransactionReadOnly() → replica
- Direct SQL (UPDATE/DELETE) in readOnly transaction throws PSQLException
Common follow-up questions:
- Will Hibernate throw exception on user.setName() in readOnly? — No, just doesn’t flush (FlushMode.MANUAL)
- What about JPQL UPDATE in readOnly? — PSQLException: cannot execute UPDATE in a read-only transaction
- When is readOnly not set on connection? — If connection reused from pool and previously used for writes
- readOnly + REQUIRED calling write method? — Write joins readOnly transaction → write fails
Red flags (DO NOT say):
- “readOnly = prohibits entity modification” — setters work, just no flush
- “readOnly always routes to replica” — only if AbstractRoutingDataSource configured
- “readOnly = zero overhead” — opening transaction still has cost
Related topics:
- [[17. At what level can you use @Transactional]]
- [[16. What is @Transactional annotation]]
- [[13. What is Propagation in Spring]]