Back to N+1 Problem & Fetching
Q2Question 2 of 2

What is EAGER vs LAZY fetching?

Key Concepts
  • LAZY means the related entity or collection is loaded only when you first access it through a Hibernate proxy
  • EAGER means the related entity is loaded immediately with the parent query, even if you never use it
  • @ManyToOne and @OneToOne default to EAGER; @OneToMany and @ManyToMany default to LAZY
  • Always override @ManyToOne to LAZY to prevent accidental N+1 queries
  • LazyInitializationException occurs when you access a lazy association after the Hibernate Session closes
  • getReference() returns a lazy proxy without hitting the database – ideal for setting foreign keys
  • findById() hits the database immediately and returns Optional – use when you need the actual data

Definition

LAZY loads related entities only when accessed (via proxy); EAGER loads them immediately with the parent – always override @ManyToOne to LAZY and fetch what you need inside the transaction.
  • LAZY fetching – the related entity or collection is loaded only when you first call a method on the proxy. Hibernate creates a lightweight stand-in object that triggers the SQL on demand.
  • EAGER fetching – the related entity is loaded immediately with the parent query, using either a JOIN or a subselect. Every load of the parent also loads the association.
  • Default fetch types – @ManyToOne and @OneToOne default to EAGER. @OneToMany and @ManyToMany default to LAZY. The EAGER defaults on @ManyToOne are the most common source of N+1.
  • LazyInitializationException – thrown when you access a lazy association after the Hibernate Session has closed. The proxy tries to fire SQL but has no open session.
  • getReference() / findById() – getReference() returns a lazy proxy without a database hit (ideal for FK-only writes). findById() hits the database immediately and returns Optional.

LAZY is like the National Bank of India vault clerk who only fetches Priya's transaction history when she specifically asks for it. EAGER is the clerk who fetches the entire transaction file every time Priya's name is mentioned – even if she only wanted her account balance. LazyInitializationException is Priya asking for her transactions after the vault has already closed for the night.

Understanding the Concepts

What is EAGER vs LAZY fetching?

When Hibernate loads a parent entity, the big question is: should it also load the related entities right now, or wait until someone actually asks for them? That decision is the fetch type.

EAGER loading means the related entities are loaded immediately, together with the parent query. Hibernate does this using either a SQL JOIN or a separate subselect fired at the same time. LAZY loading is the opposite – the related entity or collection is loaded only when you first touch it. Hibernate hands you a proxy object, and the real SQL fires only when you call a method on that proxy.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "trades")
public class Trade {
    @Id
    @Column(name = "trade_id")
    private String tradeId;

    private String symbol;
    private BigDecimal amount;

    // EAGER: Account loaded immediately with every Trade query
    // @ManyToOne  // default is EAGER!
    // private Account account;

    // CORRECT: override to LAZY
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "account_id")
    private Account account;
}

@Entity
@Table(name = "accounts")
public class Account {
    @Id
    @Column(name = "account_id")
    private String accountId;

    private String holderName;
    private BigDecimal balance;

    // @OneToMany defaults to LAZY (correct)
    @OneToMany(mappedBy = "account")
    private List<Trade> trades = new ArrayList<>();
}

What are the default fetch types for each association?

The defaults catch almost everyone out. Memorise them:

| Association | Default Fetch | Recommended |
|------------|--------------|-------------|
| @ManyToOne | EAGER | Override to LAZY |
| @OneToOne | EAGER | Override to LAZY |
| @OneToMany | LAZY | Keep as LAZY |
| @ManyToMany | LAZY | Keep as LAZY |

The dangerous one is the @ManyToOne EAGER default. If Rajesh lists 200 trades, every single Trade eagerly drags its Account along, even on a screen that only shows the trade symbol and amount.

JAVA
package com.bank.model;

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

@Entity
@Table(name = "trades")
public class Trade {
    @Id
    private String tradeId;

    // WRONG: default EAGER loads Account with every Trade
    // @ManyToOne
    // private Account account;

    // CORRECT: explicit LAZY
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "account_id")
    private Account account;

    private String symbol;
    private BigDecimal amount;
}

How does lazy loading work internally?

When Hibernate loads an entity with a LAZY association, it does not load the associated entity. Instead, it creates a proxy – a subclass of the entity class generated at runtime using bytecode enhancement. The proxy holds only the entity ID. When you call any method on the proxy (other than getId()), Hibernate intercepts the call, fires the SELECT, populates the real entity, and forwards the method call.

JAVA
package com.bank.service;

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

@Service
public class TradeInspectionService {

    private final TradeRepository tradeRepo;

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

