Back to Performance: Large IN, Dirty Checking, OSIV
Q2Question 2 of 6

readOnly, dirty checking, and Open Session in View?

Key Concepts
  • Dirty checking compares every managed entity's current state against its load-time snapshot at flush time
  • @Transactional(readOnly=true) tells Hibernate to skip dirty checking: no snapshots stored, no field comparisons at flush
  • For 1,000 entities with 20 fields, readOnly saves 20,000 field comparisons and 1,000 snapshot copies
  • The JDBC connection is also hinted as read-only, enabling database optimizations like read replica routing
  • Open Session in View (OSIV) keeps the Hibernate Session open through the entire HTTP request lifecycle
  • OSIV masks LazyInitializationException but holds database connections during JSON serialization
  • Disable OSIV with spring.jpa.open-in-view=false and fetch all data inside @Transactional with JOIN FETCH

Definition

@Transactional(readOnly=true) skips dirty checking (no snapshots, no field comparisons) and hints the JDBC connection as read-only; OSIV keeps the session open through JSON serialization, masking lazy-load problems while holding connections too long.
  • Dirty checking – Hibernate's automatic change detection. At flush time, it compares every managed entity's current field values against the snapshot taken at load time. Changed fields generate UPDATE SQL.
  • @Transactional(readOnly=true) – tells Hibernate to skip dirty checking for the entire transaction. No snapshots are stored, no comparisons run, and the JDBC connection is hinted as read-only.
  • Snapshot – a deep copy of all field values taken when an entity is first loaded into the persistence context. Used by dirty checking to detect changes.
  • Open Session in View (OSIV) – a Spring Boot filter that keeps the Hibernate Session open for the entire HTTP request, including view rendering and JSON serialization.
  • OSIV problems – holds database connections during serialization (pool exhaustion), hides N+1 queries in the view layer, and risks accidental writes outside @Transactional.

readOnly=true tells the National Bank of India clerk: "Rajesh is only reading his statement, not making transfers." The clerk skips preparing the transfer forms (dirty checking) and uses a lighter process. OSIV is like keeping the vault door open while Rajesh reads his statement in the lobby – convenient (he can peek at more accounts), but dangerous (the vault is occupied too long and other customers cannot access it).

Understanding the Concepts

How does Hibernate dirty checking work?

Dirty checking is Hibernate's automatic change detection. When an entity is loaded into the persistence context, Hibernate takes a snapshot – a deep copy of all field values. At flush time (before commit), Hibernate compares every managed entity's current state against its snapshot. If any field changed, it generates an UPDATE SQL statement.

JAVA
package com.bank.service;

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

@Service
public class AccountUpdateService {

    private final AccountRepository accountRepo;

    public AccountUpdateService(AccountRepository accountRepo) {
        this.accountRepo = accountRepo;
    }

    @Transactional
    public void updateBalance(String accountId, BigDecimal newBalance) {
        Account account = accountRepo.findById(accountId).orElseThrow();
        // Hibernate takes snapshot: {id=SAV2001, balance=500000, version=1}

        account.setBalance(newBalance);
        // No explicit save() needed!

        // At commit, Hibernate compares current vs snapshot:
        // current: {balance=400000} vs snapshot: {balance=500000}
        // Field changed! Generates:
        // UPDATE accounts SET balance=400000, version=2
        // WHERE account_id='SAV2001' AND version=1
    }
}

What does @Transactional(readOnly=true) optimize?

When readOnly=true, Hibernate skips snapshot storage and dirty checking. For N loaded entities with M fields each, this saves N x M comparisons at flush time plus the memory for N snapshot copies.

JAVA
package com.bank.service;

import com.bank.dto.TradeOverview;
import com.bank.model.Trade;
import com.bank.repository.TradeRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Service
public class TradeReportService {

    private final TradeRepository tradeRepo;

    public TradeReportService(TradeRepository tradeRepo) {
        this.tradeRepo = tradeRepo;
    }

    @Transactional(readOnly = true)
    public List<TradeOverview> getSettledTrades(String accountId) {
        List<Trade> trades = tradeRepo.findByAccountIdAndStatus(
            accountId, "SETTLED");
        // Hibernate loads trades but does NOT take snapshots
        // At commit: NO dirty checking runs (no snapshots to compare)
        // Savings: no snapshot memory, no comparison CPU

        return trades.stream()
            .map(t -> new TradeOverview(t.getTradeId(), t.getSymbol(), t.getAmount()))
            .toList();
    }
}
TEXT
With readOnly=false (1000 entities, 20 fields each):
  Snapshot storage: 1000 copies x 20 fields = 20,000 field copies
  Flush comparison: 1000 entities x 20 fields = 20,000 comparisons
  Memory: ~5MB for snapshots

