Read-Only Transactions & Optimization
Read-Only Transactions & Optimization
Every database call you make happens inside a transaction, whether you asked for one or not. When that call only reads data, locking the rows, tracking dirty state, and preparing a rollback journal is pure overhead — work the database and Hibernate perform with no benefit. The readOnly flag on @Transactional is the signal that lets both layers skip that work and go faster.
What readOnly Actually Does
Setting @Transactional(readOnly = true) on a method triggers optimizations at two distinct layers:
- Hibernate / JPA layer — the persistence context switches into flush mode NEVER. This means Hibernate will not perform dirty checking at flush time: it will never scan all managed entities for changes, never compute change-sets, and never generate UPDATE statements. The first-level cache still loads entities, but they are treated as immutable snapshots for the lifetime of the method.
- JDBC / database layer — Spring passes the read-only hint down to the JDBC connection (
connection.setReadOnly(true)). Many databases (PostgreSQL, MySQL InnoDB, Oracle) use this hint to avoid taking row-level write locks, to skip undo-log entries, or to route the connection to a read replica. The exact behaviour depends on the driver and database, but the benefit is real and measurable under load.
readOnly = true Hibernate skips that work entirely. If you accidentally mutate a managed entity inside a read-only transaction it will silently not persist — no error, just a lost update. That is a bug, not a feature.
Declaring Read-Only Methods in a Service
A common pattern is to mark the class-level @Transactional as read-only and override individual mutating methods with the write default:
The class-level annotation acts as the default. Any method without its own @Transactional annotation inherits the class setting. Any method that needs to write simply adds @Transactional — which defaults to readOnly = false — and overrides the class default.
*QueryService (the class is annotated readOnly = true) and a *CommandService (uses write transactions). This keeps the code self-documenting, makes it easy to route reads to a replica, and reduces the risk of accidental writes in query paths.
Read-Only Repositories with Spring Data JPA
Spring Data JPA lets you declare a repository as read-only by extending Repository (not JpaRepository) and annotating query methods:
By extending the base Repository marker interface instead of JpaRepository, no mutating methods (save, delete, etc.) are available on this interface, which enforces the read-only contract at compile time.
Projections: Fetching Only What You Need
Read-only transactions pair naturally with projections. Instead of loading a full entity graph into the persistence context, a projection returns only the fields the caller needs. This reduces the number of columns fetched, keeps entity objects smaller, and avoids lazy-loading surprises:
Spring Data JPA infers the SELECT list from the interface's getter names. The generated SQL fetches only id, status, and total_amount — not the full ORDER row plus all joined tables.
Measuring the Impact
The gains from readOnly = true are most visible under two conditions:
- Large entity graphs: if a single query loads hundreds of entities with associations, skipping dirty-checking at flush time saves a measurable CPU cycle count per request.
- Read-heavy workloads: if 80–90 % of your traffic is reads (typical for most web services), optimising the read path has a multiplicative effect on throughput.
Use Hibernate's statistics or a tool like Datasource-Proxy to log the number of SQL statements per request. You will often see that a naive service method issues N+1 queries or redundant flush checks that readOnly = true plus projections eliminate entirely.
Sample statistics output after enabling it — note the dirty-check count drops to zero in read-only transactions:
Common Pitfalls
- Mutating entities in a read-only transaction: As noted above, changes are silently dropped. If you see a save that is not persisting, check whether the enclosing transaction is read-only.
- Calling a read-only method from a write-transaction method in the same bean: Spring's proxy-based AOP does not intercept self-calls. If method A (read-write) directly calls
this.methodB()(read-only) within the same class, methodB runs inside A's transaction, not a new read-only one. Use propagation or restructure into different beans to avoid this. - Assuming all databases honour the hint: H2 (commonly used in tests) ignores the read-only hint. Code that mutates entities inside a
readOnly = truetransaction may appear to work in tests but fail silently in production.
readOnly = true transaction to work "sometimes", you have a latent production bug. Always annotate mutating service methods explicitly with @Transactional (defaulting to read-write) and run integration tests against a real database (PostgreSQL / MySQL) that enforces the hint.
Summary
@Transactional(readOnly = true) is not just a hint — it is a contract that tells both Hibernate and the JDBC driver to shed unnecessary work. Hibernate disables dirty-checking and flush, the database avoids write locks and undo entries, and the overall throughput of your read paths improves. Use it as the default on query-only services and repositories, pair it with projections to minimise data transfer, and be aware of the two key pitfalls: silent write loss and self-call bypass.