Home / Spring Boot / Data and Transactions / JPA Relationships and the N+1 Problem

JPA Relationships and the N+1 Problem

Relationships connect entities. Wrong fetch settings are the most common cause of slow Spring Boot applications, so understand lazy loading and the N+1 problem well.

One-to-many and many-to-one

@Entity
public class Customer {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderEntity> orders = new ArrayList<>();

    public void addOrder(OrderEntity order) {          // keep both sides in sync
        orders.add(order);
        order.setCustomer(this);
    }
    public void removeOrder(OrderEntity order) {
        orders.remove(order);
        order.setCustomer(null);
    }
}

@Entity
@Table(name = "orders")
public class OrderEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String item;

    @ManyToOne(fetch = FetchType.LAZY)                 // always set LAZY explicitly
    @JoinColumn(name = "customer_id")                  // the foreign key column (owning side)
    private Customer customer;

    public void setCustomer(Customer customer) { this.customer = customer; }
}

Many-to-many

@Entity
public class Student {
    @Id @GeneratedValue private Long id;

    @ManyToMany
    @JoinTable(name = "student_course",
               joinColumns = @JoinColumn(name = "student_id"),
               inverseJoinColumns = @JoinColumn(name = "course_id"))
    private Set<Course> courses = new HashSet<>();
}
@Entity
public class Course {
    @Id @GeneratedValue private Long id;
    @ManyToMany(mappedBy = "courses")
    private Set<Student> students = new HashSet<>();
}
Cascade typeEffect
PERSISTSaving the parent saves the children
MERGEMerging the parent merges the children
REMOVEDeleting the parent deletes the children
ALLAll of the above
orphanRemoval=trueDeletes a child removed from the parent collection
Fetch typeDefault forMeaning
LAZY@OneToMany, @ManyToManyLoaded only when you access it
EAGER@ManyToOne, @OneToOneLoaded together with the owner

Recommendation: make every association LAZY, and load data explicitly (join fetch or entity graph) when a use case needs it.

The N+1 problem

You load 1 list of 100 customers (1 query). Then for each customer you touch customer.getOrders(), and Hibernate runs one more query per customer (100 queries). Total: 1 + 100 = 101 queries.

select * from customer;                       ← 1 query
select * from orders where customer_id = 1;    ← N queries, one for each customer
select * from orders where customer_id = 2;
select * from orders where customer_id = 3;
...                                            enable spring.jpa.show-sql=true to see them

Fixes

public interface CustomerRepository extends JpaRepository<Customer, Long> {

    // 1. JOIN FETCH: one query with a join
    @Query("select distinct c from Customer c join fetch c.orders")
    List<Customer> findAllWithOrders();

    // 2. Entity graph: same effect, no JPQL
    @EntityGraph(attributePaths = "orders")
    List<Customer> findByNameStartingWith(String prefix);
}
# 3. Batch fetching: load lazy collections in groups (1 query for 50 customers)
spring.jpa.properties.hibernate.default_batch_fetch_size=50
# 4. Do not keep the session open during the view rendering
spring.jpa.open-in-view=false

LazyInitializationException

It happens when you touch a lazy field after the transaction (session) is closed, typically in the controller. Fix it by loading the data inside the service method (join fetch, entity graph) and returning a DTO. Do not fix it by making everything EAGER.

Bidirectional relationship: the rules

equals and hashCode for entities

@Entity
public class OrderLine {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) private Long id;
    private String sku;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof OrderLine other)) return false;             // instanceof works with Hibernate proxies (getClass would not)
        return id != null && id.equals(other.id);                      // two NEW objects (id null) are never equal
    }

    @Override
    public int hashCode() { return getClass().hashCode(); }            // constant: stable before and after persist, safe in HashSets
}

One-to-one with a shared primary key (@MapsId)

@Entity
public class UserProfile {
    @Id private Long id;                                // same value as the user id, no separate id generation

    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    @JoinColumn(name = "id")
    private AppUser user;

    private String bio;
}
// table user_profile(id primary key references app_users(id)). Loading profile by user id is one primary key lookup.

Lazy @OneToOne on the inverse side (mappedBy) cannot really be lazy in Hibernate because it must check whether the row exists. Put the foreign key or @MapsId on the side you load most, or use a projection.

Many-to-many with extra columns: an association entity

@Embeddable
public class EnrollmentId implements Serializable {
    private Long studentId;
    private Long courseId;
    protected EnrollmentId() { }
    public EnrollmentId(Long studentId, Long courseId) { this.studentId = studentId; this.courseId = courseId; }
    @Override public boolean equals(Object o) { return o instanceof EnrollmentId e && Objects.equals(studentId, e.studentId) && Objects.equals(courseId, e.courseId); }
    @Override public int hashCode() { return Objects.hash(studentId, courseId); }
}

@Entity
public class Enrollment {
    @EmbeddedId private EnrollmentId id;

    @ManyToOne(fetch = FetchType.LAZY) @MapsId("studentId") private Student student;
    @ManyToOne(fetch = FetchType.LAZY) @MapsId("courseId") private Course course;

    private LocalDate enrolledOn;          // extra data on the relationship
    private Integer grade;
}
// Student has @OneToMany(mappedBy = "student") Set<Enrollment>. A plain @ManyToMany cannot hold enrolledOn or grade.

Ordering and collection mapping

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position ASC")                       // SQL order by when loading
private List<OrderLine> lines = new ArrayList<>();

@ElementCollection
@CollectionTable(name = "customer_tags", joinColumns = @JoinColumn(name = "customer_id"))
@Column(name = "tag")
private Set<String> tags = new HashSet<>();     // simple values, no entity needed

Cascade and delete behavior: what really happens

ActionResult
repository.delete(customer) with CascadeType.REMOVE on ordersHibernate loads the orders, deletes each one, then the customer (many statements)
Bulk JPQL delete: delete from Order o where o.customer.id = :idOne statement. Bypasses cascades, lifecycle callbacks and the persistence context
Database ON DELETE CASCADE with @OnDelete(action = CASCADE)One statement and the database removes the children. Fastest for large children sets
orphanRemoval=true and list.remove(child)The removed child row is deleted at flush

Performance rules for relations

RuleReason
ToOne associations LAZYDefault EAGER loads owners with every query and creates N+1
Fetch collections in the use case (join fetch or entity graph)Load exactly what the screen or API needs
Never join fetch two collections at onceCartesian product. Use Set, or two queries, or batch fetch size
Return DTO projections for read-only listsNo entity tracking, one flat query
Count with a count query, not size() on a lazy collectionsize() loads the whole collection unless @LazyCollection(EXTRA)

Common mistake: Making associations EAGER to avoid LazyInitializationException. This loads far more data than needed and creates N+1 problems everywhere. Keep LAZY and fetch what each use case needs.

Interview questions

What does mappedBy mean?

It marks the inverse (non-owning) side of the relationship. The other side, with @JoinColumn, owns the foreign key.

Why should I avoid returning entities from REST controllers?

Lazy fields can throw exceptions, bidirectional relations cause infinite JSON recursion, and you expose internal structure. Use DTOs.

What is open-in-view?

A Spring Boot default that keeps the JPA session open until the response is written. It hides lazy loading problems and holds connections longer, so many teams switch it off.

Why is my child row not saved when I add it to the parent's list?

Cascade PERSIST or ALL is missing, or you changed only the inverse side and the owning side (the @ManyToOne) was not set.

Should entities have equals and hashCode based on all fields?

No. Fields change and lazy fields trigger loading. Use the id (with a constant hashCode) or a stable business key.