Back to Performance: Large IN, Dirty Checking, OSIV
Q4Question 4 of 6

readOnly, dirty checking, and OSIV?

Key Concepts
  • @Transactional(readOnly=true) sets FlushMode to MANUAL, skipping dirty checking entirely for that transaction.
  • Dirty checking compares every managed entity's current state against a snapshot taken at load time, which is wasted CPU on pure reads.
  • The readOnly flag also sets the JDBC connection read-only, enabling replica routing and relaxed locking on supported databases.
  • OSIV (Open Session In View) keeps the Hibernate Session open for the entire HTTP request, hiding N+1 queries and holding connections too long.
  • Disable OSIV with spring.jpa.open-in-view=false and fetch everything inside the service transaction using JOIN FETCH or DTO projections.
  • Combining readOnly=true with DTO projections gives the best read performance because neither dirty checking nor entity snapshots occur.

Definition

readOnly=true skips dirty checking and enables replica reads; OSIV keeps the session open too long and hides N+1 queries, so disable it.
  • @Transactional(readOnly=true) – hints that the transaction performs no writes, so Hibernate sets FlushMode to MANUAL and skips dirty-check snapshot comparisons.
  • Dirty checking – the process where Hibernate compares every managed entity's current state against a snapshot taken at load time to generate UPDATE statements at flush.
  • FlushMode.MANUAL – prevents automatic flushing, so no dirty-check comparison runs and accidental mutations are never persisted.
  • JDBC read-only connection – the readOnly flag sets the connection to read-only, allowing the database driver or pool to route queries to a read replica.
  • Open Session In View (OSIV) – a Spring Boot default (spring.jpa.open-in-view=true) that binds the Hibernate Session to the entire HTTP request so lazy associations can load in the controller or view layer.
  • OSIV downsides – holds database connections longer, hides N+1 queries fired from the view layer, and blurs transaction boundaries.

readOnly=true is like telling the bank clerk at National Bank of India that you are only here to check your balance, not to make any changes. The clerk skips the slow double-checking of whether anything was edited (dirty checking) and can send you to a faster viewing-only counter (a read replica). OSIV is like leaving the vault door open for your entire visit so you can keep fetching more files whenever you remember them. It is convenient, but it ties up the vault and the clerk far too long, and you never notice you are making a hundred trips instead of one.

Understanding the Concepts

What is @Transactional(readOnly=true)?

The readOnly attribute on @Transactional tells Spring and Hibernate that the transaction will not write anything. Hibernate responds by setting the FlushMode to MANUAL, which means it will not automatically compare entity state at the end of the transaction. The JDBC connection is also flagged as read-only, which many databases and connection pools use to route the query to a read replica.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.bank.repository.AccountRepository;
import com.bank.model.Account;
import java.math.BigDecimal;

@Service
public class BalanceService {

    private final AccountRepository accountRepository;

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

    @Transactional(readOnly = true)
    public BigDecimal getBalance(String accountId) {
        Account account = accountRepository.findById(accountId)
                .orElseThrow(() -> new IllegalArgumentException("Account not found: " + accountId));
        return account.getBalance();
    }
}
TEXT
Expected behaviour:
- Hibernate loads Account SAV2001 but takes NO dirty-check snapshot.
- Even if buggy code calls account.setBalance(...), nothing is persisted because FlushMode is MANUAL.
- The JDBC connection is flagged read-only, so it can route to a read replica.

Why does readOnly=true improve performance?

Every managed entity loaded into the persistence context carries a snapshot of its original state. At flush time, Hibernate iterates over every managed entity and compares the current field values against the snapshot to detect changes. This comparison is proportional to the number of managed entities and the number of fields on each entity. For a pure read that loads hundreds of accounts, the comparison is completely wasted CPU.

With readOnly=true, Hibernate sets FlushMode to MANUAL. No automatic flush occurs, so no snapshot comparison runs. The entities are still loaded into the persistence context for identity guarantees, but no snapshot is taken. This roughly halves the memory per entity and eliminates the CPU cost of comparison.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.bank.repository.AccountRepository;
import com.bank.dto.AccountSummary;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class StatementService {

    private final AccountRepository accountRepository;

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

    @Transactional(readOnly = true)
    public List<AccountSummary> listAllAccounts() {
        // Loading 50,000 accounts with readOnly:
        // - No snapshot array allocated per entity
        // - No dirty-check comparison at commit
        // - JDBC connection routed to read replica
        return accountRepository.findAll().stream()
                .map(a -> new AccountSummary(a.getAccountId(), a.getBalance()))
                .collect(Collectors.toList());
    }
}
TEXT
Expected result:
- 50,000 Account entities loaded without dirty-check snapshots.
- Commit completes instantly because no flush comparison runs.
- Memory usage roughly halved compared to a writable transaction.

