Home Java Hibernate N+1 Problem: How 101 SQL Queries Made a Page Load in 12 Seconds
Advanced 3 min · July 14, 2026
Hibernate N+1 Problem and How to Fix It

Hibernate N+1 Problem: How 101 SQL Queries Made a Page Load in 12 Seconds

Learn what the Hibernate N+1 query problem is, how to detect it in Spring Boot apps, and fix it with JOIN FETCH, EntityGraphs, and batch fetching.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.

Follow
Production
production tested
July 18, 2026
last updated
2,466
articles · all by Naren
Before you start⏱ 15-20 min read
  • Java 17+
  • Spring Boot 3.2+
  • Spring Data JPA (Hibernate 6.4+)
  • MySQL 8+ or PostgreSQL 15+
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

• The N+1 problem occurs when Hibernate executes 1 query to fetch parent entities and then N separate queries for each child collection. • It causes exponential query explosion: 100 invoices with 3 line items each can generate 301 SQL queries instead of 1. • Fixes include JOIN FETCH, @EntityGraph, @BatchSize, and DTO projections. • Use Hibernate query statistics or spring.jpa.show-sql=true to detect it early. • In production, the fix reduced our page load from 12 seconds to 120ms.

✦ Definition~90s read
What is Hibernate N+1 Problem and How to Fix It?

The Hibernate N+1 problem is a performance anti-pattern where your code triggers one SQL query to load parent entities and then N additional queries to load each parent's associated collections or single references, multiplying database round-trips unnecessarily.

Imagine you own a library with 100 shelves.
Plain-English First

Imagine you own a library with 100 shelves. To find books by genre, you first walk through all shelves (1 query), then for each shelf you call a librarian who goes back to check the books on that shelf individually (100 queries). That's N+1. A smarter way is to just look at a map that shows all shelves and their books together (1 query).

You've been there. You deploy a seemingly innocent Spring Boot REST endpoint that fetches a list of invoices. In dev, with 10 rows, it's snappy. In staging with 500 rows, it's sluggish. In production with 10,000 invoices, your page takes 12 seconds to load and your DBA is calling you at 2 AM. Welcome to the Hibernate N+1 query problem.

I've debugged this exact scenario in a SaaS billing system handling 2 million invoices. The root cause was always the same: lazy-loading entity associations without proper fetch planning. Hibernate, by default, defers loading of child collections until they are accessed. When you iterate over a parent list and access each child collection, Hibernate fires one SQL query per parent. That's 1 (parent fetch) + N (child fetches) queries. For a list of 100 invoices each with 3 line items, that's 301 queries. Now scale that to thousands.

In this article, I'll show you how to spot the N+1 problem using Hibernate query statistics, how to fix it with JOIN FETCH and @EntityGraph, and how to avoid it in the first place. We'll use a realistic e-commerce order management domain. By the end, you'll know how to cut your query count from hundreds to single digits.

Understanding the N+1 Query Pattern

The N+1 problem is deceptively simple. You have an entity like Order with a @OneToMany to OrderItem. You write a repository method findAll() and then in your service you loop over orders to calculate total items. Hibernate, being lazy by default, fires 1 query for orders and then for each order, when you access order.getItems(), it fires another query. That's 1 + N.

OrderEntity.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String customerName;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();

    // getters, setters
}

@Entity
@Table(name = "order_items")
public class OrderItem {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String productName;
    private BigDecimal price;

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

    // getters, setters
}
Output
Two entities with a bidirectional OneToMany/ManyToOne relationship, both lazy.
⚠ Lazy-Loading Is Not Free
📊 Production Insight
In production, we once saw 50,000 queries per request because a developer accessed order.getItems().size() in a Thymeleaf template loop. Always check view layers for lazy-loading triggers.
🎯 Key Takeaway
N+1 happens when you iterate over parent entities and access lazy collections. The fix is to plan your fetch strategy upfront.
hibernate-n-plus-1-problem Hibernate N+1: Layer Stack of Query Generation From application code down to database round trips Application Layer Service Loop | Entity Access Persistence Context EntityManager | Lazy Loading Proxy Hibernate ORM Session | Query Plan Cache | Fetch Strategy JDBC Layer Statement | ResultSet Database 100+ SELECT Queries | Connection Pool THECODEFORGE.IO
thecodeforge.io
Hibernate N Plus 1 Problem

