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
EntityManagerwork for runtime-varying fetch patterns - Overusing Entity Graphs on large
@OneToManycollections 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@Whereannotation — apply it to the child entity when invalid rows are always noise- When you need invalid rows sometimes, skip
@SQLRestrictionand use a JPQL query with an explicitWHEREclause instead