Back to Rollback Rules & Pitfalls
Q4Question 4 of 5

How does readOnly=true silently drop writes?

Key Concepts
  • @Transactional(readOnly=true) tells Spring to set Hibernate's flush mode to FlushMode.MANUAL
  • In MANUAL flush mode, Hibernate never automatically flushes pending changes to the database
  • Any INSERT or UPDATE queued in the persistence context is silently discarded at commit -- no exception
  • Class-level readOnly=true is inherited by all methods, including write methods
  • A write method under inherited readOnly silently becomes a no-op for database changes
  • Fix: override with @Transactional(readOnly=false) or plain @Transactional on every write method
  • readOnly is a correctness switch disguised as a performance optimization -- treat it with care

Definition

@Transactional(readOnly=true) switches Hibernate to manual flush mode, which silently discards all pending INSERT and UPDATE statements with no error -- never let a write method inherit a class-level readOnly.
  • readOnly hint -- when set to true, Spring passes a read-only hint down to the JDBC connection (Connection.setReadOnly(true)) and to the Hibernate session.
  • FlushMode.MANUAL -- Spring changes the Hibernate session's flush mode from AUTO to MANUAL. In MANUAL mode, Hibernate will not automatically flush the persistence context before queries or at commit time.
  • Silent discard -- any entity modification or save() call under readOnly is held in the persistence context but never flushed to the database. The pending SQL is discarded when the transaction commits. No exception is thrown.
  • Class-level inheritance -- a @Transactional(readOnly = true) annotation on the class is inherited by all methods. A write method that does not override the annotation silently loses its writes.
  • Fix -- explicitly annotate every write method with @Transactional or @Transactional(readOnly = false) to restore FlushMode.AUTO.

At the National Bank of India, the supervisor gives the entire department an "inquiry only" stamp for the day (class-level readOnly). Any transfer form filled out by a teller is accepted and looks normal, but the vault silently throws it in the bin because the department is stamped "inquiry only." Rajesh walks away thinking Rs.50,000 moved, but nothing happened. The fix: the transfer counter gets its own "full access" stamp (method-level @Transactional) that overrides the department stamp.

Understanding the Concepts

What does readOnly=true do internally?

When you annotate a method with @Transactional(readOnly = true), Spring does two things:

  1. It calls Connection.setReadOnly(true) on the JDBC connection, which some databases use for routing to a read replica.
  2. It sets the Hibernate session's flush mode to FlushMode.MANUAL.

The second point is the dangerous one. Normally, Hibernate uses FlushMode.AUTO, which means it automatically flushes pending changes before a query runs (to ensure query results reflect in-memory changes) and before the transaction commits (to persist all modifications). With FlushMode.MANUAL, that automatic flush is turned off entirely. Hibernate will only flush if you call session.flush() by hand, which you almost never do in normal Spring code.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class AccountQueryService {

    private final AccountRepository accountRepository;

    public AccountQueryService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // CORRECT use of readOnly: query-only method
    @Transactional(readOnly = true)
    public BigDecimal getBalance(String accountId) {
        return accountRepository.findById(accountId)
                .orElseThrow(() -> new AccountNotFoundException(accountId))
                .getBalance();
    }
}
TEXT
With readOnly=true:
  Hibernate flush mode: MANUAL
  Dirty checking: SKIPPED (performance optimization)
  Connection hint: setReadOnly(true) -> may route to read replica

With readOnly=false (default):
  Hibernate flush mode: AUTO
  Dirty checking: ACTIVE (detects entity changes)
  Connection hint: setReadOnly(false) -> uses primary DB

How does readOnly silently drop writes?

When you modify a managed entity or call save() under a readOnly transaction, Hibernate queues the change in the persistence context. But because the flush mode is MANUAL, Hibernate never generates the SQL statement. The change stays in memory and is discarded when the transaction commits.

Crucially, no exception is thrown. The method runs to completion, returns a success result, and the transaction commits as read-only. The data change is simply lost.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class BrokenAccountService {

    private final AccountRepository accountRepository;

    public BrokenAccountService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // WRONG: readOnly=true on a write method
    @Transactional(readOnly = true)
    public void updateHolderName(String accountId, String newName) {
        var account = accountRepository.findById(accountId).orElseThrow();
        account.setHolderName(newName);
        // Dirty checking would normally generate:
        //   UPDATE accounts SET holder_name = 'Priya' WHERE id = 'SAV2001'
        // But flush mode is MANUAL -> no flush -> no UPDATE
        // Transaction commits. Name change is LOST. No error.
    }

    // WRONG: explicit save() also silently dropped
    @Transactional(readOnly = true)
    public void createAccount(String id, String name, BigDecimal deposit) {
        Account account = new Account();
        account.setId(id);
        account.setHolderName(name);
        account.setBalance(deposit);
        accountRepository.save(account);
        // save() calls persist(), entity is in persistence context
        // But no flush -> no INSERT
        // Transaction commits. Account NOT created. No error.
    }
}
TEXT
updateHolderName("SAV2001", "Priya"):
  1. findById -> account loaded, flush mode = MANUAL
  2. setHolderName("Priya") -> entity marked dirty in persistence context
  3. Transaction commits
  4. Hibernate skips dirty check (MANUAL mode)
  5. No UPDATE generated
  6. Database still has old name. No error thrown.