How does dirty checking work internally?

When Hibernate loads an entity, it stores a snapshot (a copy of all field values as an Object array) in the persistence context. At flush time, Hibernate walks every managed entity, reads each field via reflection or bytecode enhancement, and compares the current value to the snapshot. If any field differs, Hibernate generates an UPDATE statement. This is called dirty checking.

The cost is proportional to the number of managed entities multiplied by the number of fields. For a read-heavy endpoint that loads a large result set, this cost is pure waste.

JAVA
package com.bank.demo;

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "accounts")
public class Account {

    @Id
    @Column(name = "account_id")
    private String accountId;

    @Column(name = "holder_name", nullable = false)
    private String holderName;

    @Column(name = "balance", precision = 15, scale = 2)
    private BigDecimal balance;

    // Hibernate loads this entity and stores:
    // snapshot = ["SAV2001", "Rajesh Kumar", BigDecimal("150000.00")]
    // At flush: compare current fields vs snapshot
    // If balance changed from 150000.00 to 149000.00 -> generate UPDATE
}
TEXT
Dirty-check flow:
1. Load Account SAV2001 -> snapshot = ["SAV2001", "Rajesh", 150000.00]
2. Code calls account.setBalance(new BigDecimal("149000.00"))
3. Flush triggers -> compare current vs snapshot
4. balance differs -> generate: UPDATE accounts SET balance=149000.00 WHERE account_id='SAV2001'
5. With readOnly=true: step 3 never happens, step 4 never runs.

What is Open Session In View (OSIV)?

OSIV is a Spring Boot default (spring.jpa.open-in-view=true) that registers an OpenEntityManagerInViewInterceptor. This interceptor binds the Hibernate Session (EntityManager) to the current thread for the entire HTTP request, from the moment the request enters the dispatcher through controller, service, view rendering, and response writing. The purpose is that lazy associations can be loaded anywhere during the request, even in the controller or template, without a LazyInitializationException.

YAML
# application.yml
spring:
  jpa:
    open-in-view: true   # default in Spring Boot -- the OSIV anti-pattern
JAVA
package com.bank.controller;

import org.springframework.web.bind.annotation.*;
import com.bank.repository.AccountRepository;
import com.bank.model.Account;
import java.util.List;

@RestController
public class AccountController {

    private final AccountRepository accountRepository;

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

    @GetMapping("/accounts/{id}")
    public Account getAccount(@PathVariable String id) {
        Account account = accountRepository.findById(id).orElseThrow();
        // With OSIV on: account.getTrades() triggers a lazy load HERE in the controller.
        // A SELECT fires against the database outside any explicit @Transactional.
        // This is the hidden N+1 that OSIV masks.
        return account;
    }
}
TEXT
With OSIV enabled:
1. Request enters -> Hibernate Session bound to thread
2. Service loads Account SAV2001
3. Controller returns Account -> Jackson serializes it
4. Jackson calls account.getTrades() -> lazy load fires a SELECT
5. For 50 accounts, 50 extra SELECTs fire during serialization -> N+1!
6. Session closes only after response is written -> connection held the whole time

Why is OSIV considered an anti-pattern?

OSIV causes three serious problems in production. First, it holds a database connection from the start of the request through view rendering and response writing, which reduces connection pool availability under load. Second, lazy loads in the controller or template fire invisible queries that mask the N+1 problem. Instead of getting a LazyInitializationException that forces you to fix the fetch strategy, the queries silently run and the application works until it hits production traffic. Third, the transaction boundary no longer maps cleanly to the service method, so queries can fire outside any explicit transaction.

YAML
# Correct: disable OSIV
spring:
  jpa:
    open-in-view: false
JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.bank.repository.AccountRepository;
import com.bank.model.Account;
import java.util.Optional;

@Service
public class AccountService {

    private final AccountRepository accountRepository;

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

    @Transactional(readOnly = true)
    public Account getAccountWithTrades(String accountId) {
        // With OSIV off, fetch everything inside the transaction
        return accountRepository.findWithTrades(accountId)
                .orElseThrow(() -> new IllegalArgumentException("Account not found"));
    }
}
JAVA
package com.bank.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import com.bank.model.Account;
import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, String> {

    @Query("SELECT a FROM Account a LEFT JOIN FETCH a.trades WHERE a.accountId = :id")
    Optional<Account> findWithTrades(String id);
}
TEXT
With OSIV disabled:
1. Service loads Account + trades in ONE query via JOIN FETCH
2. Connection released when @Transactional method returns
3. Controller receives a fully initialized object -> zero extra queries
4. N+1 is impossible because lazy loads outside the session throw LazyInitializationException

