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.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Java 17+
- ✓Spring Boot 3.2+
- ✓Spring Data JPA (Hibernate 6.4+)
- ✓MySQL 8+ or PostgreSQL 15+
• 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.
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.
Let me show you with code. Consider these entities:
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.
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.
Here's how to set it up:
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).
Let's fix our order repository:
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.
Here's how to use it:
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.
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.
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.
The 12-Second Invoice List: A SaaS Billing Nightmare
- 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.
grep "select.*from" /var/log/app.log | wc -lgrep "select.*from order_items" /var/log/app.log | head -5| File | Command / Code | Purpose |
|---|---|---|
| OrderEntity.java | @Entity | Understanding the N+1 Query Pattern |
| OrderRepository.java | public interface OrderRepository extends JpaRepository | What the Official Docs Won't Tell You |
| application.properties | spring.jpa.properties.hibernate.generate_statistics=true | Detecting N+1 with Hibernate Statistics |
| Order.java | @Entity | Fix #2 |
| application.properties | spring.jpa.properties.hibernate.default_batch_fetch_size=20 | Fix #3 |
| InvoiceSummary.java | public record InvoiceSummary(Long id, String invoiceNumber, String customerName,... | Fix #4 |
| OrderSpecification.java | public class OrderSpecifications { | Preventing N+1 with Spring Data JPA Specifications |
Key takeaways
Interview Questions on This Topic
Explain the Hibernate N+1 problem with a real-world example.
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Hibernate & JPA. Mark it forged?
3 min read · try the examples if you haven't