What the Official Docs Won't Tell You

The official Hibernate documentation explains JOIN FETCH and @EntityGraph, but it doesn't tell you the gritty truth: these annotations are easy to misuse, and they can actually make performance worse if you're not careful. For instance, JOIN FETCH on multiple collections creates a Cartesian product. If you join fetch both items and payments on an order, you'll get (number of items * number of payments) rows per order. That's a data explosion.

Another thing the docs gloss over: @EntityGraph works beautifully with Spring Data JPA's derived query methods, but it fails silently with custom @Query annotations if you don't align them. I've seen teams spend hours debugging why their EntityGraph was ignored.

Finally, the docs rarely mention the impact on pagination. Using JOIN FETCH with Pageable can lead to in-memory pagination because Hibernate can't apply LIMIT correctly after joins. The result: your "page of 20" actually loads all rows into memory and then slices. That's a memory killer in production.

OrderRepository.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface OrderRepository extends JpaRepository<Order, Long> {

    // This works with EntityGraph
    @EntityGraph(attributePaths = {"items"})
    List<Order> findAll();

    // This IGNORES EntityGraph because of custom @Query
    @EntityGraph(attributePaths = {"items"})
    @Query("SELECT o FROM Order o WHERE o.customerName = :name")
    List<Order> findByCustomerName(@Param("name") String name);

    // Fix: include JOIN FETCH in the @Query
    @Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.customerName = :name")
    List<Order> findByCustomerNameFixed(@Param("name") String name);
}
Output
The second method ignores EntityGraph. The third method with explicit JOIN FETCH works correctly.
💡EntityGraph + @Query = Silent Failure
📊 Production Insight
We had a report that took 30 seconds. Root cause: two JOIN FETCH on @OneToMany collections. The result set had 500,000 rows for 100 orders. We switched to batch fetching and separate queries for the second collection.
🎯 Key Takeaway
Don't mix @EntityGraph with @Query. Use JOIN FETCH inside JPQL for explicit control. And avoid multiple JOIN FETCH on collections to prevent Cartesian products.

Detecting N+1 with Hibernate Statistics

You can't fix what you can't measure. Hibernate provides a built-in Statistics API that logs query counts, cache hits, and flush times. Enable it in application.properties and watch the logs. I recommend enabling it in all environments except production, or selectively via a custom filter.

application.propertiesPROPERTIES
1
2
3
4
5
6
7
8
9
10
11
12
13
# Enable Hibernate statistics
spring.jpa.properties.hibernate.generate_statistics=true

# Log SQL statements (useful for debugging)
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Log slow queries (threshold in ms)
spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS=100

# In production, use a logger level
logging.level.org.hibernate.stat=DEBUG
logging.level.org.hibernate.SQL=DEBUG
Output
After startup, you'll see lines like: 2024-01-15 10:23:45.678 DEBUG 12345 --- [nio-8080-exec-1] o.h.stat.internal.StatisticsImpl: HQL: select o from Order o, time: 2ms, rows: 100
2024-01-15 10:23:45.680 DEBUG 12345 --- [nio-8080-exec-1] o.h.stat.internal.StatisticsImpl: HQL: select i from OrderItem i where i.order.id = ?, time: 1ms, rows: 3
... repeated 100 times
⚠ Don't Enable Statistics in Production Freely
📊 Production Insight
In a recent incident, statistics showed 10,001 queries for a single endpoint. The DBA saw the spike in database connections. We added a custom Actuator endpoint to toggle statistics on-the-fly for debugging.
🎯 Key Takeaway
Hibernate statistics are your first line of defense. Enable them in dev/staging and watch for repeated queries with the same pattern.
hibernate-n-plus-1-problem JOIN FETCH vs @EntityGraph vs Batch Fetching Three strategies to eliminate N+1 queries JOIN FETCH Batch Fetching SQL Generation Single JOIN query Multiple queries with IN clause Data Duplication Cartesian product risk No duplication Pagination Support Breaks pagination Works with pagination Ease of Use Simple JPQL addition Annotation or config needed Performance for Large Sets May transfer too much data More efficient for large collections THECODEFORGE.IO
thecodeforge.io
Hibernate N Plus 1 Problem