How is readOnly=true different from @QueryHints(readOnly)?

The @Transactional(readOnly=true) flag applies to the entire transaction: every entity loaded in that transaction skips dirty checking, and the JDBC connection is flagged read-only. The @QueryHints(@QueryHint(name="org.hibernate.readOnly", value="true")) annotation applies to a single repository method: only the entities returned by that specific query skip dirty checking.

| Aspect | @Transactional(readOnly=true) | @QueryHints(readOnly) |
|---|---|---|
| Scope | Entire transaction | Single query |
| FlushMode | MANUAL for all queries | Only returned entities are read-only |
| JDBC read-only | Yes | No |
| Replica routing | Yes (if pool supports it) | No |
| Use case | Entire read-only service method | One heavy query in a writable transaction |

JAVA
package com.bank.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.QueryHints;
import jakarta.persistence.QueryHint;
import com.bank.model.Account;
import java.util.List;

public interface AccountRepository extends JpaRepository<Account, String> {

    // Method-level readOnly: only these entities skip dirty checking
    @QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true"))
    List<Account> findByBranchCode(String branchCode);
}

What are common mistakes with readOnly and OSIV?

The most common mistake is assuming readOnly=true makes the database truly read-only. It does not enforce a constraint on all databases. It is an optimization hint. A second mistake is leaving OSIV enabled and never noticing the N+1 queries hidden in the controller layer.

JAVA
// WRONG: assuming readOnly prevents writes on all databases
package com.bank.service;

import org.springframework.transaction.annotation.Transactional;
import com.bank.repository.AccountRepository;
import com.bank.model.Account;
import java.math.BigDecimal;

public class BuggyService {

    private final AccountRepository accountRepository;

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

    @Transactional(readOnly = true)
    public void oops(String accountId) {
        Account account = accountRepository.findById(accountId).orElseThrow();
        account.setBalance(BigDecimal.ZERO); // mutation in a read-only transaction
        // With Hibernate: FlushMode is MANUAL, so this is NOT persisted.
        // But do NOT rely on this as a safety mechanism.
        // Some JPA providers or future versions might behave differently.
    }
}
JAVA
// CORRECT: use readOnly for genuine reads, not as a write-prevention guard
package com.bank.service;

import org.springframework.transaction.annotation.Transactional;
import com.bank.repository.AccountRepository;
import java.math.BigDecimal;

public class CorrectService {

    private final AccountRepository accountRepository;

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

    @Transactional(readOnly = true)
    public BigDecimal getBalance(String accountId) {
        return accountRepository.findById(accountId)
                .orElseThrow()
                .getBalance();
        // Pure read: no mutation, readOnly skips dirty checking for performance.
    }
}

When should you use readOnly=true?

Use readOnly=true on every service method that only reads data. This includes balance enquiries, statement views, report generation, search endpoints, and any method that returns data without modifying it. Combine it with DTO projections for best performance because the projection skips entity instantiation entirely.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.bank.repository.AccountRepository;
import com.bank.dto.AccountSummary;
import java.util.List;

@Service
public class ReportService {

    private final AccountRepository accountRepository;

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

    @Transactional(readOnly = true)
    public List<AccountSummary> getDailyReport(String branchCode) {
        // Best combination: readOnly + DTO projection
        // - readOnly: JDBC connection flagged for replica routing
        // - DTO projection: no entity loaded, no persistence context overhead
        return accountRepository.findSummaryByBranchCode(branchCode);
    }
}
TEXT
Performance comparison for 10,000 accounts:
- Full entity, writable TX:   ~120ms, ~80MB heap (snapshots + entities)
- Full entity, readOnly TX:   ~80ms,  ~45MB heap (entities, no snapshots)
- DTO projection, readOnly TX: ~40ms, ~15MB heap (plain objects, no PC)

What about thread safety and connection pool impact?

With OSIV enabled, the database connection is held for the entire HTTP request duration, including view rendering and response writing. Under high concurrency, this exhausts the connection pool. With OSIV disabled and readOnly=true, the connection is acquired when the @Transactional method starts and released when it returns, which is the shortest possible hold time.

JAVA
package com.bank.config;

import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DataSourceConfig {

    @Bean
    @ConfigurationProperties("spring.datasource.hikari")
    public HikariDataSource dataSource() {
        HikariDataSource ds = new HikariDataSource();
        // With OSIV off + readOnly, connections are held only during service calls
        // Pool of 20 connections can serve far more concurrent requests
        ds.setMaximumPoolSize(20);
        ds.setConnectionTimeout(5000);
        return ds;
    }
}
TEXT
Connection hold time comparison (50 concurrent requests):
- OSIV on:  connection held ~200ms (service 50ms + serialization 150ms)
- OSIV off: connection held ~50ms  (service only)
- Pool of 20 handles 400 req/s with OSIV off vs 100 req/s with OSIV on

