LEARN · DEBUGGING GUIDE

Hibernate N+1 Select Problem: Detection, Diagnosis, and Fixes

The Hibernate N+1 select problem silently kills performance with thousands of tiny queries. This guide shows you how to spot it with SQL logging, fix it with joins or batch fetching, and verify the fix under load.

IntermediateDatabase7 min read

What this usually means

Your code retrieves a parent entity, then iterates over its child collection. Hibernate's default lazy loading fires a separate SELECT for each child access. Instead of one query with a JOIN, you get one query for the parent and N queries for the children. The same antipattern appears with `@OneToMany`, `@ManyToOne`, and `@ElementCollection` when the fetch strategy isn't explicitly set to JOIN. Spring Data JPA's `findAll()` on an entity with lazy associations is the most common trigger.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Enable Hibernate SQL logging: add `spring.jpa.show-sql=true` and `logging.level.org.hibernate.SQL=DEBUG` to `application.properties`
  • 2Count the number of SQL statements logged for a single endpoint call — if it's >> expected entities, you have N+1
  • 3Check the Hibernate Statistics bean: `spring.jpa.properties.hibernate.generate_statistics=true` and look for 'Queries executed to database'
  • 4Use a database proxy like p6spy or datasource-proxy to capture all queries with stack traces
  • 5In the logs, look for repeated SELECT statements with the same WHERE clause but different IDs — classic N+1 signature
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • search`application.properties` or `application.yml` for Hibernate SQL logging and statistics flags
  • searchHibernate Statistics MBean (JMX) or logged stats at session close
  • searchSpring Data JPA repository methods with `@EntityGraph` annotations
  • searchService layer methods iterating over lazy collections (e.g., `parent.getChildren().forEach(...)`)
  • searchDatabase slow query log (e.g., `pg_stat_statements` on PostgreSQL) to identify high-frequency queries
  • searchp6spy log file or stdout for query count and timing per request
  • search`@ManyToOne` and `@OneToMany` annotations in entity classes — check if `fetch` is `LAZY` or `EAGER`
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningDefault lazy loading on `@OneToMany` without explicit `JOIN FETCH` in the query
  • warningIterating over a lazy collection in the service layer while the session is still open (Open Session in View anti-pattern)
  • warningUsing `findAll()` on a Spring Data JPA repository without `@EntityGraph` or `@Query` with JOIN FETCH
  • warningSerializing entities with lazy associations in REST responses — triggers N+1 during JSON serialization (Jackson lazy loading)
  • warningBatch fetching disabled: without `@BatchSize`, Hibernate loads children one by one
  • warningCircular references or deep navigation in DTO mapping causing cascading lazy loads
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildUse `JOIN FETCH` in JPQL: `SELECT p FROM Parent p JOIN FETCH p.children WHERE p.id = :id`
  • buildApply `@EntityGraph(attributePaths = {"children"})` on the Spring Data JPA repository method
  • buildAdd `@BatchSize(size = 20)` on the child collection to batch lazy loads into fewer queries
  • buildSwitch to `@NamedEntityGraph` and use `@EntityGraph` on the repository method
  • buildUse DTO projections (JPQL `SELECT new ParentDTO(...)`) to avoid loading associations entirely
  • buildEnable Hibernate's `hibernate.default_batch_fetch_size` property globally (e.g., 16)
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedAfter the fix, re-run the same endpoint and count SQL statements — should be 1 or 2 instead of N+1
  • verifiedMonitor Hibernate Statistics: 'Queries executed to database' should drop to near the number of parent entities
  • verifiedRun a load test (e.g., with k6 or Gatling) comparing response times before and after the fix
  • verifiedCheck database connection pool metrics — idle connections should increase and active threads decrease
  • verifiedAssert in integration tests: use `@Transactional` and count queries with `QueryCountHolder` from p6spy
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningSetting all associations to `EAGER` fetch — this can load the entire database into memory
  • warningLeaving `spring.jpa.open-in-view=true` (default) in production — it keeps the session open for lazy loading in the view layer
  • warningUsing `JOIN FETCH` on multiple collections in the same query — creates Cartesian product (row explosion)
  • warningFixing only one endpoint while leaving other service methods with the same antipattern
  • warningForgetting to test with realistic data volumes — N+1 might not show with 3 rows but kills with 10k
  • warningAdding `@Transactional` in the service layer without understanding that it keeps the session open for lazy loading
