You fetch a list of members once, then look at the SQL log and find 101 SELECT lines. That’s the N+1 problem. One initial query to fetch the entities, plus N more, one each time you touch the association, for a total of N+1. The signal here isn’t an estimated millisecond count; it’s the number of queries in the log.
How one becomes 101
Say Member references Team with lazy loading (FetchType.LAZY). You fetch 100 members and print each one’s team name in a loop.
List<Member> members = memberRepository.findAll(); // 1 query
for (Member member : members) {
System.out.println(member.getTeam().getName()); // 1 query per team here
}
The first line fetches all members in one go.
select m.id, m.name, m.team_id from member m;
The loop is the problem. member.getTeam() is lazy, so the moment you touch the team name, it hits the database. Once per member, an identical-shaped SELECT goes out.
select t.id, t.name from team t where t.id = 1;
select t.id, t.name from team t where t.id = 2;
select t.id, t.name from team t where t.id = 3;
-- ... repeated for every member
select t.id, t.name from team t where t.id = 100;
One member query + 100 team queries = 101. It grows in proportion to your data. The same mechanism fires in 1:N (team→members), N:M (student↔course), and multi-level associations (department→team→member), where multi-level cases multiply out. You confirm it by counting SELECT lines in the log.
Why it’s dangerous
Even if each query is fast, the count is the cost. Each SELECT grabs a connection and adds a network round-trip (RTT). With many concurrent users, the connection pool drains quickly, and other requests wait or time out. N+1 isn’t a slow-query problem; it’s a too-many-queries problem, so every fix points the same direction: reduce the number of queries.
Trying to prevent it with eager loading just loads associations always, which tends to create N+1 somewhere else. Instead of global EAGER, the rule is to fix it selectively, only in the queries that need it.
Fetch Join
The most direct fix. JPQL’s JOIN FETCH eagerly loads the association via an SQL join. A plain JOIN only uses the association in the condition without loading it, but JOIN FETCH fills it into the persistence context so no extra queries are needed.
@Query("SELECT m FROM Member m JOIN FETCH m.team")
List<Member> findAllWithTeam();
101 collapses to 1.
select m.id, m.name, t.id, t.name
from member m
inner join team t on t.id = m.team_id;
There are constraints. Fetch-joining a collection can duplicate rows via a Cartesian product, so you need DISTINCT. Combined with pagination it paginates in memory rather than in the database (with the HHH000104 warning), which is risky. Fetch-joining more than one collection at once throws MultipleBagFetchException. So you typically fetch-join only one collection and hand the rest to the next approach.
@BatchSize
@BatchSize collects IDs and fetches the association in one IN query instead of one per row. With 100 teams and a batch size of 100, the team fetch becomes 1 query instead of 100.
@Entity
public class Member {
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 100)
private Team team;
}
The log changes to this: 1 member query + 1 team query (with IN) = 2.
select m.id, m.name, m.team_id from member m;
select t.id, t.name from team t where t.id in (1, 2, 3, ..., 100);
To apply it globally rather than per field, set hibernate.default_batch_fetch_size. Batch sizes of 50–100 are common; weigh them against the IN clause length limit and memory. It’s also safe with pagination, which makes it the practical default for collection N+1.
@EntityGraph
@EntityGraph declares which associations to load together in a given query. Under the hood it emits a join query much like Fetch Join.
@EntityGraph(attributePaths = {"team"})
@Query("SELECT m FROM Member m")
List<Member> findAllWithTeam();
Only the attributes named in attributePaths are pulled in as EAGER. There are two types. FETCH forces only the named attributes to EAGER and the rest to LAZY. LOAD makes the named attributes EAGER and leaves the rest following the entity’s declared default strategy.
DTO projection
If the values your screen needs are fixed, there’s no reason to load the entity graph. Use JPQL new to SELECT only the columns you need into a DTO, and the association-loading problem never arises.
@Query("SELECT new com.example.dto.MemberDto(m.id, m.name, t.name) " +
"FROM Member m JOIN m.team t")
List<MemberDto> findAllMemberDto();
One join and you’re done, with no wasted columns or memory. It suits read-only APIs.
QueryDSL can apply a fetch join via fetchJoin() in dynamic queries, and for complex joins you write SQL directly with a Native Query. For read-only methods, adding @Transactional(readOnly = true) skips the change-detection snapshot, cutting memory and potentially enabling read optimizations.
Make it visible first
N+1 is hard to find by reading code, because lazy loading works silently. The first step is turning on the log.
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true
With this on, if invoking one feature prints a run of identical SELECTs, that’s N+1. For more precision, turn on statistics.
spring.jpa.properties.hibernate.generate_statistics=true
Hibernate Statistics reports the query count directly, so you can confirm with a number whether your fix dropped it from 101 to 2. In production, use p6spy to see the real SQL with bound parameters and execution time, or an APM/DB profiler to catch where the same query repeats.