DEV Community

Cover image for "🚩 readOnly = true Is Not a Comment"
Kyryl
Kyryl

Posted on

"🚩 readOnly = true Is Not a Comment"

@Transactional(readOnly = true) gets slapped on every query method out of habit, the way people sprinkle final on local variables. A marker for the next reader, a bit of documentation that says "this method does not write anything." Except it is not documentation. It is a flag that Hibernate and, if you have set it up, your Postgres routing DataSource both actually read and act on.

Ignore that and you get one of two failures. Either a write silently vanishes with no exception, or every read in your app hits the primary database because nothing ever told the driver it was allowed to go to a replica.

What people think readOnly = true does

The common mental model: it is a hint for whoever reads the code later, maybe a small optimization Spring does under the hood, nothing that changes behavior you would notice. Under that model, marking a method readOnly = true when it is not strictly read-only feels harmless. Worst case, a wasted annotation.

That model is wrong on the part that matters most: the flush mode.

What it actually does: changes Hibernate's flush mode

Inside a transaction marked readOnly = true, Spring propagates that flag down to the underlying Hibernate Session and sets its flush mode to manual. Normally, Hibernate auto-flushes: before you run a query, it checks the persistence context for dirty managed entities and writes them out first, so the query sees consistent state. Under manual flush mode, none of that happens.

@Transactional(readOnly = true)
Report load(Long id) {
    // Hibernate sets FlushMode.MANUAL here
    // no dirty-checking, no auto-flush
    return repo.findById(id);
}
Enter fullscreen mode Exit fullscreen mode

For a genuinely read-only method, this is a pure win. Skipping dirty-checking on every entity in the persistence context is real overhead, and readOnly = true removes it. That is the entire justification for slapping it on query methods, and it is a good one, right up until someone mutates state inside that method.

The trap: a write inside a read-only transaction

Here is the version that gets shipped:

@Transactional(readOnly = true)
// "just documentation", right?
Report loadAndPatch(Long id) {
    Report r = repo.findById(id);
    r.setLastViewedAt(now()); // looks persisted
    return r;
}
// ...it never flushes. The update vanishes.
Enter fullscreen mode Exit fullscreen mode

r is a managed entity. setLastViewedAt mutates its field. Every instinct built from normal Spring Data usage says this change will be picked up on flush, the way it would in any other @Transactional method. It will not. The flush mode is manual, nothing triggers a flush, and the transaction commits with the mutation sitting in memory and nowhere else.

No exception. Nothing in the logs. The method returns a Report object with the field looking correctly set, because the in-memory object really was mutated. Only the database was never told. The bug surfaces days or weeks later as "last viewed timestamps are not updating," and the first three places anyone looks are the query, the column mapping, and the transaction boundary. The annotation that caused it reads like the most innocent line in the method.

This is worse than a typo in a query. A typo throws. This just quietly does nothing, forever, until someone notices the data is stale.

The other half: it can route your read to a replica

The flush-mode change is reason enough to take readOnly = true seriously, but on a Postgres setup with read replicas it does a second job. If you have wired an AbstractRoutingDataSource that inspects the current transaction's read-only flag, that flag is the actual signal deciding which physical database the query hits:

class ReplicaRoutingDataSource
        extends AbstractRoutingDataSource {

    protected Object determineCurrentLookupKey() {
        boolean ro = TransactionSynchronizationManager
            .isCurrentTransactionReadOnly();
        return ro ? "replica" : "primary";
    }
}
Enter fullscreen mode Exit fullscreen mode

TransactionSynchronizationManager.isCurrentTransactionReadOnly() returns exactly the value Spring set from your @Transactional(readOnly = true) annotation. Get the annotation right and your reads spread across replicas, taking load off the primary. Get it wrong, forget it on a genuinely read-only method, and that query hits the primary for no reason. Put it on a method that writes, and depending on your routing setup you can end up sending a write-carrying transaction at a replica connection that rejects writes outright, or worse, one that does not reject them and you get an inconsistency between primary and replica state.

The annotation is not decoration in either direction. It is the single signal two separate systems, your ORM's flush behavior and your connection routing, both key off.

The honest trade-off

Mislabeling a write path as readOnly = true fails silently, not loudly. That is the real cost here, and it is worse than it sounds. A method that should compile-error or throw at runtime instead just does nothing to the database, and the visible parts of the system, the returned object, the lack of any exception, all look correct. You do not get a stack trace pointing at the problem. You get a support ticket three weeks later asking why a field never updates.

The fix is not to avoid readOnly = true. It genuinely helps, both for the flush-mode overhead and for replica routing. The fix is discipline: only mark a method readOnly = true when you have actually checked that it never mutates a managed entity, directly or through a called method. Treat the annotation as a contract, not a habit. If a method's purpose changes later and someone adds a write to it, the missing exception means nobody will be warned. Code review is the only real defense, along with grep-ing for readOnly = true methods that call setters on entities pulled from a repository.

Have you tracked a "why is this not saving" bug back to a stray readOnly = true? What caught it, a test, a code review, or production data going stale?

Top comments (0)