Spring Read-Only Transactions: Boost Performance & Avoid Pitfalls
Learn how to use Spring's @Transactional(readOnly = true) effectively.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- ✓Java 17+
- ✓Spring Boot 3.x (or 2.x with understanding of differences)
- ✓Basic knowledge of JPA and Hibernate
- ✓Familiarity with Spring's @Transactional annotation
- Use @Transactional(readOnly = true) on service methods that only read data.
- It hints the JPA provider to optimize queries (e.g., skip dirty checking).
- In Spring Boot 3.x, it also sets the JDBC connection to read-only, preventing accidental writes.
- Avoid using read-only transactions for write operations; it will throw an exception in Spring Boot 3.x.
- For complex read-only logic, consider using a separate transaction manager or read replica.
Think of a read-only transaction like a library card that only allows you to read books, not write in them. The library (database) can let you read faster because it knows you won't scribble in the margins. Spring's @Transactional(readOnly = true) is that card—it tells the database and the JPA provider to optimize for reading only.
You've got a service method that only fetches data. You slap @Transactional on it because you read somewhere that it's good practice. But do you set readOnly = true? Most developers don't, and that's a performance leak. I've seen production systems where a simple read operation triggered full Hibernate dirty checking, causing unnecessary overhead and even deadlocks under high concurrency.
Let's get real: read-only transactions are not just a documentation hint. They can significantly reduce database round trips, skip unnecessary locks, and prevent accidental writes. In Spring Boot 3.x, the behavior changed—now the JDBC connection is set to read-only, which can throw exceptions if you try to write. That's a good thing, but it caught many teams off guard.
In this article, I'll show you exactly how to use @Transactional(readOnly = true) effectively, share war stories from production, and reveal what the official docs won't tell you. By the end, you'll know when to use it, when to avoid it, and how to debug the inevitable issues.
What Are Read-Only Transactions?
A read-only transaction is a database transaction that does not modify any data. In Spring, you mark a method with @Transactional(readOnly = true) to indicate that the transaction should be optimized for reads. This tells the JPA provider (like Hibernate) to skip dirty checking and flush at the end, reducing overhead. It also hints the database to use read locks instead of write locks, improving concurrency.
Here's the hard truth: most developers think readOnly is just a hint. It's not. In Spring Boot 3.x, the JDBC connection is actually set to read-only via Connection.setReadOnly(true). This means if you try to execute an INSERT, UPDATE, or DELETE, the database will throw a SQLException. That's a safety net that caught many teams off guard when upgrading from Spring Boot 2.x.
But even without that, Hibernate behaves differently. With readOnly=true, Hibernate disables dirty checking—it doesn't need to track entity changes because it knows nothing will be written. This can significantly reduce memory usage and CPU cycles, especially when dealing with large result sets.
Let's see a simple example:
How Read-Only Transactions Improve Performance
The performance benefits of read-only transactions come from multiple layers:
- Hibernate Dirty Checking: When a transaction is not read-only, Hibernate must track all loaded entities to detect changes. This requires storing a snapshot of each entity's state and comparing it on flush. With readOnly=true, Hibernate skips this entirely, saving memory and CPU.
- Flush Mode: By default, Hibernate flushes the persistence context before query execution to synchronize pending changes. With readOnly=true, Hibernate sets the flush mode to MANUAL or NEVER, avoiding unnecessary flushes.
- Database Locks: Many databases use shared locks for read-only transactions and exclusive locks for write transactions. By declaring read-only, you allow the database to use more optimistic locking strategies, reducing contention.
- Connection Pooling: Some connection pools (like HikariCP) can optimize read-only connections by routing them to read replicas if configured.
Let's look at a benchmark: In a typical Spring Boot application with Hibernate, a read-only transaction can be up to 30% faster than a read-write transaction for the same query, especially when dealing with large result sets.
Here's a code snippet showing how to verify that dirty checking is skipped:
Common Pitfalls with Read-Only Transactions
Even experienced developers fall into these traps. Here are the most common pitfalls:
1. Writing Inside a Read-Only Transaction In Spring Boot 3.x, this throws a SQLException. In earlier versions, the write might silently succeed but still cause dirty checking overhead. Always ensure that any method annotated with @Transactional(readOnly = true) does not perform writes.
2. Lazy Loading Outside the Transaction A read-only transaction ends when the method returns. If you try to lazy-load an association after the transaction ends, you'll get a LazyInitializationException. This is a common issue when using Spring's Open Session in View (OSIV) pattern. Disable OSIV (spring.jpa.open-in-view=false) and handle lazy loading explicitly.
3. Propagation and Read-Only If a read-only transaction calls another transactional method with PROPAGATION_REQUIRES_NEW, the new transaction is not read-only by default. You must set readOnly on that method as well.
4. Read-Only and Isolation Levels Setting readOnly=true does not change the isolation level. If you need a specific isolation (like READ_UNCOMMITTED), you must set it explicitly. Some databases ignore readOnly for certain isolation levels.
Let's see an example of a pitfall:
Read-Only Transactions with Multiple Data Sources
If your application uses multiple data sources (e.g., a primary database for writes and a read replica for reads), you need to configure separate transaction managers. Spring's @Transactional annotation can specify which transaction manager to use via the transactionManager attribute.
Here's a common setup: you have a read-only data source pointing to a read replica, and you want all read-only transactions to use that replica. You can create a custom annotation that combines @Transactional(readOnly = true) with the correct transaction manager.
Let's see how to implement this:
What the Official Docs Won't Tell You
The Spring documentation covers the basics, but here are the gotchas I've learned from production:
1. Read-Only Does Not Prevent All Writes In Spring Boot 2.x, the readOnly flag is a hint, not a guarantee. Hibernate still flushes if you call flush() explicitly. In Spring Boot 3.x, the JDBC connection is set to read-only, but native queries that are not detected as writes might still slip through. Always test with a database user that has read-only privileges.
2. @Transactional(readOnly=true) on Class Level If you annotate a class with @Transactional(readOnly=true), all methods inherit that setting. But if you override a method with @Transactional(readOnly=false), the class-level annotation is overridden. However, the transaction manager might still be the default one. Be explicit.
3. Read-Only and Hibernate's Query Plan Cache Hibernate caches query plans. With readOnly=true, the query plan cache might not be used if the same query is executed in a read-write context. This can cause performance issues in mixed workloads.
4. Read-Only Transactions in JPA Repositories Spring Data JPA's default methods (like findAll()) are not annotated with @Transactional. If you call them outside a service-level transaction, each call runs in its own transaction, which is read-write by default. Always wrap them in a read-only transaction.
5. Read-Only and Spring's Open Session in View (OSIV) OSIV keeps the session open during view rendering. If you have a read-only transaction that returns entities, and the view tries to lazy-load associations, it will work because the session is still open. But this is a performance anti-pattern. Disable OSIV and handle lazy loading explicitly.
Let's see a code example that demonstrates the OSIV issue:
Best Practices for Read-Only Transactions
After years of production experience, here are my recommendations:
- Annotate all read-only service methods with @Transactional(readOnly = true). This includes simple finders, reporting methods, and any method that only queries data.
- Use a custom annotation for read-only transactions to reduce boilerplate and ensure consistency, especially if you have multiple data sources.
- Test with Spring Boot 3.x if you're upgrading. The behavior change around read-only connections can break existing code.
- Enable Hibernate statistics in development to verify that read-only transactions are not flushing.
- Avoid relying on OSIV. Disable it and handle lazy loading explicitly with JOIN FETCH or EntityGraph.
- Consider using read replicas for read-heavy applications. Configure a separate transaction manager and route read-only transactions to the replica.
- Be explicit about transaction propagation. If a read-only transaction calls another method with REQUIRES_NEW, that new transaction is not read-only by default.
Here's a complete example of a service with best practices:
The Midnight Deadlock: Read-Only Transactions Gone Wrong
- Always set readOnly = true for read-only service methods—even if you only execute SELECTs.
- In Spring Boot 3.x, the JDBC connection is set to read-only, preventing accidental writes and reducing lock contention.
- Enable Hibernate's query plan cache logging to detect missing read-only hints.
- Use a separate transaction manager for read replicas if you have a read-write split.
- Test under concurrent load to catch deadlocks early.
Check stack trace for the write call.Add @Transactional(readOnly=false) on the write method or split the transaction.| File | Command / Code | Purpose |
|---|---|---|
| ReadOnlyTransactionExample.java | @Service | What Are Read-Only Transactions? |
| PerformanceTest.java | @SpringBootTest | How Read-Only Transactions Improve Performance |
| PitfallExample.java | @Service | Common Pitfalls with Read-Only Transactions |
| ReadOnlyTransactionWithMultipleDataSources.java | @Configuration | Read-Only Transactions with Multiple Data Sources |
| OsivIssue.java | @Service | What the Official Docs Won't Tell You |
| BestPracticeService.java | @Service | Best Practices for Read-Only Transactions |
Key takeaways
Interview Questions on This Topic
What is the difference between @Transactional and @Transactional(readOnly = true) in Spring?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
That's Hibernate & JPA. Mark it forged?
4 min read · try the examples if you haven't