Start Learning
Javaneer
Back to stage
Module 3·Data & Persistence

Relationships & the N+1 Problem

Model @OneToMany/@ManyToOne, understand lazy vs eager loading, and slay the notorious N+1 query problem.

18 min readAdvanced
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.

One trip with a list, not a trip per item

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 query

Now 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.

Diagnose and cure

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.
Was this lesson helpful?
Edit this page on GitHub