Fix #1: JOIN FETCH for Immediate Loading

The most straightforward fix is to use JOIN FETCH in your JPQL query. This tells Hibernate to load the association in the same SQL query using a JOIN. It's explicit, easy to understand, and works with pagination if you're careful (single collection only).

OrderRepository.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Fix N+1 with JOIN FETCH
    @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items")
    List<Order> findAllWithItems();

    // With pagination (works only if no multiple collections)
    @Query(value = "SELECT o FROM Order o JOIN FETCH o.items",
           countQuery = "SELECT COUNT(o) FROM Order o")
    Page<Order> findAllWithItemsPaged(Pageable pageable);

    // Filtered
    @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.customerName = :name")
    List<Order> findByCustomerWithItems(@Param("name") String name);
}
Output
Single SQL query: SELECT o.*, i.* FROM orders o JOIN order_items i ON o.id = i.order_id WHERE o.customer_name = ?
💡Use DISTINCT to Avoid Duplicate Parents
📊 Production Insight
We replaced our N+1 query with JOIN FETCH and saw page load drop from 4 seconds to 200ms. But we had to add DISTINCT because the UI showed 3 copies of each order.
🎯 Key Takeaway
JOIN FETCH is the simplest fix for N+1. Use DISTINCT to avoid duplicates. For pagination, ensure you only join one collection.

Fix #2: Using @EntityGraph for Declarative Fetching

@EntityGraph is a JPA 2.1 feature that lets you define fetch strategies declaratively without writing JPQL. It's great for derived query methods (findByXxx) where you can't easily add JOIN FETCH. You define an entity graph on the entity or repository method.

Order.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Entity
@Table(name = "orders")
@NamedEntityGraph(name = "Order.withItems",
    attributeNodes = @NamedAttributeNode("items"))
public class Order {
    // ... fields
}

// Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph("Order.withItems")
    List<Order> findByCustomerName(String name);

    // Dynamic graph via attribute paths
    @EntityGraph(attributePaths = {"items", "payments"})
    List<Order> findByStatus(String status);
}
Output
Generates LEFT JOIN FETCH for items and payments. Be careful with multiple collections.
⚠ EntityGraph Uses LEFT JOIN, Not INNER JOIN
📊 Production Insight
We use @EntityGraph for simple list endpoints. For complex reporting, we fall back to JPQL JOIN FETCH with DTO projections for better control.
🎯 Key Takeaway
@EntityGraph is clean for derived queries. But it always uses LEFT JOIN and can't be combined with @Query. Use it when you need a quick fix without writing JPQL.

Fix #3: Batch Fetching as a Safety Net

Sometimes you can't avoid lazy-loading — maybe you have deep object graphs or dynamic access patterns. Batch fetching tells Hibernate to load multiple collections in batches rather than one by one. You can configure it globally or per entity.

Batch fetching is not a silver bullet; it still generates multiple queries (N/batchSize + 1), but it reduces them significantly. For example, with batch size 20, 100 orders generate 1 + 5 queries instead of 101.

application.propertiesPROPERTIES
1
2
3
4
5
6
7
8
9
# Global batch fetching
spring.jpa.properties.hibernate.default_batch_fetch_size=20

# Or per entity
@Entity
@BatchSize(size = 20)
public class Order {
    // ...
}
Output
Hibernate now loads collections in batches of 20. For 100 orders, it fires 6 queries instead of 101.
💡Batch Size Tuning
📊 Production Insight
We set default_batch_fetch_size=25 globally. It saved us when a new developer added a lazy access in a loop without realizing it. The query count went from 1001 to 41.
🎯 Key Takeaway
Batch fetching is a pragmatic safety net when you can't control all access patterns. Set it globally in application.properties.

Fix #4: DTO Projections to Avoid Entity Loading

The ultimate fix is to not load entities at all when you only need a subset of data. DTO projections with JPQL or constructor expressions let you fetch exactly the columns you need. This avoids lazy-loading entirely and reduces memory footprint.

For our invoice summary example, we only needed invoice number, customer name, and total items. No need to load the full LineItem entities.