    @Transactional(readOnly = true)
    public void inspectProxy() {
        Trade trade = tradeRepo.findById("TRD-001").orElseThrow();

        // trade.getAccount() returns a PROXY, not the real Account
        System.out.println(trade.getAccount().getClass().getName());
        // Output: com.bank.model.Account$HibernateProxy$abc123

        // This triggers the SELECT on accounts table:
        String name = trade.getAccount().getHolderName();
        // Hibernate fires: SELECT * FROM accounts WHERE account_id = 'SAV2001'
        System.out.println(name);
        // Output: Rajesh Kumar
    }
}

What is LazyInitializationException?

This exception occurs when you access a LAZY association after the Hibernate Session has closed. The proxy wakes up, tries to fire SQL, but there is no open session to run the query.

JAVA
package com.bank.controller;

import com.bank.model.Trade;
import com.bank.repository.TradeRepository;
import org.springframework.web.bind.annotation.*;

@RestController
public class TradeMistakeController {

    private final TradeRepository tradeRepo;

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

    // WRONG: accessing lazy association after session closes
    @GetMapping("/trades/{id}")
    public String getTradeHolder(@PathVariable String id) {
        Trade trade = tradeRepo.findById(id).orElseThrow();
        // Transaction ended when findById returned
        // Session is CLOSED

        return trade.getAccount().getHolderName();
        // org.hibernate.LazyInitializationException:
        // could not initialize proxy - no Session
    }
}

How do you fix LazyInitializationException?

The correct fix is to load what you need while the transaction is still open – using JOIN FETCH, @EntityGraph, or a DTO projection. Never fix it by re-enabling Open Session in View.

JAVA
package com.bank.repository;

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

public interface TradeRepository extends JpaRepository<Trade, String> {

    // FIX 1: JOIN FETCH loads Account inside the transaction
    @Query("SELECT t FROM Trade t JOIN FETCH t.account WHERE t.tradeId = :id")
    Optional<Trade> findWithAccount(@Param("id") String tradeId);
    // One query: SELECT t.*, a.* FROM trades t
    //            JOIN accounts a ON t.account_id = a.account_id
    //            WHERE t.trade_id = ?
}
JAVA
package com.bank.service;

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

@Service
public class TradeService {

    private final TradeRepository tradeRepo;

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

    // FIX 2: Map to DTO inside the transaction
    @Transactional(readOnly = true)
    public TradeDetailDTO getTradeDetail(String tradeId) {
        Trade trade = tradeRepo.findWithAccount(tradeId).orElseThrow();
        // Account already loaded via JOIN FETCH
        return new TradeDetailDTO(
            trade.getTradeId(),
            trade.getSymbol(),
            trade.getAmount(),
            trade.getAccount().getHolderName()
        );
        // DTO has no proxies. Safe to serialize after session closes.
    }
}

What is Hibernate.initialize()?

Hibernate.initialize() forces a lazy association to load while the session is still open. It fires an additional SELECT query. Use it sparingly – calling it in a loop recreates N+1.

JAVA
package com.bank.service;

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

@Service
public class AccountWarmupService {

    private final AccountRepository accountRepo;

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

    @Transactional(readOnly = true)
    public Account getAccountWithTrades(String accountId) {
        Account account = accountRepo.findById(accountId).orElseThrow();
        Hibernate.initialize(account.getTrades());
        // Fires: SELECT * FROM trades WHERE account_id = 'SAV2001'
        // Trades are now loaded. No LazyInitializationException later.
        return account;
    }

    // WRONG: calling initialize in a loop = N+1
    @Transactional(readOnly = true)
    public void wrongWarmup() {
        var accounts = accountRepo.findAll();
        for (Account a : accounts) {
            Hibernate.initialize(a.getTrades());  // N extra queries!
        }
        // Use JOIN FETCH or @BatchSize instead
    }
}

What is getReference() vs findById()?

getReference(id) returns a LAZY proxy without hitting the database. It is perfect when you only need the foreign key, not the full entity. findById(id) hits the database immediately and returns an Optional.

| Feature | getReference() / getReferenceById() | findById() |
|---------|-----------------------------------|------------|
| Database hit | No (proxy only) | Yes (SELECT immediately) |
| Return type | Entity (proxy) | Optional |
| Use case | Setting FK on child entity | Reading entity data |
| Non-existent ID | EntityNotFoundException later | Optional.empty() immediately |

JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.model.Trade;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class TradePlacementService {

    @PersistenceContext
    private EntityManager em;

    @Transactional
    public void placeTrade(String accountId, String symbol, BigDecimal amount) {
        // getReference: NO SELECT on accounts table
        Account ref = em.getReference(Account.class, accountId);
        // ref is a proxy with only the ID

        Trade trade = new Trade();
        trade.setTradeId("TRD-099");
        trade.setSymbol(symbol);
        trade.setAmount(amount);
        trade.setAccount(ref);  // sets FK without loading Account

        em.persist(trade);
        // INSERT INTO trades (trade_id, symbol, amount, account_id)
        // VALUES ('TRD-099', 'RELIANCE', 245050.00, 'SAV2001')
        // Account was NEVER loaded from the database!
    }

    @Transactional(readOnly = true)
    public void readAccount(String accountId) {
        // findById: SELECT fires immediately
        Account account = em.find(Account.class, accountId);
        // SELECT * FROM accounts WHERE account_id = 'SAV2001'
        // Returns the full entity, ready to read
        System.out.println(account.getHolderName());
        // Output: Rajesh Kumar
    }
}

What are common mistakes with fetch types?

The two most common mistakes are leaving @ManyToOne at its EAGER default and fixing LazyInitializationException by enabling Open Session in View.

JAVA
package com.bank.model;

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

@Entity
@Table(name = "trades")
public class Trade {
    @Id
    private String tradeId;

    // WRONG: EAGER default loads Account with every Trade query
    @ManyToOne
    @JoinColumn(name = "account_id")
    private Account account;
    // 200 trades = 200 Account SELECTs (N+1)

    // CORRECT: explicit LAZY
    // @ManyToOne(fetch = FetchType.LAZY)
    // @JoinColumn(name = "account_id")
    // private Account account;

    private String symbol;
    private BigDecimal amount;
}
YAML
# WRONG: enabling OSIV to hide LazyInitializationException
spring:
  jpa:
    open-in-view: true  # keeps session open during view rendering
    # Masks the problem, holds DB connections too long

# CORRECT: disable OSIV and fetch properly
spring:
  jpa:
    open-in-view: false
    # Use JOIN FETCH, @EntityGraph, or DTO projections instead

When should you use EAGER vs LAZY?

The rule is straightforward: always use LAZY. Override @ManyToOne and @OneToOne to LAZY. Fetch what you need explicitly inside the transaction using JOIN FETCH or @EntityGraph.

Use EAGER only in rare cases where the association is always needed and the data is small (for example, a Status enum entity with 5 rows that is loaded with every parent).

JAVA
package com.bank.model;

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

@Entity
@Table(name = "trades")
public class Trade {
    @Id
    private String tradeId;

    // Always LAZY for real associations
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "account_id")
    private Account account;

    // Rare exception: tiny lookup table always needed
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "status_code")
    private TradeStatus status;  // only 5 rows, always displayed

    private String symbol;
    private BigDecimal amount;
}

Visual Overview

DIAGRAM
  LAZY Loading (proxy):
  ┌─────────────────────┐
  │  trade = findById()   │ → SELECT * FROM trades WHERE id = ?
  └──────────┬──────────┘
             │
             ▼
  ┌───────────────────────────────────┐
  │  trade.getAccount()             │
  │  Returns: Account$HibernateProxy │
  │  (holds only ID, no DB hit yet)  │
  └─────────────────┬─────────────────┘
                    │
                    ▼
  ┌───────────────────────────────────┐
  │  account.getHolderName()         │
  │  Proxy intercepts the call       │
  │  NOW fires:                       │
  │  SELECT * FROM accounts            │
  │  WHERE account_id = 'SAV2001'     │
  └───────────────────────────────────┘

  getReference() vs findById():
  ┌─────────────────────────┐
  │  getReference("SAV2001")  │ → Proxy (no DB hit)
  │  trade.setAccount(ref)   │ → INSERT with FK only
  └─────────────────────────┘
  ┌─────────────────────────┐
  │  findById("SAV2001")     │ → SELECT * FROM accounts
  │  Returns Optional<Acc>   │ → Full entity in memory
  └─────────────────────────┘

Key Takeaway

Default fetch types are the most common source of accidental over-fetching. @ManyToOne and @OneToOne default to EAGER, meaning every query for a child entity also loads the parent – cascading into N+1 queries. Always override both to LAZY. @OneToMany and @ManyToMany already default to LAZY, which is correct.

LazyInitializationException occurs when a lazy collection is accessed after the session closes. The fix is fetching what you need inside the @Transactional method using JOIN FETCH or @EntityGraph, then mapping to a DTO before returning to the controller. Never fix it by re-enabling Open Session in View, because that just masks the problem while holding database connections during the entire response cycle.

The getReference() method returns a lazy proxy that only holds the entity ID without hitting the database. It is efficient for setting foreign keys: at National Bank of India, Rajesh uses em.getReference(Account.class, "SAV2001") to set the account on a new trade without loading the full Account entity. findById() hits the database immediately and returns Optional – use it when you need to read the actual data.

Remember one sentence: always default to LAZY, fetch explicitly inside the transaction, and use getReference for FK-only writes.

---

Share
Use ← → arrow keys to navigate