Everything worked fine in development, but once the data volume grows, response times degrade sharply. The logs show a flood of SQL queries being fired. This is the classic N+1 problem.

This article covers how to detect the N+1 problem and how to solve it using @EntityGraph and JOIN FETCH.

What Is the N+1 Problem

The N+1 problem is a performance issue that occurs when fetching related entities.

Consider a one-to-many relationship between an Article and its Comments.

@Entity
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String title;
    
    @OneToMany(mappedBy = "article")
    private List<Comment> comments = new ArrayList<>();
}

@Entity
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String content;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "article_id")
    private Article article;
}

If you try to display a list of articles along with their comment counts using the following code:

List<Article> articles = articleRepository.findAll();
for (Article article : articles) {
    System.out.println(article.getTitle() + ": " + article.getComments().size());
}

The queries actually issued look like this:

-- 1st query: fetch article list
SELECT * FROM article;

-- Subsequent queries: fetch comments for each article (repeated once per article)
SELECT * FROM comment WHERE article_id = 1;
SELECT * FROM comment WHERE article_id = 2;
SELECT * FROM comment WHERE article_id = 3;
...

With 100 articles, that’s 1 + 100 = 101 queries. This is the N+1 problem.

How to Detect the N+1 Problem

To check whether your application is suffering from the N+1 problem, start by enabling query logging.

Add the following to application.properties:

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

spring.jpa.show-sql prints SQL to standard output. In production, using logging.level.org.hibernate.SQL is preferable since it routes output through the logging framework, making it easier to manage.

If you see the same query pattern repeating, there’s a good chance you have an N+1 problem.

For more detailed analysis, you can also enable Hibernate statistics:

spring.jpa.properties.hibernate.generate_statistics=true

Solving It with @EntityGraph

@EntityGraph lets you fetch related entities in a single query.

Simply annotate your Repository interface methods with @EntityGraph:

public interface ArticleRepository extends JpaRepository<Article, Long> {
    
    @EntityGraph(attributePaths = "comments")
    List<Article> findAll();
    
    @EntityGraph(attributePaths = "comments")
    Optional<Article> findById(Long id);
}

Specify the field name of the related entity in attributePaths. This fetches articles and comments together in one query using a LEFT JOIN.

To fetch deeply nested related entities, use @NamedEntityGraph:

@Entity
@NamedEntityGraph(
    name = "Article.withCommentsAndAuthor",
    attributeNodes = {
        @NamedAttributeNode("comments"),
        @NamedAttributeNode(value = "author", subgraph = "author-detail")
    },
    subgraphs = {
        @NamedSubgraph(
            name = "author-detail",
            attributeNodes = @NamedAttributeNode("profile")
        )
    }
)
public class Article {
    @ManyToOne(fetch = FetchType.LAZY)
    private User author;
    // ...
}

Then reference it by name in the Repository:

@EntityGraph("Article.withCommentsAndAuthor")
List<Article> findAll();

Solving It with JOIN FETCH in JPQL

When you need a custom query, use JOIN FETCH in JPQL. This is an extension of query methods that allows more flexible filtering conditions.

public interface ArticleRepository extends JpaRepository<Article, Long> {
    
    @Query("SELECT a FROM Article a LEFT JOIN FETCH a.comments WHERE a.published = true")
    List<Article> findPublishedArticlesWithComments();
}

Using JOIN FETCH fetches the related entities at the same time. To fetch multiple associations:

@Query("SELECT a FROM Article a " +
       "LEFT JOIN FETCH a.comments " +
       "LEFT JOIN FETCH a.author " +
       "WHERE a.createdAt >= :since")
List<Article> findRecentArticlesWithDetails(@Param("since") LocalDateTime since);

LEFT JOIN FETCH retrieves the parent entity even when no related entities exist. When a relationship is always guaranteed to exist, using INNER JOIN FETCH eliminates the need for NULL checks and can improve performance.

One caveat: fetching multiple collection associations simultaneously with JOIN FETCH can cause a MultipleBagFetchException. In that case, change List to Set, or split the queries and run them separately.

When to Use @EntityGraph vs. JOIN FETCH

It can be hard to decide which to use. Here is a general guideline:

Use @EntityGraph when:

  • Fetching associations without any filtering conditions
  • Applying eager loading to an existing query method
  • Reusing the same fetch strategy in multiple places