InvoiceSummary.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public record InvoiceSummary(Long id, String invoiceNumber, String customerName, int itemCount) {}

// Repository
public interface InvoiceRepository extends JpaRepository<Invoice, Long> {

    @Query("""
        SELECT new com.example.dto.InvoiceSummary(
            i.id, i.invoiceNumber, i.customerName, 
            SIZE(i.lineItems)
        ) FROM Invoice i
    """)
    List<InvoiceSummary> findAllSummaries();

    // Or use constructor expression with JOIN for more complex
    @Query("""
        SELECT new com.example.dto.InvoiceLineItemDTO(
            i.id, i.invoiceNumber, li.productName, li.amount
        ) FROM Invoice i JOIN i.lineItems li
    """)
    List<InvoiceLineItemDTO> findAllWithLineItems();
}
Output
Single query: SELECT i.id, i.invoice_number, i.customer_name, (SELECT COUNT(*) FROM line_items li WHERE li.invoice_id = i.id) FROM invoices i
💡DTO Projections Are Underrated
📊 Production Insight
Our 12-second page became 120ms after switching to a DTO projection with a subquery for counts. The entity manager wasn't even involved for the list.
🎯 Key Takeaway
For read-only operations, prefer DTO projections over entities. They are faster, use less memory, and completely avoid N+1.

Preventing N+1 with Spring Data JPA Specifications

When you need dynamic queries with filters, JOIN FETCH and @EntityGraph can become unwieldy. Spring Data JPA's Specification API combined with FetchStrategy can help, but you must be explicit about fetching. I recommend creating a custom repository base class that applies fetch joins based on annotations.

Alternatively, use QueryDSL or Blaze-Persistence for more sophisticated fetch control. But for most cases, a simple pattern with Specifications and a fetch utility works.

OrderSpecification.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class OrderSpecifications {

    public static Specification<Order> hasCustomerName(String name) {
        return (root, query, cb) -> {
            // Ensure fetch is applied only for non-count queries
            if (query.getResultType() != Long.class) {
                root.fetch("items", JoinType.LEFT);
            }
            return cb.equal(root.get("customerName"), name);
        };
    }
}

// Service
public List<Order> findOrdersWithItems(String name) {
    return orderRepository.findAll(
        OrderSpecifications.hasCustomerName(name)
    );
}
Output
Generates: SELECT o.*, i.* FROM orders o LEFT JOIN order_items i ON o.id = i.order_id WHERE o.customer_name = ?
⚠ Specification Fetch Breaks Pagination
📊 Production Insight
We use Specifications for our admin search page. The fetch join is applied only for non-count queries. We also set a default batch fetch size as a safety net.
🎯 Key Takeaway
Specifications with manual fetch give you flexibility but require careful handling of count queries. Use them only when you need dynamic filters.
● Production incidentPOST-MORTEMseverity: high

The 12-Second Invoice List: A SaaS Billing Nightmare

Symptom
GET /api/invoices took 12 seconds for 10,000 invoices. Database CPU at 95%. 10,001 SQL queries logged per request.
Assumption
We assumed lazy-loading would be fine because 'nobody navigates to line items often.' We forgot that the UI rendered a summary count for each invoice.
Root cause
Invoice entity had @OneToMany(mappedBy="invoice", fetch = FetchType.LAZY) List<LineItem> lineItems. The Thymeleaf template called invoice.lineItems.size() inside a loop, triggering N separate queries.
Fix
Added @EntityGraph(attributePaths = {"lineItems"}) on the repository method and used a DTO projection for the summary. Query count dropped to 1.
Key lesson
  • Never trust lazy-loading when iterating over collections in views or DTO mappers.
  • Always profile with Hibernate statistics in staging before production.
  • Use batch fetching as a safety net even after JOIN FETCH optimizations.
