Sitemap
Engineering Under Pressure

We write about building systems that survive the real world. Backend architecture, AI engineering, DevOps, cloud, system design, performance, debugging, and production failures no theory for theory’s sake, just engineering lessons earned the hard way.

Hibernate N+1 Query Problem in Spring Boot: Why Fetch Join Isn’t Always the Fix

One endpoint makes 400 SQL queries to return 100 records. You add a fetch join, bring it down to one query, and discover that production performance has somehow become worse.

11 min readOct 26, 2026

--

Hibernate has a talent for making expensive database behavior look perfectly innocent in Java.

A repository method returns a list of orders. A service maps those orders into response objects. Everything looks ordinary until somebody enables SQL logging and notices that an endpoint returning 100 orders is executing more than 100 queries.

Most experienced Java developers recognize the problem immediately. Lazy-loaded relationships are being accessed during mapping, so Hibernate issues additional queries for associated entities. It’s the classic N+1 problem, and the usual recommendation is to fetch the relationships eagerly using a join.

That recommendation works often enough to become muscle memory. It also creates some spectacular performance problems when applied without considering how many rows the join will produce.

Imagine an order management application where customers can have hundreds of orders, and each order contains several line items. The API supports pagination, sorting, and filtering. Everything works reasonably well until traffic increases and database CPU starts climbing. A developer finds N+1 queries in the order listing endpoint and replaces the original repository query with a fetch join.

Query count drops dramatically. Response time gets worse. The developer checks the logs again, convinced something must be wrong with the measurement. Hibernate is now issuing fewer SQL statements, which should mean less database work. Unfortunately, query count was never the complete performance metric.

Where N+1 Actually Comes From

Consider two entities with a familiar relationship.

@Entity
public class Order {.


@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
@OneToMany(mappedBy = "order")
private List<OrderItem> items;
}

A repository retrieves 100 orders:

List<Order> orders = orderRepository.findAll();

Later, a mapper builds the API response:

return orders.stream()
.map(order -> new OrderResponse(
order.getId(),
order.getCustomer().getName()
))
.toList();

Hibernate initially retrieves the orders. Accessing customer.getName() may then trigger additional queries for customer entities that haven't already been loaded into the persistence context.

If all 100 orders belong to different customers, you could end up with one query for the orders and another 100 queries for their customers. If several orders share a customer, Hibernate’s first-level cache may reduce the number of additional queries. A configured second-level cache can also change the observed behavior.

That’s worth mentioning because N+1 is a description of an inefficient access pattern, not a promise that Hibernate will always execute exactly N+1 SQL statements.

Developers sometimes reproduce the problem in a tiny test, count eleven queries for ten orders, and assume production will behave identically. Real entity distributions, cache state, fetch configuration, and transaction boundaries can produce different numbers.

The underlying issue remains the same: application code accesses relationships that weren’t included in the original retrieval plan.

Why Fetch Join Looks Like a Perfect Solution

A developer changes the repository query:

@Query("""
SELECT o
FROM Order o
JOIN FETCH o.customer
""")
List<Order> findAllWithCustomers();

Hibernate can now retrieve orders and customers in one SQL query.

For a many-to-one relationship, this is often a sensible optimization. Each order refers to at most one customer, so joining customer information doesn’t multiply order rows the way joining a collection can.

The endpoint becomes faster, query count drops, and everyone moves on.

Problems begin when the same technique is applied to collections.

Suppose the API also needs line items.

@Query("""
SELECT o
FROM Order o
LEFT JOIN FETCH o.items
""")
List<Order> findAllWithItems();

One query still sounds attractive, but the SQL result no longer contains one row per order.

If an order contains eight items, its order columns appear across eight joined rows. An order with twenty items contributes twenty rows.

For 100 orders averaging ten items, the database may return roughly 1,000 rows.

Hibernate then has to process those rows and assemble the object graph.

That might still be faster than issuing 101 separate queries. Quite often it is.

But what happens when the collection grows to hundreds of items per order, or when another collection is fetched in the same query?

