How to Fix the Hibernate N+1 Problem in Spring Boot Using JOIN FETCH

How to Fix the Hibernate N+1 Problem in Spring Boot Using JOIN FETCH

Is your Spring Boot API running blazingly fast on your local development machine, but crawling to a halt in production? If you are using Spring Data JPA and Hibernate, the culprit is almost always the silent database killer: The N+1 Query Problem.

In enterprise applications, data relationships can become deeply nested. When you retrieve a list of parent entities and subsequently loop through them to access their child entities, Hibernate's default behavior might trigger a new SQL query for every single child relationship. What should be a single efficient query turns into dozens, hundreds, or even thousands of database round-trips.

📺 Watch the Live Database Debugging!
If you want to see exactly how this explosion of queries looks in the console and how to fix it step-by-step, watch the full YouTube tutorial.

▶ Watch the Hibernate N+1 Video Tutorial

1. Understanding the Root Cause (FetchType.LAZY)

Before we dive into the solution, we have to build the environment that causes the problem. Let's look at a standard e-commerce domain model consisting of Order, OrderItem, and Product.

Best practices dictate that we should use FetchType.LAZY for our database relationships to prevent loading massive amounts of unnecessary data. However, this exact best practice is what sets the trap for the N+1 problem. When an entity is loaded lazily, Hibernate replaces it with a proxy. The actual database hit does not occur until you call a method on that proxy (like .getItems()).

package com.springlavalab.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

import java.time.LocalDateTime;
import java.util.List;

@Entity
@Table(name = "orders")
@Getter
@Setter
public class Order {
    @Id
    @GeneratedValue
    private Long id;
    
    private Long userId;
    private Double totalAmount;
    private LocalDateTime createdAt;

    // The Trap is set here: LAZY fetch type
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items;
}

To demonstrate this fully, we will explicitly make the relationships in OrderItem lazy as well. Remember, in JPA, @ManyToOne is eagerly fetched by default unless you explicitly change it.

package com.springlavalab.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

@Entity
@Table(name = "order_items")
@Getter
@Setter
public class OrderItem {
    @Id
    @GeneratedValue
    private Long id;
    
    private Integer quantity;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;
}

2. Reproducing the N+1 Problem (V1 Approach)

To expose the data safely via a REST API, we utilize a Data Transfer Object (DTO). This prevents infinite recursion issues and hides internal database structures from the client.

package com.springlavalab.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;

@Data
@AllArgsConstructor
public class OrderResponse {
    private Long orderId;
    private Double amount;
    private List<String> productNames;
}

Now, let's look at the standard Spring Data JPA repository method most developers write first: a derived query method.

package com.springlavalab.repository;

import com.springlavalab.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface OrderRepository extends JpaRepository<Order, Long> {
    // Standard derived query - This will cause the N+1 problem!
    List<Order> findByUserIdOrderByCreatedAtDesc(Long userId);
}

The issue surfaces in the Service layer. When we fetch the orders, Hibernate executes exactly one query. But when we map over the stream and call order.getItems() and then item.getProduct(), Hibernate is forced to go back to the database for every single iteration.

package com.springlavalab.service;

import com.springlavalab.dto.OrderResponse;
import com.springlavalab.entity.Order;
import com.springlavalab.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public List<OrderResponse> getOrdersV1(Long userId) {
        long start = System.currentTimeMillis();

        // 1 Query executed here
        List<Order> orders = orderRepository.findByUserIdOrderByCreatedAtDesc(userId);

        // N + M Queries executed inside this mapping function!
        List<OrderResponse> response = mapOrders(orders);

        long end = System.currentTimeMillis();
        System.out.println("V1 Time taken: " + (end - start) + " ms");

        return response;
    }

    private static List<OrderResponse> mapOrders(List<Order> orders) {
        return orders.stream().map(order -> {
            List<String> products = order.getItems().stream()
                    .map(item -> item.getProduct().getName())
                    .toList();

            return new OrderResponse(
                    order.getId(),
                    order.getTotalAmount(),
                    products
            );
        }).toList();
    }
}

If you configure spring.jpa.show-sql=true in your application.properties, you will see a massive explosion of SELECT statements in your console. One API call should not generate 30+ database queries!


3. The Solution: Using JOIN FETCH (V2 Approach)

To resolve this, we must instruct Hibernate to retrieve the entire entity graph in a single query. We achieve this using JPQL and the JOIN FETCH keyword.

Unlike a standard JOIN (which is used for filtering data), a JOIN FETCH actually populates the lazy-loaded collections immediately. We also use the DISTINCT keyword to ensure that the multiple rows generated by the SQL join are collapsed back into unique Order objects in memory.

// Add this to your OrderRepository

@Query("""
    SELECT DISTINCT o FROM Order o
    JOIN FETCH o.items i
    JOIN FETCH i.product
    WHERE o.userId = :userId
    ORDER BY o.createdAt DESC
""")
List<Order> findAllWithItemsAndProducts(@Param("userId") Long userId);

Now, we can create our V2 service method. Notice that we reuse the exact same mapOrders method. The Java logic does not need to change! Because the data is already initialized by our custom query, Hibernate will not execute any additional SQL inside the mapping stream.

// Add this to your OrderService

public List<OrderResponse> getOrdersV2(Long userId) {
    long start = System.currentTimeMillis();

    // 1 single query retrieves everything!
    List<Order> orders = orderRepository.findAllWithItemsAndProducts(userId);

    // No extra queries executed here.
    List<OrderResponse> response = mapOrders(orders);

    long end = System.currentTimeMillis();
    System.out.println("V2 Time taken: " + (end - start) + " ms");

    return response;
}

4. Project Configuration & Controllers

To tie it all together, expose the endpoints in your REST controller and configure your database connection.

package com.springlavalab.controller;

import com.springlavalab.dto.OrderResponse;
import com.springlavalab.service.OrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @GetMapping("/v1")
    public List<OrderResponse> getOrdersV1(@RequestParam Long userId) {
        return orderService.getOrdersV1(userId);
    }

    @GetMapping("/v2")
    public List<OrderResponse> getOrdersV2(@RequestParam Long userId) {
        return orderService.getOrdersV2(userId);
    }
}
Production Tip: When NOT to use JOIN FETCH
While JOIN FETCH is powerful, be careful when using it alongside pagination (Pageable). Hibernate cannot apply SQL-level limits (`LIMIT`/`OFFSET`) when fetching multiple collections, resulting in the dreaded "in-memory pagination" warning. For paginated queries with child collections, look into using @EntityGraph or batch fetching via spring.jpa.properties.hibernate.default_batch_fetch_size.

Conclusion

By simply writing a custom JPQL query with JOIN FETCH, we optimized our API endpoint to execute one highly efficient SQL query instead of flooding the database. Monitoring your application logs with SQL execution tracking enabled during local development is the best way to catch these N+1 issues before they reach production.

🚀 Explore More Spring Boot Performance Tuning

Now that you understand the N+1 problem, take your application's speed and scalability to the next level with these guides.

⚡ Spring Boot Database Performance Tuning

Master advanced techniques for optimizing your Spring Data JPA and database connection layers for high scalability.

🚀 Complete Application Optimization

A comprehensive guide to tuning your entire Spring Boot application architecture to handle maximum throughput.

📦 Redis Cache Performance Optimization

Learn how to offload your database completely by implementing a high-performance Redis caching layer.