Production debug guideA step-by-step guide for on-call engineers3 entries
Symptom · 01
Endpoint response time > 5 seconds
Fix
Check database slow query log. Look for repetitive SELECT queries with same structure but different IDs.
Symptom · 02
Database CPU at 100%
Fix
Enable Hibernate statistics via Spring Actuator (custom endpoint) or restart with debug logging. Count total queries per request.
Symptom · 03
High memory usage in JVM heap
Fix
Check for in-memory pagination caused by JOIN FETCH with Pageable. Look for warnings in logs about 'firstResult/maxResults specified with collection fetch'.
★ N+1 Quick Debug Cheat SheetFast actions to identify and mitigate N+1 queries during an incident
Repetitive SELECT queries in logs
Immediate action
Add spring.jpa.properties.hibernate.generate_statistics=true and restart. Check query count.
Commands
grep "select.*from" /var/log/app.log | wc -l
grep "select.*from order_items" /var/log/app.log | head -5
Fix now
Add @EntityGraph(attributePaths = {"items"}) on repository method and redeploy.
Slow page load for list endpoints+
Immediate action
Check if view template accesses lazy collections. Use DTO projection instead.
Commands
curl -X GET /actuator/health
tail -f /var/log/app.log | grep "HQL:"
Fix now
Replace entity list with DTO projection in service layer.
Pagination returning wrong data or too many rows+
Immediate action
Check if JOIN FETCH is used with Pageable. Remove fetch join from count query.
Commands
grep "countQuery" /var/log/app.log
grep "firstResult" /var/log/app.log
Fix now
Use batch fetching or separate query for count.
TechniqueSQL Queries (100 orders)Memory UsagePagination SupportEase of Use
Lazy Loading (N+1)101Low (per entity)YesEasy (but dangerous)
JOIN FETCH1High (all data)Single collection onlyMedium
@EntityGraph1High (LEFT JOIN)Single collection onlyEasy (derived queries)
Batch Fetching (size=20)6MediumYesEasy (global config)
DTO Projection1Low (only selected columns)YesMedium (requires DTO class)
⚙ Quick Reference
7 commands from this guide
FileCommand / CodePurpose
OrderEntity.java@EntityUnderstanding the N+1 Query Pattern
OrderRepository.javapublic interface OrderRepository extends JpaRepository {What the Official Docs Won't Tell You
application.propertiesspring.jpa.properties.hibernate.generate_statistics=trueDetecting N+1 with Hibernate Statistics
Order.java@EntityFix #2
application.propertiesspring.jpa.properties.hibernate.default_batch_fetch_size=20Fix #3
InvoiceSummary.javapublic record InvoiceSummary(Long id, String invoiceNumber, String customerName,...Fix #4
OrderSpecification.javapublic class OrderSpecifications {Preventing N+1 with Spring Data JPA Specifications

Key takeaways

1
The N+1 problem is the most common Hibernate performance issue in Spring Boot apps. Always profile with statistics.
2
Prefer JOIN FETCH for single collections, @EntityGraph for derived queries, and DTO projections for read-only operations.
3
Set a global batch fetch size (default_batch_fetch_size=20) as a safety net for unpredictable access patterns.
4
Never mix @EntityGraph with @Query. Use explicit JOIN FETCH inside JPQL instead.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Explain the Hibernate N+1 problem with a real-world example.
Q02SENIOR
How would you debug an N+1 issue in a Spring Boot application running in...
Q03SENIOR
What are the trade-offs between JOIN FETCH and @EntityGraph when dealing...
Q01 of 03JUNIOR

Explain the Hibernate N+1 problem with a real-world example.

ANSWER
The N+1 problem occurs when you fetch a list of parent entities and then iterate to access each child collection. For instance, fetching 100 orders and calling order.getItems() inside a loop triggers 1 query for orders and 100 queries for items. This multiplies database round-trips. Fix with JOIN FETCH, @EntityGraph, or DTO projections.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the N+1 problem in Hibernate?
02
How do I detect N+1 queries in Spring Boot?
03
What is the difference between JOIN FETCH and @EntityGraph?
04
Can I use JOIN FETCH with pagination?
05
Is batch fetching a good alternative to JOIN FETCH?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.

Follow
Verified
production tested
July 18, 2026
last updated
2,466
articles · all by Naren
🔥

That's Hibernate & JPA. Mark it forged?

3 min read · try the examples if you haven't

Previous
Hibernate Caching — First and Second Level
7 / 28 · Hibernate & JPA
Next
Spring JDBC with JdbcTemplate: Complete Guide to Database Access