( 07 )War story

User Profile Endpoint Slams Database with 101 Queries

Backend Engineer (Senior)Spring Boot 2.6, Hibernate 5.6, PostgreSQL 13, HikariCP, Kubernetes

Timeline

  1. 09:15Alert: p95 latency for `/api/users/profile` spiked from 20ms to 800ms
  2. 09:18HikariCP pool exhausted: 50 active connections, 0 idle
  3. 09:20Enable Hibernate SQL logging and reproduce the endpoint with a single user ID
  4. 09:22Logs show 1 SELECT from `users` table followed by 100 SELECTs from `orders` table – N+1 confirmed
  5. 09:25Check `User` entity: `@OneToMany(mappedBy="user")` on `orders` field – no fetch strategy specified
  6. 09:30Hotfix: Add `@EntityGraph(attributePaths = "orders")` to `UserRepository.findByUsername()`
  7. 09:35Redeploy and verify: only 2 SQL queries now (1 for user + 1 for orders with JOIN)
  8. 09:40Latency drops back to 20ms, pool connections drop to 5 active

Monday morning, our pager goes off. The user profile endpoint—critical for our mobile app—was showing 800ms p95 latency. Our HikariCP pool was completely drained: 50 active connections, zero idle. The database CPU was pegged at 90%. I immediately suspected N+1 because we'd had similar issues before.

I enabled Hibernate SQL logging on the staging environment and hit the endpoint with a user ID that had 100 orders. The logs confirmed my suspicion: one SELECT from the users table, then exactly 100 SELECTs from the orders table, each with a different ID. The `User` entity had a lazy `@OneToMany` on the `orders` field, and the service was iterating over `user.getOrders()` to build the response.

I pushed a hotfix: added `@EntityGraph(attributePaths = "orders")` to the repository method. Redeployed and saw latency drop to 20ms. The SQL log showed only two queries: one for the user and one for the orders with a JOIN. We also added a `@BatchSize(size=20)` as a safety net for other endpoints. The lesson: never assume lazy loading won't be triggered; always explicitly fetch what you need in queries.

Root cause

Default lazy loading on `@OneToMany` without explicit fetch strategy. Service layer iterated over `user.getOrders()` causing Hibernate to fire a separate SELECT for each order.

The fix

Added `@EntityGraph(attributePaths = "orders")` on the repository method to force a JOIN FETCH. Also added `@BatchSize(size=20)` on the collection as a fallback.

The lesson

Always specify fetch strategy explicitly in queries (JOIN FETCH or @EntityGraph). Relying on lazy loading in service code is a ticking time bomb. Enable SQL logging in development to catch N+1 early.

( 08 )Why N+1 Happens: Lazy Loading Mechanics

When you annotate a `@OneToMany` with default `FetchType.LAZY`, Hibernate returns a proxy collection. The actual SQL to load children is deferred until you access that collection (e.g., call `.size()` or iterate). Without any fetch hint, Hibernate executes a separate SELECT for each parent entity's children. This is the N+1: 1 SELECT for the parent list, then N SELECTs for each child set.

The problem is exacerbated by Spring's Open Session in View (OSIV) pattern, which keeps the Hibernate session open during view rendering. If your REST controller returns entities and Jackson serialization triggers lazy loading, you get N+1 queries in the serialization phase—completely hidden from your service code.

( 09 )Detecting N+1 with Hibernate Statistics and p6spy

Hibernate's built-in statistics are your first weapon. Set `spring.jpa.properties.hibernate.generate_statistics=true`. At session end, you'll see a line like: `Session Metrics: 1 flush, 101 queries executed to database`. If queries >> expected entities, you have N+1.