createAccount("SAV2005", "Priya", 50000):
  1. new Account() created, saved to persistence context
  2. Transaction commits
  3. Hibernate skips flush (MANUAL mode)
  4. No INSERT generated
  5. Account does not exist in database. No error thrown.

Why is class-level readOnly the most dangerous variant?

The trap becomes severe when readOnly = true is placed at the class level. Developers put it on the class because most methods are queries, and readOnly is a legitimate performance optimization (it skips dirty checking and may route to read replicas). But every method in the class inherits the readOnly setting, including methods that perform writes.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;

// WRONG: readOnly on the class, write methods inherit it
@Service
@Transactional(readOnly = true)
public class AccountServiceBroken {

    private final AccountRepository accountRepository;

    public AccountServiceBroken(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // This works correctly (read-only query)
    public BigDecimal getBalance(String accountId) {
        return accountRepository.findById(accountId)
                .orElseThrow().getBalance();
    }

    // This works correctly (read-only query)
    public List<Account> getAllAccounts() {
        return accountRepository.findAll();
    }

    // WRONG: this write method inherits readOnly=true!
    public void transfer(String fromId, String toId, BigDecimal amount) {
        var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        var to = accountRepository.findByIdForUpdate(toId).orElseThrow();

        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
        // Entities modified in memory
        // Flush mode is MANUAL (inherited from class-level readOnly)
        // No UPDATE generated
        // Transaction commits. Balances unchanged. No error.
    }

    // WRONG: this create method also inherits readOnly=true!
    public void createAccount(String id, String name, BigDecimal deposit) {
        Account account = new Account();
        account.setId(id);
        account.setHolderName(name);
        account.setBalance(deposit);
        accountRepository.save(account);
        // No INSERT generated. Account not created. No error.
    }
}
TEXT
Rajesh calls transfer("SAV2001", "CUR3001", 50000):
  1. Method inherits @Transactional(readOnly=true) from class
  2. Hibernate flush mode: MANUAL
  3. debit SAV2001 -> entity changed in memory
  4. credit CUR3001 -> entity changed in memory
  5. Transaction commits
  6. No UPDATE statements generated
  7. SAV2001 still has original balance
  8. CUR3001 still has original balance
  9. Rajesh told "Transfer successful" (method returned normally)
  10. Money never moved. Silent corruption.

How do you fix the class-level readOnly trap?

The fix is to explicitly annotate every write method with @Transactional or @Transactional(readOnly = false). Method-level annotations always override class-level annotations in Spring.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;

// Class-level readOnly for query methods
@Service
@Transactional(readOnly = true)
public class AccountServiceFixed {

    private final AccountRepository accountRepository;

    public AccountServiceFixed(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // Inherits readOnly=true -- correct for a query
    public BigDecimal getBalance(String accountId) {
        return accountRepository.findById(accountId)
                .orElseThrow().getBalance();
    }

    // Inherits readOnly=true -- correct for a query
    public List<Account> getAllAccounts() {
        return accountRepository.findAll();
    }

    // CORRECT: override with writable @Transactional
    @Transactional // readOnly=false by default, overrides class-level
    public void transfer(String fromId, String toId, BigDecimal amount) {
        var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        var to = accountRepository.findByIdForUpdate(toId).orElseThrow();

        if (from.getBalance().compareTo(amount) < 0) {
            throw new InsufficientFundsException(fromId, amount);
        }

        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
        // FlushMode.AUTO restored -> dirty check runs -> UPDATE generated
    }

    // CORRECT: explicit readOnly=false
    @Transactional(readOnly = false)
    public void createAccount(String id, String name, BigDecimal deposit) {
        Account account = new Account();
        account.setId(id);
        account.setHolderName(name);
        account.setBalance(deposit);
        accountRepository.save(account);
        // FlushMode.AUTO -> INSERT generated -> account created
    }
}
TEXT
Method-level @Transactional overrides class-level:
  getBalance()    -> inherits readOnly=true  (correct: query)
  getAllAccounts() -> inherits readOnly=true  (correct: query)
  transfer()      -> @Transactional -> readOnly=false (correct: write)
  createAccount() -> @Transactional(readOnly=false) (correct: write)

What is the better practice: class-level or method-level?

Pankaj, the architect at National Bank of India, recommends method-level annotations exclusively for services that have both read and write methods. This eliminates the inheritance trap entirely.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;

// BEST PRACTICE: method-level annotations only
@Service
public class AccountServiceBestPractice {

