Introduction
This article is the third in a series about data access with Spring Data.
This time, we’ll look at how JPA entity mappings and fetch strategies affect database read performance.
Object-relational mapping frameworks such as Hibernate provide a convenient way to work with relational data through Java objects. The SQL generated by Hibernate is not always obvious from the application code. Accessing a collection, navigating an association, or executing a repository method can result in additional queries, sometimes many more than expected.
The goal is not to avoid JPA or replace every repository operation with SQL. It is to understand how entity mappings influence database access and choose the appropriate strategy for each operation.
The examples use PostgreSQL, Spring Data JPA, and Hibernate 6.
1. Understanding Entity State
Before looking at fetch strategies, it helps to understand how JPA manages entity instances.
An entity can exist in several states:
- Transient: A newly created object that is not associated with a persistence context.
- Managed: An entity associated with a persistence context. Hibernate tracks changes and can synchronize them with the database.
- Detached: An entity that was previously managed but is no longer associated with a persistence context.
- Removed: An entity scheduled for deletion.
The persistence context maintains the identity of managed entities and coordinates their synchronization with the database.
1.1 The first-level cache
Every JPA persistence context maintains a first-level cache of managed entities.
Consider:
@Transactional(readOnly = true)
public void inspectAccount(UUID accountId) {
Account first = entityManager.find(Account.class, accountId);
Account second = entityManager.find(Account.class, accountId);
assert first == second;
}
Within the same persistence context, both calls return the same managed entity instance. Assuming the entity was not already loaded, Hibernate generally executes one database query rather than two.
This identity guarantee is useful, but it is important to understand its scope.
The first-level cache belongs to the persistence context, not necessarily to a database transaction. In typical Spring applications using transaction-scoped persistence contexts, its lifetime generally follows the transaction. Extended persistence contexts can span multiple transactions. It is not a shared application cache and does not automatically eliminate repeated queries across different persistence contexts.
JPQL queries may still execute SQL even when their result entities are already managed. Hibernate can reuse existing entity instances, but query execution and entity materialization are separate concerns.
The first-level cache is an identity map, not a general-purpose query result cache.
Hibernate also supports an optional second-level cache that can share cached entity state across persistence contexts. Unlike the first-level cache, it requires explicit configuration and introduces additional consistency and cache invalidation considerations. It is best evaluated for specific access patterns rather than enabled indiscriminately.
1.2 Entity references
Sometimes an operation needs to establish an association without reading the referenced entity. For example, suppose we want to associate an order with an existing customer. A conventional implementation might load the customer first:
@Transactional
public Order createOrder(UUID customerId) {
Customer customer = customerRepository.findById(customerId)
.orElseThrow();
Order order = new Order();
order.setCustomer(customer);
return orderRepository.save(order);
}
If the application only needs the customer’s identifier, loading the entire entity may be unnecessary.
Spring Data JPA provides getReferenceById():
@Transactional
public Order createOrder(UUID customerId) {
Customer customer = customerRepository.getReferenceById(customerId);
Order order = new Order();
order.setCustomer(customer);
return orderRepository.save(order);
}
The reference can often be used to establish the foreign-key relationship without issuing a SELECT for the customer.
However, a reference does not guarantee that the referenced row exists. Depending on the mapping and when the reference is accessed, Hibernate may defer database access until later.
If the referenced customer does not exist, the operation may fail when the proxy is initialized or when the database enforces the foreign-key constraint.
Entity references are useful when the application already knows the identifier and does not need the referenced entity’s state.
2. Choosing Fetch Strategies
JPA supports two basic association fetch types:
LAZY: The associated data may be loaded when it is accessed.EAGER: The provider must make the associated data available as part of loading the entity.
The defaults depend on the association type.
| Association | JPA default |
|---|---|
@ManyToOne | EAGER |
@OneToOne | EAGER |
@OneToMany | LAZY |
@ManyToMany | LAZY |
@ElementCollection | LAZY |
These defaults are not necessarily appropriate for application workloads.
In particular, eagerly fetching to-one associations can introduce additional database queries even when the application does not need the associated data.
A useful starting point is to declare associations as lazy and select the required fetch plan for each operation.
2.1 Lazy loading
Consider an order with associated items and a customer:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private Instant createdAt;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
}
Loading an order does not necessarily load its customer or items.
Order order = orderRepository.findById(orderId)
.orElseThrow();
The initial query might look like:
SELECT id, customer_id, created_at, status
FROM orders
WHERE id = ?;
Accessing the items can trigger another query:
int itemCount = order.getItems().size();
Conceptually:
SELECT id, order_id, product_id, quantity
FROM order_item
WHERE order_id = ?;
Lazy loading avoids fetching data that the application never accesses. However, it can become expensive when the application accesses the same association across many entities.
2.2 The N+1 query problem
Suppose we load 100 orders and then access the items belonging to each order.
@Transactional(readOnly = true)
public void processOrders() {
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
for (OrderItem item : order.getItems()) {
process(item);
}
}
}
Assuming the collections are not already initialized and batch fetching is not enabled, Hibernate may execute one query to load the orders, followed by one query for each order’s items.
Conceptually:
SELECT id, customer_id, created_at, status
FROM orders;
Then:
SELECT *
FROM order_item
WHERE order_id = ?;
Repeated for each order. The result is 101 queries instead of one or a small number of queries. This is commonly known as the N+1 query problem.
The problem is not lazy loading itself. It is accessing lazy associations repeatedly without considering the resulting query pattern. The appropriate solution depends on how much data the application needs.
3. Fetching Related Data Efficiently
There are several ways to load related entities without triggering an excessive number of queries.
3.1 Join fetching
When the application needs an entity and its associated collection, a fetch join can be appropriate.
With Spring Data JPA:
@Query("""
SELECT o
FROM Order o
LEFT JOIN FETCH o.items
WHERE o.id = :id
""")
Optional<Order> findWithItems(@Param("id") UUID id);
Hibernate can retrieve the order and its items using a single SQL query.
Conceptually:
SELECT o.*, i.*
FROM orders o
LEFT JOIN order_item i ON i.order_id = o.id
WHERE o.id = ?;
This eliminates the additional collection query. However, the SQL result contains one row for each matching item.
An order containing 20 items produces 20 result rows, with the order columns repeated for each row. Hibernate reconstructs the entity graph from these rows.
In Hibernate 6, duplicate root entity references resulting from collection fetch joins are automatically removed from
the Java result list. Adding DISTINCT to the JPQL query is therefore unnecessary for this purpose.
For a small aggregate retrieved by identifier, join fetching is often a reasonable tradeoff. For large result sets, the repeated data can become expensive.
Fetching multiple collections
Fetching multiple collections in a single query can produce a Cartesian product.
Consider an order with two collections:
@OneToMany(mappedBy = "order")
private List<OrderItem> items;
@OneToMany(mappedBy = "order")
private List<Shipment> shipments;
Fetching both collections using joins:
SELECT o.*, i.*, s.*
FROM orders o
LEFT JOIN order_item i ON i.order_id = o.id
LEFT JOIN shipment s ON s.order_id = o.id
WHERE o.id = ?;
An order with 20 items and 10 shipments produces 200 rows, with each item repeated for every shipment.
Hibernate also prevents fetching multiple bag collections simultaneously, throwing a MultipleBagFetchException. A
List without an @OrderColumn is typically mapped as a bag.
Changing the collections to Set may avoid the exception, but it does not eliminate the row multiplication.
For larger collections, separate queries or batch fetching are often more efficient.
Fetch joins and pagination
Collection fetch joins require particular care when combined with pagination.
Consider:
@Query("""
SELECT o
FROM Order o
LEFT JOIN FETCH o.items
ORDER BY o.createdAt DESC
""")
List<Order> findRecentOrders(Pageable pageable);
Applying a SQL LIMIT directly to the joined result could truncate an order’s collection rather than return the
requested number of complete orders. Hibernate may therefore apply pagination in memory instead of limiting the
SQL result. This can cause substantially more data to be loaded than expected.
A useful Hibernate configuration is:
spring:
jpa:
properties:
hibernate:
query:
fail_on_pagination_over_collection_fetch: true
This makes Hibernate fail rather than silently perform in-memory pagination over a collection fetch. For paginated results, consider loading the page of parent identifiers first, followed by a separate query that fetches the required associations.
3.2 Entity graphs
JPA entity graphs provide another way to define which associations should be fetched for a particular operation.
For example:
@EntityGraph(
type = EntityGraph.EntityGraphType.FETCH,
attributePaths = {"customer", "items"}
)
@Query("""
SELECT o
FROM Order o
WHERE o.id = :id
""")
Optional<Order> findDetailedOrder(@Param("id") UUID id);
The entity graph tells Hibernate which associations should be included in the fetch plan. This can be preferable to embedding fetch joins directly in JPQL, particularly when the same query needs different fetch plans.
JPA distinguishes between two entity graph types:
- Fetch graph (
fetchgraph): Attributes included in the graph are treated as eager, while unspecified attributes are treated as lazy, subject to provider behavior. - Load graph (
loadgraph): Attributes included in the graph are treated as eager, while unspecified attributes retain their mapping-defined fetch behavior.
Spring Data JPA supports both through the type attribute of @EntityGraph.
The fetch graph explicitly identifies the associations required for this operation.
However, an entity graph does not guarantee a particular SQL query shape.
Hibernate may use joins or additional queries depending on the mapping and fetch plan.
Entity graphs are a way to control what is fetched, not a guarantee that everything is fetched in a single SQL statement.
3.3 Batch fetching
Join fetching is not always appropriate. Suppose the application loads a page of orders and subsequently needs their items. Rather than issuing one query per order, Hibernate can batch the initialization of lazy associations.
For example:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 32
With batch fetching enabled, Hibernate can load collections belonging to several orders using a query similar to:
SELECT *
FROM order_item
WHERE order_id IN (?, ?, ?, ?);
Instead of issuing 100 separate collection queries, Hibernate may initialize the collections in a few batches. The exact number of queries depends on the batch size, mappings, and access pattern.
Batch fetching is particularly useful when the application accesses lazy associations across a collection of parent
entities. Unlike a collection fetch join, it does not multiply the rows returned by the initial parent query.
Batch fetching can also be configured for individual entities or collections using Hibernate’s @BatchSize annotation.
For example:
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 32)
private List<OrderItem> items = new ArrayList<>();
This allows batch fetching to be configured selectively rather than applying a global default.
3.4 Subselect fetching
Hibernate also supports subselect fetching for collections.
For example:
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();
When one collection is initialized, Hibernate can fetch collections for other relevant parent entities using a secondary query based on the original parent selection.
This can reduce the number of queries without requiring a join fetch. However, subselect fetching may load more collections than the application actually needs. It is therefore most useful when the application typically accesses the same collection across the complete set of retrieved parent entities.
Subselect fetching is a Hibernate-specific feature rather than portable JPA functionality. Its behavior also depends on how the parent entities were loaded and which entities remain associated with the persistence context.
As with other fetch strategies, the generated SQL should be inspected rather than assuming a particular approach will be faster.
4. Avoiding Unnecessary Entity Loading
Not every database read needs to return managed entities. For read-only operations, projections can often provide a simpler and more efficient alternative.
4.1 DTO projections
Suppose an application needs to display a list of orders with their identifiers and creation timestamps.
Loading complete Order entities may be unnecessary.
Instead, define a DTO:
public record OrderSummary(
UUID id,
Instant createdAt
) {}
Then use a constructor expression:
@Query("""
SELECT new com.example.OrderSummary(o.id, o.createdAt)
FROM Order o
WHERE o.status = :status
ORDER BY o.createdAt DESC
""")
List<OrderSummary> findSummaries(
@Param("status") OrderStatus status,
Pageable pageable
);
Hibernate can select only the columns needed to construct the DTO. The returned objects are not managed entities, so Hibernate does not need to track their state for subsequent updates. This can reduce data transfer, entity materialization, and persistence-context overhead. DTO projections are particularly useful for read-only API responses, reporting, and list views.
4.2 Interface projections
Spring Data JPA also supports interface-based projections.
public interface OrderSummaryView {
UUID getId();
Instant getCreatedAt();
}
A repository method can return the projection directly:
List<OrderSummaryView> findByStatus(
OrderStatus status,
Pageable pageable
);
For simple top-level properties, Spring Data JPA can select the required columns.
Interface-based projections typically use proxy objects to expose the selected properties, whereas DTO projections construct concrete objects directly.
For high-volume read operations, the additional proxy overhead may be worth considering, although the difference should be measured rather than assumed to be significant.
Projections involving nested associations may also require joins and can cause more data to be loaded than expected.
For performance-sensitive queries, inspect the generated SQL.
4.3 When to use entities
Projections are not universally better than entities.
Managed entities remain useful when the application needs to:
- Modify persistent state through JPA.
- Navigate associations within a transaction.
- Apply domain behavior involving several mapped properties.
- Benefit from persistence-context identity and dirty checking.
The distinction is primarily about the purpose of the operation. If the application needs a small subset of data for presentation, a projection is often appropriate. If it needs to work with a persistent domain object, loading an entity may be the better choice.
5. Managing the Persistence Context
Fetch strategies are closely related to transaction boundaries and persistence-context lifetime. A lazy association can normally be initialized while the entity remains associated with an open persistence context capable of loading it.
5.1 Lazy initialization failures
Consider:
public Order findOrder(UUID orderId) {
return orderRepository.findById(orderId)
.orElseThrow();
}
With Open Session in View disabled and no surrounding transaction, the persistence context normally closes after the repository operation. The returned entity is then detached.
Accessing an uninitialized collection later can fail:
Order order = orderService.findOrder(orderId);
int count = order.getItems().size();
Hibernate may throw a LazyInitializationException. The problem is not necessarily that the association should have been eager.
It is that the application accessed data outside the persistence context in which it could be loaded.
A better approach is to fetch the required data within a well-defined transactional operation and return a suitable
result.
For example:
@Transactional(readOnly = true)
public OrderDetails findOrderDetails(UUID orderId) {
Order order = orderRepository.findWithItems(orderId)
.orElseThrow();
return new OrderDetails(
order.getId(),
order.getItems().stream()
.map(item -> new OrderItemDetails(
item.getId(),
item.getQuantity()))
.toList()
);
}
The example accesses only properties of OrderItem, which were loaded by the collection fetch join.
The DTO is fully constructed before the transaction ends.
This avoids returning an entity graph whose lazy associations might be accessed later, outside the persistence context.
5.2 Open Session in View
Spring Boot applications using JPA may have Open Session in View enabled.
This keeps the persistence context open during web request processing, allowing lazy associations to be initialized after the service transaction has completed. Although convenient, this can hide database access in controllers, serializers, or view rendering.
For example, serializing an entity graph may trigger additional SQL queries long after the application service has returned. These queries may execute outside the service transaction and make database access patterns difficult to predict.
For applications with explicit transaction boundaries, I recommend disabling Open Session in View:
spring:
jpa:
open-in-view: false
Instead, load the required data inside the service layer and return DTOs or other representations that do not depend on lazy initialization.
This makes database access more predictable and keeps persistence concerns within the application layer.
5.3 Read-only transactions
Spring supports read-only transaction declarations:
@Transactional(readOnly = true)
public List<OrderSummary> findOrders(OrderStatus status) {
return orderRepository.findSummaries(
status,
Pageable.ofSize(100)
);
}
With Hibernate, Spring can configure the session for read-only operation, reducing automatic flushing and dirty-checking overhead for entities loaded within the transaction.
However, readOnly = true should not be treated as a universal database-enforced prohibition against writes.
Its exact behavior depends on the transaction manager, persistence provider, and database configuration.
It is primarily a declaration of intent that can enable optimizations.
6. Inspecting Generated SQL
JPA performance tuning should start with the SQL actually executed.
An operation that looks simple in Java may produce several queries, fetch unnecessary columns, or join more tables than expected.
Before changing mappings, it is useful to understand:
- How many SQL statements are executed?
- Which tables and columns are accessed?
- Are associations loaded through joins or secondary queries?
- Are queries repeated for individual entities?
- How many rows are returned?
- Are appropriate indexes used?
6.1 Hibernate SQL logging
Spring Boot can enable Hibernate SQL logging:
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
The first logger shows SQL statements. The second shows JDBC parameter bindings. Parameter logging can expose sensitive information, so it should be enabled selectively. SQL logging is useful during development, but it does not necessarily provide a complete picture of query execution time or database-side resource consumption.
6.2 Hibernate statistics
Hibernate can collect statistics about entity loading, queries, and persistence-context activity.
For example:
spring:
jpa:
properties:
hibernate:
generate_statistics: true
Statistics can help identify excessive entity loads, collection fetches, and repeated queries.
They introduce some overhead and are best enabled selectively during development, testing, or performance investigations.
Detecting query regressions
Hibernate statistics can also help detect changes in database access patterns during integration testing. For example, a repository operation that previously executed two SQL statements might start executing dozens after a mapping change.
Hibernate’s prepared-statement counter measures JDBC statement preparations rather than the number of distinct SQL queries.
Hibernate exposes statistics through its Statistics API:
SessionFactory sessionFactory =
entityManagerFactory.unwrap(SessionFactory.class);
Statistics statistics = sessionFactory.getStatistics();
statistics.clear();
// Execute the operation being tested.
long statementCount = statistics.getPrepareStatementCount();
For reliable assertions, statistics should be collected in an isolated test context, since the counters are maintained
at the SessionFactory level.
The objective is not necessarily to enforce a single query for every operation, but to catch unexpected increases in database activity.
6.3 PostgreSQL execution plans
Once an expensive SQL statement has been identified, PostgreSQL’s EXPLAIN can help determine why it is expensive.
For example:
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.created_at
FROM orders o
WHERE o.status = 'PENDING'
ORDER BY o.created_at DESC
LIMIT 100;
The execution plan shows how PostgreSQL accesses the data, including scans, joins, sorting, and estimated versus actual
row counts. ANALYZE executes the query, so it should be used carefully with statements that modify data.
For queries generated by Hibernate, it is often useful to copy the SQL and parameter values into a database client and inspect the plan directly. The important point is that optimizing entity mappings and optimizing SQL are not separate activities.
Hibernate determines the SQL shape, while PostgreSQL determines how that SQL is executed.
7. Choosing the Right Read Pattern
There is no single fetch strategy that works best for every operation. The appropriate choice depends on how much data is needed, how many entities are retrieved, and whether the application needs managed state.
| Pattern | Appropriate use case | Main consideration |
|---|---|---|
| Lazy loading | Associations that are not always needed | Can produce N+1 queries |
| Fetch join | Small aggregates retrieved together | Row multiplication and pagination |
| Entity graph | Operation-specific fetch plans | Does not guarantee a single query |
| Batch fetching | Collections accessed across multiple parents | Requires tuning and additional queries |
| Subselect fetching | Collections accessed across a parent result set | May fetch unnecessary collections |
| DTO projection | Read-only queries needing selected fields | Does not return managed entities |
| Entity reference | Establishing associations by identifier | Does not validate existence immediately |
| Ordinary entity loading | Operations requiring managed entity state | Persistence-context and fetch overhead |
A few principles are worth keeping in mind:
- Prefer lazy associations as a starting point. Fetch related data deliberately based on the needs of each operation.
- Avoid N+1 queries. Use fetch joins, entity graphs, batch fetching, or projections where appropriate.
- Avoid over-fetching. Loading large entity graphs or joining multiple collections can be as expensive as executing too many small queries.
- Use projections for read-only results. Managed entities are not always necessary.
- Keep database access within clear boundaries. Avoid relying on Open Session in View to initialize associations during response processing.
- Inspect the generated SQL. The actual database workload matters more than the apparent simplicity of the repository method.
Conclusion
JPA provides a convenient programming model, but efficient database reads require an understanding of how Hibernate translates entity mappings and application access patterns into SQL.
Lazy loading, fetch joins, entity graphs, batch fetching, and projections each solve different problems. Choosing between them requires balancing the number of queries against the amount of data retrieved and the overhead of managing entities.
The goal is not to minimize the number of SQL statements at any cost. It is to retrieve the data the application actually needs with predictable database access patterns and reasonable resource consumption.
In the next article, we’ll turn to write throughput and examine JDBC batching, identifier generation, and bulk inserts with Spring Data and PostgreSQL.
 on [Unsplash](https://unsplash.com/photos/modern-library-interior-with-white-shelves-and-stairs-wIVNGVvo_is?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)](https://blog.cloudneutral.se/read-patterns-with-spring-data/cover.webp)