For deeper inspection, use p6spy or datasource-proxy. These JDBC interceptors log every SQL statement with a stack trace. You can grep for repeated SELECTs with different parameter values. In p6spy, enable `logMessageFormat: com.p6spy.engine.spy.appender.MultiLineFormat` to see the calling code. I've often found the N+1 originating from a getter in a DTO mapper that was never suspected.

( 10 )JOIN FETCH vs @EntityGraph: When to Use Which

`JOIN FETCH` in JPQL is explicit and works for any query. Example: `SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id`. It forces an inner join and loads children in the same query. But it can cause Cartesian products if you join multiple collections—use with caution.

Spring Data JPA's `@EntityGraph` is cleaner. On your repository method, add `@EntityGraph(attributePaths = {"orders"})`. This generates a LEFT JOIN FETCH. It's declarative and works with derived queries. However, it doesn't support pagination well—the join may fetch all rows and then apply pagination in memory. For pagination, consider using `@Query` with a separate count query.

( 11 )Batch Fetching: A Safety Net for Legacy Code

When you can't rewrite every query, use `@BatchSize` on the collection: `@OneToMany(mappedBy="user") @BatchSize(size=20) private List<Order> orders;`. This makes Hibernate load children in batches of 20 instead of one by one. So with 100 children, you get 5 queries instead of 100. It's not as good as a single join, but it's a massive improvement.

Globally, you can set `hibernate.default_batch_fetch_size=16` in properties. This applies to all lazy associations that don't have an explicit batch size. Combine with `hibernate.jdbc.batch_size` for insert/update batching. Batch fetching works well with OSIV because it reduces the number of round trips per request.

( 12 )Prevention: DTO Projections and the Query Plan Cache

The most radical fix is to avoid loading entities altogether. Use JPQL constructor expressions or Spring Data Projections: `SELECT new com.example.UserOrderSummary(u.id, u.name, o.total) FROM User u JOIN u.orders o`. No lazy loading, no N+1. This also reduces memory pressure.

Monitor your database's query plan cache. Repeated similar queries (differing only by parameter) can be a sign of N+1. In PostgreSQL, query `pg_stat_statements` for queries with high execution count and low total time—they indicate many small queries. Hibernate's N+1 often manifests as thousands of identical SELECTs with different WHERE clause values.

Frequently asked questions

Does setting `fetch = FetchType.EAGER` fix N+1?

No, EAGER loading fixes N+1 only for the immediate association, but it can cause other problems. EAGER forces a JOIN or a separate SELECT immediately when the parent is loaded. However, if you have multiple EAGER associations, Hibernate may issue multiple SELECTs anyway (or a Cartesian product). Worse, EAGER can load entire database tables into memory, causing severe performance issues. Always prefer explicit fetch strategies in queries over global fetch type changes.

Why does N+1 appear only in production and not in development?

Development databases often have small datasets (e.g., 3 users, 5 orders). With small numbers, N+1 adds only a few extra queries that go unnoticed. In production with thousands of records, those extra queries multiply and overwhelm the database. Always test with realistic data volumes. Use tools like Testcontainers to seed large datasets in integration tests.

Can Spring Data JPA's `findAll()` cause N+1?

Yes, absolutely. `findAll()` loads all parent entities. If any parent has a lazy association that gets accessed (e.g., during serialization or in a service loop), Hibernate will fire N+1 queries. To fix, add `@EntityGraph(attributePaths = {"association"})` on the `findAll()` method, or use a custom `@Query` with JOIN FETCH.

What is the Open Session in View (OSIV) antipattern?

OSIV (enabled by default in Spring Boot with `spring.jpa.open-in-view=true`) keeps the Hibernate session open until the view (or response) is rendered. This allows lazy loading to succeed even after the service method returns. However, it hides N+1 problems because queries are fired during serialization, outside your service code. Worse, it ties up database connections longer. Disable OSIV in production (`spring.jpa.open-in-view=false`) to force early detection of N+1.