    private final AccountRepository accountRepository;

    public AccountServiceBestPractice(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional(readOnly = true)
    public BigDecimal getBalance(String accountId) {
        return accountRepository.findById(accountId)
                .orElseThrow().getBalance();
    }

    @Transactional(readOnly = true)
    public List<Account> getAllAccounts() {
        return accountRepository.findAll();
    }

    @Transactional
    public void transfer(String fromId, String toId, BigDecimal amount) {
        var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        var to = accountRepository.findByIdForUpdate(toId).orElseThrow();
        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
    }

    @Transactional
    public void createAccount(String id, String name, BigDecimal deposit) {
        Account account = new Account();
        account.setId(id);
        account.setHolderName(name);
        account.setBalance(deposit);
        accountRepository.save(account);
    }
}
TEXT
Method-level only:
  + No inheritance trap -- each method declares its own intent
  + Easy to audit: grep for @Transactional to see read/write split
  + New write methods cannot accidentally inherit readOnly
  - Slightly more verbose (one annotation per method)

How do you detect this bug in code review and testing?

Shallow tests can miss this bug because the in-memory entity objects have the correct values. Only an integration test that reads from the database after the transaction commits will catch the discrepancy.

JAVA
package com.bank.service;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.math.BigDecimal;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class ReadOnlyTrapTest {

    @Autowired
    private AccountServiceBroken brokenService;

    @Autowired
    private AccountRepository accountRepository;

    @Test
    void transferUnderReadOnlySilentlyDropsWrites() {
        // Setup: SAV2001 balance = 500000, CUR3001 balance = 100000
        brokenService.transfer("SAV2001", "CUR3001",
            new BigDecimal("50000"));

        // Read from DB (new transaction)
        BigDecimal fromBalance = accountRepository.findById("SAV2001")
                .orElseThrow().getBalance();
        BigDecimal toBalance = accountRepository.findById("CUR3001")
                .orElseThrow().getBalance();

        // This assertion FAILS -- proving the readOnly bug
        assertThat(fromBalance)
            .isEqualByComparingTo(new BigDecimal("450000"));
        // Actual: 500000 (unchanged, writes silently dropped)
    }
}
TEXT
Test output:
  Expected: 450000
  Actual:   500000
  Transfer was silently dropped. readOnly=true prevented the flush.

Code review rule:
  Any write operation (save, update, delete, entity modification)
  under an inherited or explicit readOnly=true is a critical bug.
  Treat it as severity P0 in code review.

What are the performance benefits of readOnly=true?

readOnly=true is not just a correctness risk -- it has genuine performance benefits when used correctly on query methods:

  • Skip dirty checking -- Hibernate does not compare entity snapshots at flush time, saving CPU on large result sets.
  • Read replica routing -- databases and connection pool routers can use the readOnly hint to send queries to read replicas, reducing load on the primary.
  • No flush overhead -- skipping the flush saves a round trip for each query within the transaction.
JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Service
public class ReportingService {

    private final AccountRepository accountRepository;

    public ReportingService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional(readOnly = true)
    public List<Account> generateEndOfDayReport() {
        // 10,000 accounts loaded
        // With readOnly: no dirty checking on 10,000 entities
        // Without readOnly: Hibernate compares all 10,000 snapshots
        return accountRepository.findAll();
    }
}
TEXT
Performance comparison for 10,000 entities:
  With readOnly=true:
    Load: 200ms
    Dirty check: SKIPPED
    Total: 200ms

  Without readOnly (FlushMode.AUTO):
    Load: 200ms
    Dirty check: 150ms (compare 10,000 snapshots)
    Total: 350ms

  readOnly saves ~43% time on large read-only queries.

What should a senior engineer know?

  • readOnly is a correctness switch disguised as a performance switch -- on the read path, it is great because it skips dirty checking and enables read replica routing. On the write path, it silently destroys data. Treat it with the same seriousness as a security setting.
  • Database-level read-only -- some databases (PostgreSQL) enforce read-only at the transaction level. If SET TRANSACTION READ ONLY is sent, an INSERT will throw an error instead of being silently dropped. But this depends on the JDBC driver and database combination.
  • Spring Data JPA repositories -- SimpleJpaRepository (the default implementation) uses @Transactional(readOnly = true) on query methods by default. This is correct because repository query methods are read-only by design.
  • Testing strategy -- always write integration tests that verify write operations by reading the data back from the database in a separate transaction. Do not rely on in-memory entity state.
JAVA
package com.bank.config;

import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.jupiter.api.Test;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

class ReadOnlyArchTest {