With readOnly=true:
  Snapshot storage: 0
  Flush comparison: 0
  Memory saved: ~5MB

How does readOnly help at the database level?

The JDBC connection is also hinted as read-only. Some databases use this hint for optimizations.

  • PostgreSQL: uses a lighter MVCC snapshot with reduced locking overhead
  • MySQL InnoDB: skips undo log creation for read-only transactions
  • Connection pool: read-only hint can route to a read replica in master-slave setups
YAML
# application.yml: routing read-only queries to replica
spring:
  datasource:
    primary:
      url: jdbc:postgresql://primary:5432/national_bank
    replica:
      url: jdbc:postgresql://replica:5432/national_bank
  # @Transactional(readOnly=true) routes to replica datasource
  # @Transactional routes to primary datasource

What is Open Session in View (OSIV)?

OSIV is a Spring Boot filter (OpenEntityManagerInViewInterceptor) that keeps the JPA EntityManager open for the entire HTTP request lifecycle – from controller entry through JSON serialization. It is enabled by default in Spring Boot.

JAVA
package com.bank.controller;

import com.bank.model.Account;
import com.bank.service.AccountService;
import org.springframework.web.bind.annotation.*;

@RestController
public class AccountController {

    private final AccountService accountService;

    public AccountController(AccountService accountService) {
        this.accountService = accountService;
    }

    // WITH OSIV (default: spring.jpa.open-in-view=true):
    @GetMapping("/accounts/{id}")
    public Account getAccount(@PathVariable String id) {
        Account account = accountService.findById(id);
        // @Transactional ends in service layer
        // But session is still OPEN because of OSIV!
        // JSON serialization triggers: account.getTrades()
        // Fires lazy load SQL. No LazyInitializationException.
        // BUT: DB connection held during entire serialization!
        return account;
    }
}

Why is OSIV an anti-pattern in production?

OSIV causes three problems in production. First, database connections are held for the entire request duration including JSON serialization. Under load, the connection pool is exhausted. Second, lazy loads during serialization fire N+1 queries invisible to the service layer. Third, accidental writes during rendering could persist without an explicit @Transactional boundary.

YAML
# WRONG: OSIV enabled (Spring Boot default)
spring:
  jpa:
    open-in-view: true
    # DB connection held from controller entry to response complete
    # Lazy loads fire during JSON serialization (hidden N+1)

# CORRECT: disable OSIV
spring:
  jpa:
    open-in-view: false
    # Spring Boot warns at startup if OSIV is enabled:
    # "spring.jpa.open-in-view is enabled by default.
    #  Therefore, database queries may be performed during view rendering."

How do you fix LazyInitializationException without OSIV?

Fetch everything inside the @Transactional method using JOIN FETCH or @EntityGraph, then map to a DTO before returning. The DTO has no lazy proxies and is safe to serialize.

JAVA
package com.bank.service;

import com.bank.dto.AccountDetailDTO;
import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AccountService {

    private final AccountRepository accountRepo;

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

    // WRONG: returning entity to controller (lazy proxies explode without OSIV)
    @Transactional(readOnly = true)
    public Account findById(String id) {
        return accountRepo.findById(id).orElseThrow();
    }

    // CORRECT: JOIN FETCH + map to DTO inside transaction
    @Transactional(readOnly = true)
    public AccountDetailDTO getAccountDetail(String id) {
        Account account = accountRepo.findWithTrades(id).orElseThrow();
        // Account + trades loaded via JOIN FETCH in one query
        return new AccountDetailDTO(
            account.getAccountId(),
            account.getHolderName(),
            account.getBalance(),
            account.getTrades().stream()
                .map(t -> new AccountDetailDTO.TradeItem(
                    t.getTradeId(), t.getSymbol(), t.getAmount()))
                .toList()
        );
        // DTO has no proxies. Safe to serialize after session closes.
    }
}

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

@Transactional(readOnly=true) applies to the entire transaction – ALL queries skip dirty checking. @QueryHints(readOnly=true) applies to a single query – only entities loaded by that method skip dirty checking. Other queries in the same transaction still have full dirty checking.

| Scope | @Transactional(readOnly=true) | @QueryHints(readOnly=true) |
|-------|------------------------------|---------------------------|
| Applies to | Entire transaction | Single query |
| Dirty checking | Skipped for ALL entities | Skipped for THIS query's entities |
| JDBC hint | Connection hinted read-only | No connection hint |
| Use case | Read-only service methods | Mix of read and write in one TX |

JAVA
package com.bank.service;

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

@Service
public class PortfolioReportService {

