Skip to main content

How to Fix N+1 in Spring Boot JPA (And When Entity Graphs Make Things Worse)

5 min readBy Hamza

Learn how to fix the N+1 query problem in Spring Boot JPA using @EntityGraph, and why overusing eager fetching caused a cartesian product explosion with versioned entities.

spring bootjpan+1entity graphhibernateperformanceoptimization

Need help optimizing application? Contact me for a performance audit of your application.


TLDR: The N+1 problem turns 1 query into 100+ because JPA lazy-loads related entities one-by-one in a loop. I fixed ours with @EntityGraph, then broke it again by overusing it — a join on a versioned @OneToMany with hundreds of versions per entry caused a cartesian product that caused our whole application to crash due to memory issues but the problem was not the memory.


Why is my ORM making so many database calls?


N+1 happens when you fetch a list of parent entities and then access a lazy-loaded child collection for each one. JPA defers the child query until you touch the property, so each iteration triggers a separate SELECT.


I hit this while building my job-tracking app where listing applications also needed their assets. The naive approach looked like this:


@Entity
public class Application {
    @Id private Long id;
    private String companyName;
    private String jobTitle;

    @OneToMany(mappedBy = "application", fetch = FetchType.LAZY)
    private List<Asset> assets;
}

// In the service:
List<Application> apps = applicationRepository.findAll();
// The loop below triggers N+1 queries
for (Application app : apps) {
    List<Asset> assets = app.getAssets(); // 1 query PER application
}

Fetch 100 applications, get 101 queries. Same pattern I'd seen before in a Reddit scraper where each post in a loop fetched its comments individually — but in JPA, Hibernate hides these calls so you don't notice until your response times hit 5 seconds.


Hibernate's solution: @EntityGraph. It tells JPA to eagerly fetch the child collection in a JOIN, collapsing everything into a single query.


How to fix N+1 with @EntityGraph


Add @EntityGraph on a Spring Data JPA repository method:


@Repository
public interface ApplicationRepository extends JpaRepository<Application, Long> {

    // @Query prevents Spring Data from trying to derive a query
    // from the method name (which would fail parsing "WithAssets")
    @EntityGraph(attributePaths = {"assets"})
    @Query("SELECT a FROM Application a")
    List<Application> findAllWithAssets();
}

The generated SQL changes from:


-- Before (N+1)
select * from applications;                    -- 1 query
select * from assets where application_id=?;   -- N queries

-- After (single join)
select a.*, asst.* from applications a
left join assets asst on a.id = asst.application_id;  -- 1 query

I used this pattern for the application listing endpoint. Assets loaded in a single round-trip. Response time dropped from 3s to 200ms for 100 applications.


You can also build EntityGraphs dynamically via EntityManager when the query pattern changes at runtime:


EntityGraph<Application> graph = entityManager.createEntityGraph(Application.class);
graph.addAttributeNodes("assets");

List<Application> apps = entityManager
    .createQuery("select a from Application a", Application.class)
    .setHint("jakarta.persistence.fetchgraph", graph) // pass the graph directly
    .getResultList();

When Entity Graphs make things worse


I learned this the hard way. We had a content entry system with versioning:


@Entity
public class Entry {
    @OneToMany(mappedBy = "entry")
    private List<Version> versions;
}

@Entity
public class Version {
    private boolean valid;
    // ...
}

I added @EntityGraph(attributePaths = {"versions"}) thinking "one query is always better." But each entry had 100+ versions, and the LEFT JOIN multiplied every row. A list of 50 entries with 200 versions each returned 10,000 rows — all so I could filter to the ones where valid = true.


The fix: filter at the SQL level with @SQLRestriction (Hibernate 6, Spring Boot 3.x). It appends a WHERE clause to every query that loads the entity — including the JOIN fired by EntityGraph — so the unwanted rows never enter the result set:


import org.hibernate.annotations.SQLRestriction;

@Entity
@SQLRestriction("valid = true")
public class Version {
    @ManyToOne
    private Entry entry;
    private boolean valid;
}

Now the same @EntityGraph query joins only valid versions instead of all 200. The cartesian product collapses, and the EntityGraph stays clean — no separate JPQL query needed. @SQLRestriction replaced the deprecated @Where annotation, so if you're on Hibernate 6+ use this instead.


One caveat: the restriction applies to every load of Version, which is exactly what you want when invalid rows are always noise. If you sometimes need the invalid ones, reach for a JPQL query with an explicit WHERE clause instead.



Another fix you can use: @NamedEntityGraph with @EntityGraph(type = EntityGraphType.FETCH) on the entity itself, then reference it from the repository. Keeps the graph definition close to the entity and reusable across queries.


Running into slow JPA queries? Get in touch — I help teams optimize Spring Boot data access patterns for production.


Key Takeaways


  • N+1 happens when lazy-loading child entities in iteration — 1 parent query + N child queries
  • @EntityGraph(attributePaths = {"childCollection"}) on a repository method merges everything into a JOIN query
  • Dynamic EntityGraphs via EntityManager work for runtime-varying fetch patterns
  • Overusing Entity Graphs on large @OneToMany collections causes cartesian product explosions — rows multiply by every joined child row
  • Versioned entities with hundreds of entries per parent are the most common trap: use @SQLRestriction (Hibernate 6+) to filter at the SQL level so only relevant rows are joined
  • @SQLRestriction("valid = true") replaced the deprecated @Where annotation — apply it to the child entity when invalid rows are always noise
  • When you need invalid rows sometimes, skip @SQLRestriction and use a JPQL query with an explicit WHERE clause instead