    @Test
    void noClassLevelReadOnlyOnServicesThatHaveWriteMethods() {
        // Custom ArchUnit rule to flag class-level readOnly
        // on services that contain save/update/delete calls
        var classes = new ClassFileImporter()
            .importPackages("com.bank.service");

        // This is a simplified check -- in practice, use a custom
        // ArchCondition that inspects method bodies
        ArchRule rule = noClasses()
            .that().resideInAPackage("com.bank.service")
            .and().haveSimpleNameEndingWith("WriteService")
            .should().beAnnotatedWith(
                org.springframework.transaction.annotation.Transactional.class)
            .because("Write services should use method-level @Transactional");

        rule.check(classes);
    }
}
TEXT
Senior engineer checklist for readOnly:
  1. Never put readOnly=true at class level on a service that writes
  2. Use method-level annotations for services with mixed read/write
  3. Integration tests must verify writes by reading from DB
  4. Code review: any write under readOnly is a P0 bug
  5. Consider PostgreSQL SET TRANSACTION READ ONLY for DB-level enforcement

What are common interview follow-up questions?

  • Does readOnly=true throw an error on write? No, not in the default Spring + Hibernate setup. The write is silently discarded. Some databases may throw if they enforce read-only at the SQL level.
  • Can you call session.flush() manually under readOnly? Technically yes, but it defeats the purpose of readOnly and may behave inconsistently across Hibernate versions.
  • Does readOnly affect native queries? @Query(nativeQuery = true) with an UPDATE or INSERT will execute because native queries bypass the Hibernate persistence context. But this is unreliable and should not be relied upon.
  • How does readOnly interact with Spring Data JPA's default? SimpleJpaRepository annotates read methods with @Transactional(readOnly = true) by default. Custom write methods on the repository interface get plain @Transactional. This is the correct pattern.

Visual Overview

DIAGRAM
┌──────────────────────────────────────────────────────────┐
│  FlushMode.AUTO (default, readOnly=false)                 │
│                                                           │
│  Entity modified → dirty flag set                         │
│       │                                                   │
│  Before query or commit:                                  │
│       │                                                   │
│  Hibernate AUTO FLUSH                                     │
│       │                                                   │
│  ┌─────────────────────────────────┐                      │
│  │ Compare entity snapshot         │                      │
│  │ Generate UPDATE/INSERT SQL      │                      │
│  │ Send to database                │                      │
│  └─────────────────────────────────┘                      │
│       │                                                   │
│  Data persisted ✓                                         │
└──────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────┐
│  FlushMode.MANUAL (readOnly=true)                         │
│                                                           │
│  Entity modified → dirty flag set (ignored)               │
│       │                                                   │
│  Before query or commit:                                  │
│       │                                                   │
│  Hibernate SKIPS flush                                    │
│       │                                                   │
│  ┌─────────────────────────────────┐                      │
│  │ No snapshot comparison          │                      │
│  │ No SQL generated                │                      │
│  │ Nothing sent to database        │                      │
│  └─────────────────────────────────┘                      │
│       │                                                   │
│  Transaction commits (read-only)                          │
│  Pending changes DISCARDED silently                       │
│  Data NOT persisted ✗                                     │
└──────────────────────────────────────────────────────────┘

Key Takeaway

The reason @Transactional(readOnly=true) can make a write silently disappear is that Spring uses it to switch the Hibernate session's flush mode to MANUAL. Normally, Hibernate runs in AUTO flush mode, so before a query and at commit time it automatically generates INSERT and UPDATE statements from the in-memory persistence context. Under readOnly, the flush mode is MANUAL, which means that automatic flush never happens. Any entity modification or save() call stays in memory and is quietly discarded when the transaction commits. There is no exception and no warning.

At the National Bank of India, a developer put readOnly=true on the entire AccountService class because most methods were lookups and read-only transactions are a genuine performance optimization. The problem was that the transfer() and createAccount() methods inherited that readOnly setting. They debited and credited entities in memory, returned success, and committed nothing. Priya was told her transfer succeeded, but the money never moved. The code looked correct and even passed shallow tests because the in-memory entity objects had the correct values.

Pankaj, the architect, enforces a strict rule: readOnly=true is placed only on individual query methods, never at the class level of a service that also writes. If a class-level default must exist, every write method gets an explicit @Transactional override. In code review, any write operation under an inherited readOnly is treated as a severity P0 bug.

Remember one sentence: readOnly=true is a correctness switch disguised as a performance optimization -- it silently drops writes, so never let a write method inherit it.

---

Share
Use ← → arrow keys to navigate