Relationships & the N+1 Problem
Model @OneToMany/@ManyToOne, understand lazy vs eager loading, and slay the notorious N+1 query problem.
On this page
Real data is connected. A Member has many Loans; each Loan belongs to one Book.
JPA maps these relationships between entities - and hides one of the most common
performance traps in all of Spring: the N+1 problem.
Mapping relationships
@Entity
class Loan {
@Id @GeneratedValue Long id;
@ManyToOne // many loans -> one book
Book book;
@ManyToOne // many loans -> one member
Member member;
LocalDate borrowedAt;
LocalDate dueAt;
}
@Entity
class Member {
@Id @GeneratedValue Long id;
String displayName;
@OneToMany(mappedBy = "member") // one member -> many loans
List<Loan> loans;
}@ManyToOne and @OneToMany describe the two sides; mappedBy says which side owns
the foreign key (the Loan.member column).
Lazy vs eager loading
To-many relationships (@OneToMany) are lazy by default: the loans list isn't
loaded until you actually access it. That's usually what you want - you don't pay to
load loans every time you fetch a member. But laziness is exactly what sets the trap.
The N+1 problem
Load 100 members, then loop and read each member's loans:
List<Member> members = memberRepository.findAll(); // 1 query
for (Member m : members) {
m.getLoans().size(); // + 1 query PER member!
}That's 1 query for the members, then N more (one per member) to lazily fetch each one's loans: N+1 queries where a single join would do. With 100 members that's 101 database round-trips - a silent killer that looks fine with test data and melts in production.
Cooking dinner, you don't drive to the store, buy an onion, drive home, then drive back
for garlic, and again for tomatoes - that's N+1 trips. You take one trip with a
full shopping list. JOIN FETCH is that single trip: tell the database up front you
want members and their loans, and it brings everything back in one query.
The fix: fetch it in one query
Ask for the relationship up front with a JOIN FETCH (or an entity graph):
@Query("SELECT m FROM Member m JOIN FETCH m.loans")
List<Member> findAllWithLoans(); // members AND their loans — ONE queryNow it's 1 query total. Keep relationships lazy by default, and explicitly fetch what a given use case needs.
N+1 hides until it hurts
With ten rows in dev, N+1 is invisible. With ten thousand in production, it's an outage.
Watch your SQL logs (spring.jpa.show-sql=true) during development - a burst of
near-identical queries is the tell-tale sign.
An endpoint lists all members with the number of books each has borrowed. In production
it's painfully slow, and the SQL log shows one query for members followed by hundreds of
identical SELECT * FROM loan WHERE member_id = ? queries. Name the problem and give
the one-line change that fixes it.
What causes the N+1 query problem?
Key takeaways
- JPA maps relationships with @ManyToOne and @OneToMany; mappedBy marks the side that owns the foreign key.
- @OneToMany collections are lazy by default - not loaded until accessed - which is usually good but enables N+1.
- The N+1 problem: 1 query for N parents, then N more to lazily fetch each parent's relation - a silent performance killer.
- Fix it by loading the relation up front with JOIN FETCH or an @EntityGraph, turning N+1 queries into one.
- Watch SQL logs in development; a burst of near-identical queries signals N+1.