The amount of transferred data and object hydration work can become much larger than expected.

Reducing round trips is useful. Increasing the result set by an order of magnitude is a cost you need to measure.

Fetching Two Collections Can Turn a Small Request Into a Huge Result

Imagine an order has twelve line items and eight shipment events.

An engineer wants both collections available for response mapping:

@Query("""
SELECT o
FROM Order o
LEFT JOIN FETCH o.items
LEFT JOIN FETCH o.shipmentEvents
WHERE o.id = :id
""")
Optional<Order> findDetailedOrder(Long id);

At the SQL level, joining both collections can produce twelve times eight rows for the same order.

That’s ninety-six rows representing one parent entity.

For a single order, it might be manageable. For a page of fifty orders with similar collection sizes, the database could return thousands of rows, repeating the same parent and associated values.

The application then spends time transferring, reading, and reconstructing information that was duplicated by the join structure.

There’s another Hibernate-specific complication. Fetching multiple bag-valued collections in one query can produce MultipleBagFetchException. Developers sometimes respond by changing collection types without considering the underlying SQL multiplication.

Switching a List to a Set may affect Hibernate's ability to execute a particular fetch plan, but it doesn't make the Cartesian multiplication disappear.

You can avoid an exception and still end up with an expensive query.

This is one reason I don’t like treating fetch joins as a universal cure for N+1. They are a tool for shaping retrieval, and retrieval plans need to account for relationship cardinality.

Pagination Is Where Collection Fetch Joins Become Dangerous

Suppose your API supports:

GET /api/orders?page=0&size=20

The frontend expects twenty orders, ordered by creation date.

A developer writes a repository query that fetches orders together with their items and accepts a Pageable parameter.

At first glance, Hibernate should execute a query with a limit of twenty rows.

But a collection fetch join complicates pagination because the SQL result contains repeated parent rows. Applying a limit directly to joined rows may return only a fraction of the requested distinct orders.

Depending on Hibernate configuration and version, Hibernate may need to retrieve a larger result set and apply pagination in memory. It can emit a warning rather than performing efficient database-level pagination.

On a small dataset, nobody notices.

On a table containing millions of orders, fetching a large joined result just to return twenty parent entities can become extremely expensive.

I would configure Hibernate to fail rather than silently allow this behavior:

spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true

This makes the problem visible during development or testing instead of allowing a seemingly harmless repository method to create a production incident.

A better approach is usually to paginate parent identifiers first, then retrieve the required associations for those selected parents.

For example, first select the twenty order IDs that satisfy the filter and sorting requirements.

Then execute another query to fetch those orders and their items:

@Query("""
SELECT DISTINCT o
FROM Order o
LEFT JOIN FETCH o.items
WHERE o.id IN :ids
""")
List<Order> findOrdersWithItems(List<Long> ids);

You’ll need to restore the original page ordering because an IN predicate doesn't guarantee result order.

The two-query approach can be substantially more predictable than forcing pagination and collection fetching into one SQL statement.

It also keeps the number of selected parent entities under explicit control.

Batch Fetching Is Often a Better Compromise

Hibernate provides batch fetching, which can reduce the number of secondary queries needed to initialize lazy associations.

Instead of issuing a separate query for each collection, Hibernate can load multiple collections in batches.

A common configuration is:

spring.jpa.properties.hibernate.default_batch_fetch_size=32

Or you can configure batch fetching for particular associations:

@OneToMany(mappedBy = "order")
@BatchSize(size = 32)
private List<OrderItem> items;

Suppose an endpoint retrieves 100 orders and then accesses their items.

Get The Systems & Architecture’s stories in your inbox

Join Medium for free to get updates from this writer.

Without batching, Hibernate might execute one query for the orders and up to 100 additional queries for the collections.

With an effective batch size of 32, Hibernate can initialize multiple collections per secondary query, reducing the number of database round trips considerably.

The exact query count depends on Hibernate’s fetching behavior, persistence context state, and which associations are actually accessed.