    private final TradeRepository tradeRepo;
    private final AccountRepository accountRepo;

    public PortfolioReportService(TradeRepository tradeRepo,
                                   AccountRepository accountRepo) {
        this.tradeRepo = tradeRepo;
        this.accountRepo = accountRepo;
    }

    @Transactional  // NOT readOnly: this transaction writes
    public void generateAndStamp(String accountId) {
        // Trades loaded with @QueryHints(readOnly=true) on repo method
        var trades = tradeRepo.findByAccountId(accountId);
        // These Trade entities skip dirty checking (query-level readOnly)

        // Account loaded normally (with dirty checking)
        Account account = accountRepo.findById(accountId).orElseThrow();
        account.setLastReportDate(java.time.LocalDate.now());
        // This Account IS dirty-checked. UPDATE generated at commit.
    }
}

What are common mistakes with readOnly and OSIV?

The most common mistake is using @Transactional(readOnly=true) on a method that writes data. The write is silently ignored.

JAVA
package com.bank.service;

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

@Service
public class ReadOnlyMistakeDemo {

    private final AccountRepository accountRepo;

    public ReadOnlyMistakeDemo(AccountRepository accountRepo) {
        this.accountRepo = accountRepo;
    }

    // WRONG: readOnly=true but method writes data
    @Transactional(readOnly = true)
    public void wrongDebit(String accountId, BigDecimal amount) {
        Account account = accountRepo.findById(accountId).orElseThrow();
        account.setBalance(account.getBalance().subtract(amount));
        // No snapshot taken, no dirty checking
        // Balance change is SILENTLY IGNORED!
    }

    // CORRECT: no readOnly for write operations
    @Transactional
    public void correctDebit(String accountId, BigDecimal amount) {
        Account account = accountRepo.findById(accountId).orElseThrow();
        account.setBalance(account.getBalance().subtract(amount));
        // Dirty checking detects the change. UPDATE generated.
    }
}

Visual Overview

DIAGRAM
  Dirty Checking Flow:
  ┌───────────────────────────────────┐
  │  1. Load entity                     │
  │     SELECT * FROM accounts           │
  │     Take SNAPSHOT of all fields      │
  └─────────────────┬─────────────────┘
                    │
                    ▼
  ┌───────────────────────────────────┐
  │  2. Application modifies fields      │
  │     account.setBalance(400000)       │
  └─────────────────┬─────────────────┘
                    │
                    ▼
  ┌───────────────────────────────────┐
  │  3. Flush (before commit)            │
  │     Compare current vs snapshot      │
  │     balance: 400000 vs 500000 DIRTY! │
  │     Generate: UPDATE accounts SET ... │
  └───────────────────────────────────┘

  readOnly=true: steps 1 and 3 are SKIPPED
  (no snapshot, no comparison, no UPDATE)

  OSIV vs No OSIV:
  ┌─────────────────────────────────────────┐
  │  With OSIV:                                 │
  │  Controller → Service(@TX) → JSON serialize  │
  │  │───── Session OPEN ──────────────────┤  │
  │  DB connection held entire time              │
  └─────────────────────────────────────────┘
  ┌─────────────────────────────────────────┐
  │  Without OSIV:                              │
  │  Controller → Service(@TX) → JSON serialize  │
  │  │───── Session ───┤                          │
  │  DB connection released after service return │
  └─────────────────────────────────────────┘

Key Takeaway

@Transactional(readOnly=true) tells Hibernate to skip dirty checking: no load-time snapshots are stored, and no field-by-field comparison runs at flush time. For 1,000 entities with 20 fields, this saves 20,000 comparisons and the memory for 1,000 snapshot copies. The underlying JDBC connection is also hinted as read-only, enabling database optimizations like lighter locking in PostgreSQL or read-replica routing.

Open Session in View is a Spring Boot default that keeps the Hibernate Session open through the entire HTTP request. It masks LazyInitializationException by allowing lazy loads during JSON serialization, but holds database connections for the entire response cycle, hides N+1 queries in the view layer, and can exhaust the connection pool under load. At National Bank of India, Rajesh disables OSIV with spring.jpa.open-in-view=false and fetches all data inside @Transactional using JOIN FETCH, mapping to DTOs before returning.

The correct production architecture is: disable OSIV, use @Transactional(readOnly=true) for all read methods, fetch needed associations with JOIN FETCH or @EntityGraph inside the transaction, map to DTOs, and return DTOs to the controller. The DTO has no lazy proxies and is safe to serialize after the session closes.

Remember one sentence: readOnly skips dirty checking for performance, OSIV masks lazy-load problems while holding connections too long – disable OSIV and fetch properly inside the transaction.

---

Share
Use ← → arrow keys to navigate