Use JOIN FETCH when:

  • You need to filter with a WHERE clause
  • Complex join conditions are required
  • You need to apply conditions to the related entities themselves

Combining both is unpredictable and not recommended — pick one or the other.

Choosing Between FetchType.LAZY and FetchType.EAGER

The guiding principle is: default everything to LAZY, and fetch explicitly only where needed.

With EAGER, every time you load an entity, its associations are automatically loaded as well. This fetches data you may not need, and it makes N+1 problems harder to spot.

Set LAZY explicitly:

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private User author;

If you change existing EAGER mappings to LAZY, you may encounter LazyInitializationException. In those cases, use @EntityGraph or JOIN FETCH to fetch explicitly.

Query Optimization with Batch Fetching

When @EntityGraph or JOIN FETCH cannot be used, batch fetching is a solid alternative. It reduces N+1 queries to N/batch_size+1 queries.

Add the following to application.properties:

spring.jpa.properties.hibernate.default_batch_fetch_size=10

This causes related entities to be fetched in batches of 10:

-- Without batch fetching: 100 queries
SELECT * FROM comment WHERE article_id = 1;
SELECT * FROM comment WHERE article_id = 2;
...

-- With batch fetching: 10 queries
SELECT * FROM comment WHERE article_id IN (1, 2, 3, ..., 10);
SELECT * FROM comment WHERE article_id IN (11, 12, 13, ..., 20);
...

A recommended batch size is 10–50. Setting it too large makes the IN clause excessively long and can backfire.

To configure batch size per entity, add @BatchSize to the @OneToMany side:

@Entity
public class Article {
    @OneToMany(mappedBy = "article")
    @BatchSize(size = 20)
    private List<Comment> comments = new ArrayList<>();
}

N+1 Prevention with Pagination

The N+1 problem also appears in paginated queries. When using Pageable for pagination, you still need to address it.

@EntityGraph and Pageable can be used together:

public interface ArticleRepository extends JpaRepository<Article, Long> {
    
    @EntityGraph(attributePaths = "comments")
    Page<Article> findAll(Pageable pageable);
}

When fetching collections with @EntityGraph or JOIN FETCH, the JOIN produces duplicate rows, which can make counts inaccurate. Separate the count query using @Query and use DISTINCT:

@Query(value = "SELECT DISTINCT a FROM Article a LEFT JOIN FETCH a.comments",
       countQuery = "SELECT COUNT(DISTINCT a) FROM Article a")
Page<Article> findAllWithComments(Pageable pageable);

A Real-World Performance Improvement

Let’s walk through an actual example of resolving the N+1 problem.

Before:

// Fetch 100 articles and display comment count
List<Article> articles = articleRepository.findAll();
for (Article article : articles) {
    int commentCount = article.getComments().size();
    System.out.println(article.getTitle() + ": " + commentCount);
}

Query log:

-- 1st query
SELECT * FROM article; -- fetches 100 rows

-- Queries 2–101
SELECT * FROM comment WHERE article_id = 1;
SELECT * FROM comment WHERE article_id = 2;
... (repeated 100 times)

Test conditions: 100 articles, average 5 comments per article, local development environment (PostgreSQL)
Execution time: ~2.5 seconds

After:

// Add @EntityGraph to the Repository
@EntityGraph(attributePaths = "comments")
List<Article> findAll();

// Same application code
List<Article> articles = articleRepository.findAll();
for (Article article : articles) {
    int commentCount = article.getComments().size();
    System.out.println(article.getTitle() + ": " + commentCount);
}

Query log:

-- Only 1 query (articles and comments fetched together via LEFT JOIN)
SELECT a.*, c.*
FROM article a
LEFT JOIN comment c ON a.id = c.article_id;

Execution time: ~0.08 seconds

The number of queries dropped from 101 to 1, and execution time improved by more than 30x.

Summary

This article covered how to solve the N+1 problem in Spring Data JPA.

The recommended approach is:

  1. Enable query logging to detect the problem
  2. Use @EntityGraph or JOIN FETCH to fetch associations in one shot
  3. Default all associations to LAZY and fetch only where needed
  4. Apply batch fetching for further optimization

Make it a habit to review your query logs before deploying to production — it goes a long way toward preventing performance issues before they happen.