What should a senior engineer know?

A senior engineer knows that readOnly=true does not make the database truly read-only for all engines but is a strong optimization and safety hint. They combine it with DTO projections for the best read performance. They disable OSIV in every microservice and treat LazyInitializationException as a signal to fix the fetch strategy, not as a problem to suppress. They also know that readOnly=true at the transaction level composes with method-level query hints: you can run a writable transaction overall but mark one expensive reporting query as read-only through a @QueryHint.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.bank.repository.AccountRepository;
import com.bank.repository.TradeRepository;
import com.bank.model.Trade;
import java.math.BigDecimal;

@Service
public class TradeSettlementService {

    private final AccountRepository accountRepository;
    private final TradeRepository tradeRepository;

    public TradeSettlementService(AccountRepository accountRepository,
                                  TradeRepository tradeRepository) {
        this.accountRepository = accountRepository;
        this.tradeRepository = tradeRepository;
    }

    @Transactional // writable transaction
    public void settleTrade(String tradeId) {
        Trade trade = tradeRepository.findById(tradeId).orElseThrow();
        BigDecimal amount = trade.getAmount();

        // This query loads accounts read-only via @QueryHints on the repository method
        // Even though the overall transaction is writable
        var account = accountRepository.findReadOnlyById(trade.getAccountId());

        // Settlement logic continues with writable entities...
        trade.setStatus("SETTLED");
    }
}

Visual Overview

TEXT
  Dirty Checking Flow (writable transaction)
  ============================================

  persist(account)     load(account)       flush / commit
       |                    |                    |
       v                    v                    v
  +-----------+      +-------------+      +------------------+
  | Entity    |      | Entity      |      | For EACH entity: |
  | created   |----->| snapshot[]  |----->| current == snap? |
  |           |      | stored in   |      | YES -> skip      |
  |           |      | Persistence |      | NO  -> UPDATE    |
  +-----------+      | Context     |      +------------------+
                     +-------------+

  readOnly=true: FlushMode = MANUAL
  ============================================

  persist(account)     load(account)       commit
       |                    |                 |
       v                    v                 v
  +-----------+      +-------------+      +----------+
  | Entity    |      | NO snapshot |      | No flush |
  | created   |----->| stored      |----->| No check |
  |           |      |             |      | Fast     |
  +-----------+      +-------------+      +----------+

  OSIV Connection Timeline
  ============================================

  OSIV ON:
  |-- Request --|-- Service --|-- Controller --|-- JSON --|-- Response --|
  |<============ Connection held entire request ========================>|

  OSIV OFF:
  |-- Request --|-- Service --|-- Controller --|-- JSON --|-- Response --|
                |<= Conn held =>|
                   (released)

Key Takeaway

The readOnly flag on @Transactional is both a performance optimization and a safety guard. When Rajesh marks a balance-enquiry method readOnly=true in the National Bank of India service, Hibernate sets the flush mode to manual, which means it skips dirty checking entirely. Normally Hibernate snapshots every loaded entity and, at flush time, compares the current state to the snapshot to generate updates. For a pure read that comparison is wasted CPU, and skipping it saves memory and time. The JDBC connection is also flagged read-only, which the database driver or pool can use to route the query to a read replica.

Open Session In View is the anti-pattern that Pankaj, the architect, disables in every microservice at National Bank of India. With OSIV on, Spring binds the Hibernate session to the whole HTTP request, so lazy associations can load anywhere, even during JSON serialization. That convenience hides three real problems: the database connection is held for the entire request duration, lazy loads in the controller fire invisible N+1 queries, and the transaction boundary no longer maps to the service method. Disabling OSIV with spring.jpa.open-in-view=false forces the team to fetch everything inside the service transaction using JOIN FETCH or DTO projections, which makes the query count explicit and predictable.

The senior position is that readOnly=true on every read method, OSIV disabled, and DTO projections for list endpoints together give the fastest, most predictable read path. Connections are released promptly, dirty checking is eliminated, and N+1 queries are impossible because lazy loads outside the session throw a LazyInitializationException that forces the developer to fix the fetch strategy rather than hide it.

Remember one sentence: readOnly=true skips dirty checking and enables replica routing, while OSIV disabled forces explicit fetch strategies and releases connections promptly.

---

Share
Use ← → arrow keys to navigate