Batch fetching doesn’t eliminate the cost of loading the items. It reduces the number of separate queries required to retrieve them.

That’s often a reasonable trade-off for APIs that need collection data but also require reliable pagination.

It avoids the enormous joined result sets that can occur when multiple collections are fetched together.

However, batch size isn’t a magic number. A value of 32 may work well for one workload and poorly for another. Database parameter limits, query plans, entity distributions, and memory usage all influence the result.

I’d benchmark a few realistic values rather than choosing the largest number available.

EntityGraph Doesn’t Automatically Fix Everything

Spring Data JPA makes entity graphs convenient:

@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByStatus(OrderStatus status);

This can be a clean way to specify which associations should be fetched for a particular operation.

But an entity graph doesn’t erase the underlying database costs.

If Hibernate implements the fetch plan using joins, the same row multiplication concerns can apply. If the query includes pagination over a collection, the same compatibility problem may appear.

Entity graphs are useful for expressing fetch requirements without embedding every detail in a JPQL query. They are not a guarantee of optimal SQL.

The distinction matters because repository code can look beautifully declarative while producing an expensive execution plan.

When performance is important, inspect the SQL Hibernate actually generates.

Sometimes You Should Stop Returning Entities

A common source of Hibernate performance problems is using entity graphs as API response models.

Imagine an endpoint that needs only an order ID, customer name, total amount, and creation date.

The application loads an Order entity, accesses its customer relationship, initializes some collections during mapping, and eventually constructs a response containing four fields.

That is a lot of persistence machinery for a small read model.

A DTO projection can be simpler:

@Query("""
SELECT new com.example.api.OrderSummary(
o.id,
c.name,
o.totalAmount,
o.createdAt
)
FROM Order o
JOIN o.customer c
WHERE o.status = :status
ORDER BY o.createdAt DESC, o.id DESC
""")
List<OrderSummary> findOrderSummaries(
OrderStatus status,
Pageable pageable
);

The query selects the values the endpoint needs rather than loading managed entities and traversing relationships afterward.

This can reduce object creation, persistence-context overhead, and accidental lazy loading.

It also creates a cleaner boundary between persistence models and API contracts.

There are trade-offs. DTO projections introduce explicit query shapes, and maintaining many specialized projections can become cumbersome. Some operations genuinely need managed entities because they modify business state.

But for high-traffic read endpoints, I’d rather have a query designed for the response than load a large entity graph and hope the mapper doesn’t trigger unexpected SQL.

Open Session in View Can Hide Bad Query Behavior

Spring Boot applications using JPA may have Open Session in View enabled, depending on configuration.

With Open Session in View, the persistence context can remain available during web response processing, allowing lazy associations to be initialized after the service-layer transaction has completed.

That convenience can hide N+1 problems.

A controller returns an entity or maps it into a DTO, and lazy loading still works because the persistence context remains open.

Developers may not notice that serialization or mapping triggers additional SQL queries.

Disabling Open Session in View can make these assumptions more visible:

spring.jpa.open-in-view=false

But switching it off without reviewing data access patterns can cause LazyInitializationException in code that previously relied on lazy loading outside transactional service methods.

The goal isn’t to disable a property and declare victory.

It’s to make retrieval requirements explicit at the service or repository boundary so that controllers and serializers don’t accidentally determine database access patterns.

I prefer knowing which queries an endpoint needs before response mapping begins.

That makes performance behavior easier to test and reason about.

How I’d Diagnose a Slow Hibernate Endpoint

If an endpoint becomes slow, I wouldn’t immediately search for every lazy association and replace it with a fetch join.

I’d first measure query count, total database time, rows returned, and application-side processing time.

A request executing 120 tiny queries may suffer primarily from network round trips. Another request executing one enormous join may spend most of its time transferring and hydrating duplicated rows.

Those problems require different fixes.

Hibernate statistics can help identify entity loading and query behavior. SQL logging is useful in development, but enabling verbose SQL output indiscriminately in production can create its own performance and operational problems.

