Fetch Strategies and the Queries You Never Asked For
Fetch Strategies and the Queries You Never Asked For
The Lie
Hibernate’s fetch configuration is a declaration of intent. You say FetchType.LAZY and the collection loads lazily. You say FetchType.EAGER and it loads eagerly. Configuration drives behavior.
The Reality
There are two independent axes to fetch configuration, and they conflict in ways that produce surprising SQL.
Axis 1: FetchType (LAZY vs EAGER) controls when the data loads. LAZY means on first access. EAGER means at entity load time.
Axis 2: FetchMode (SELECT, JOIN, SUBSELECT, BATCH) controls how the data loads. SELECT means a separate query. JOIN means a SQL JOIN in the parent query. SUBSELECT means a query with a subquery replaying the parent query.
These axes combine into a matrix, and several combinations produce behavior that contradicts what you would expect.
The decision tree maps access patterns to fetch strategies. The bar chart below it shows actual query counts per strategy for 100 parent entities. Choosing between JOIN FETCH and @BatchSize is not a purity question: JOIN FETCH wins when the association is always accessed, @BatchSize wins when it is sometimes accessed and you cannot predict which items will be needed.
| FetchType | FetchMode | Behavior |
|---|---|---|
| LAZY | SELECT | Standard lazy loading. Separate query on first access. |
| LAZY | BATCH | Lazy, but batches multiple proxy initializations into IN queries. |
| LAZY | SUBSELECT | Lazy, but initializes all proxies from the parent query at once. |
| LAZY | JOIN | Ignored for collections. Hibernate falls back to SELECT. |
| EAGER | SELECT | Loads immediately via a separate SELECT after the parent query. |
| EAGER | JOIN | Loads via SQL JOIN in the parent query (for find-by-id only). |
| EAGER | SUBSELECT | Loads immediately via subquery. |
The critical trap: EAGER + JOIN only works for EntityManager.find() and getReference(). When you use JPQL, HQL, Criteria, or Spring Data derived queries, Hibernate ignores the JOIN fetch mode and falls back to SELECT. This means your “optimized” eager join becomes an N+1 the moment you use any query that is not a primary key lookup.
The Evidence
@Entity
@Table(name = "departments")
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// BAD: EAGER + JOIN, expecting a single query
@OneToMany(mappedBy = "department", fetch = FetchType.EAGER)
@Fetch(FetchMode.JOIN)
private Set<Employee> employees = new HashSet<>();
}
// Using EntityManager.find() - JOIN works as expected
Department dept = entityManager.find(Department.class, 1L);
// Generated SQL:
// select d1_0.id, d1_0.name, e1_0.department_id, e1_0.id, e1_0.name, e1_0.salary
// from departments d1_0
// left join employees e1_0 on d1_0.id = e1_0.department_id
// where d1_0.id = ?
// Using JPQL - JOIN mode is IGNORED, falls back to SELECT
List<Department> depts = entityManager
.createQuery("SELECT d FROM Department d", Department.class)
.getResultList();
// Generated SQL:
// select d1_0.id, d1_0.name from departments d1_0
// select e1_0.department_id, e1_0.id, e1_0.name, e1_0.salary from employees e1_0 where e1_0.department_id = ?
// select e1_0.department_id, e1_0.id, e1_0.name, e1_0.salary from employees e1_0 where e1_0.department_id = ?
// ... N+1 queries
The same entity, two different access patterns, completely different SQL. The annotation said JOIN. Hibernate decided otherwise.
The Fix
Stop using FetchType.EAGER. Full stop.
// BETTER: Always LAZY, explicitly fetch when needed
@Entity
@Table(name = "departments")
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
private Set<Employee> employees = new HashSet<>();
}
// Fetch explicitly in queries that need the association
public interface DepartmentRepository extends JpaRepository<Department, Long> {
// When you need employees
@Query("SELECT d FROM Department d JOIN FETCH d.employees")
List<Department> findAllWithEmployees();
// When you don't
List<Department> findAll();
}
EAGER fetch creates a global policy: this association loads every time the entity loads, regardless of whether the caller needs it. That is never the right granularity. Different use cases need different data. The fetch strategy should be defined at the query level, not the entity level.
The only exception: @ManyToOne and @OneToOne associations where the foreign key is on the owning side and the related entity is almost always needed. Even then, prefer LAZY and handle the occasional LazyInitializationException over the cost of always loading data you sometimes do not need.
The Cost Model
EAGER fetch on a @OneToMany with 50 child records, accessed via JPQL:
- Per query: 1 additional SELECT per parent entity (N+1 pattern)
- At 100 parent entities: 101 queries instead of 1
- At 1,000 parent entities: 1,001 queries
- Network overhead: 50ms per round trip on a 5ms-latency connection means 5 seconds of pure network time for 1,000 parents
EAGER fetch cannot be overridden to LAZY at query time. Once the annotation says EAGER, every JPQL query, every derived query, every Criteria query loads that association. There is no @Query annotation that says “ignore the EAGER on this one.” You can use a projection or a DTO query, but you cannot make an EAGER association lazy for a specific query.
LAZY can always be overridden to eager at query time via JOIN FETCH or EntityGraph. This asymmetry is the entire argument for defaulting to LAZY.
The diagram shows how Hibernate resolves fetch behavior based on the combination of FetchType, FetchMode, and the type of query being executed. The key path to watch is the JPQL/Criteria branch, where JOIN fetch mode is silently downgraded to SELECT. Understanding this decision tree prevents the silent N+1 that EAGER + JOIN creates in production.