For PostgreSQL, I’d examine execution plans with EXPLAIN (ANALYZE, BUFFERS) on representative queries, taking appropriate care because ANALYZE executes the statement.

I’d also inspect endpoint access patterns. Does the frontend need every association being loaded? Is the API returning hundreds of records when the user sees only twenty? Does the mapper traverse relationships that aren’t included in the response?

Sometimes the best optimization is removing unnecessary data access.

If the endpoint genuinely needs related collections, I’d compare fetch joins, batch fetching, and separate queries using production-like data distributions.

A database with ten orders per customer behaves differently from one where a few customers have hundreds of thousands of orders.

Performance tuning without realistic cardinality is mostly storytelling.

Frequently Asked Questions About Hibernate N+1 Queries

What is the N+1 query problem in Hibernate?

It occurs when an initial query retrieves a collection of entities and accessing related entities or collections triggers additional SQL queries. For example, loading 100 orders and then lazily loading each order’s customer can produce many additional database round trips.

How do you fix N+1 queries in Spring Boot?

Common approaches include JPQL fetch joins, entity graphs, batch fetching, DTO projections, and restructuring data access around specific API requirements. The best option depends on association cardinality, pagination needs, and the amount of data being retrieved.

Why can JOIN FETCH make Hibernate slower?

Fetching collections through joins can multiply SQL result rows and duplicate parent data. Fetching multiple collections can create particularly large result sets. Hibernate must process those rows and reconstruct entities, so fewer queries don’t always mean less work.

Can I use pagination with JOIN FETCH in Hibernate?

Pagination with collection fetch joins is problematic because SQL row limits don’t correspond directly to distinct parent entities. Hibernate may apply pagination in memory or reject the operation when configured to fail. Paginating parent IDs first and fetching associations separately is often safer.

What does hibernate.default_batch_fetch_size do?

It allows Hibernate to batch certain lazy association loads, reducing the number of secondary SQL queries. It doesn’t eliminate the need to load associated data, but it can significantly reduce round trips for endpoints that access many related entities.

Is EntityGraph better than JOIN FETCH?

Neither is universally better. Entity graphs provide a declarative way to specify fetch requirements, while fetch joins express them directly in JPQL. Both can lead to expensive SQL if the resulting fetch plan loads too much data or multiplies rows.

Should I disable spring.jpa.open-in-view?

Disabling it can help enforce clearer persistence boundaries and expose accidental lazy loading during response processing. However, applications relying on Open Session in View may need repository and service-layer changes first. The decision should be based on the application’s architecture and data access behavior.

A Better Question Than “How Many Queries?”

Suppose your team proudly reduces an endpoint from 101 SQL statements to one. Before celebrating, inspect what that one statement actually returns.

How many rows does PostgreSQL produce? How much data is transferred? How much time does Hibernate spend hydrating entities? Does the query still paginate efficiently? Are you loading associations the API never uses?

One carefully designed query can be excellent. One enormous join can also be worse than several small, predictable queries.

Hibernate gives Java developers a convenient object model over relational data, but relational databases still operate on rows, joins, indexes, and execution plans. Ignoring that difference is how a simple repository optimization becomes a production performance regression.

The next time you encounter N+1, don’t count queries and stop there. Measure the amount of work required to return the data your application actually needs.

For Java engineers working through ORM performance, SQL bottlenecks, JVM behavior, and production debugging, I’ve assembled the Java & Spring Boot Production Engineering System. It includes 12 engineering resources covering Spring Boot internals, production troubleshooting, SQL performance, Redis, Kafka, microservice boilerplates, release checks, and Senior Java interview preparation.

I also write about Java, backend architecture, and production engineering at Devrim’s Engineering Notes on Substack.

--

--

Written by The Systems & Architecture

Checklists and systems for real-world engineering. Prevent incidents before they happen. Java, Spring Boot, System Design

Published in Engineering Under Pressure

We write about building systems that survive the real world. Backend architecture, AI engineering, DevOps, cloud, system design, performance, debugging, and production failures no theory for theory’s sake, just engineering lessons earned the hard way.