IntelliJ IDEA
IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin
Hibernate 7.4 New Features
Hibernate 7.4 introduced several improvements that simplify loading a page of data along with their associated child collection, historical data access, and audit logging.
The article will focus on the following features:
- Limits and Fetch Joins: How Hibernate 7.4 improves working with paginated queries that include fetched associations.
- History and Audit Tables: How the new capabilities support querying entity state across time and working with historical data.
You can check out the sample code for this article in this GitHub repository.
Limits and Fetch Joins
One common requirement in data-driven applications is loading a page of parent entities along with an associated child entity collection. For example, suppose an application has an Order entity with a Set<OrderItem> collection, and we want to load the first few orders together with their order items.
List<Order> orders = session
.createSelectionQuery(
"select o from Order o join fetch o.items order by o.id",
Order.class
)
.setMaxResults(10)
.getResultList();
In Hibernate versions before 7.4, applying a limit to a query that used a collection fetch join could not be safely pushed down to the database. Because each Order may have multiple OrderItem rows, limiting the SQL result directly could cut off part of an order’s item collection. To avoid returning incomplete collections, Hibernate loaded all matching rows from the database and applied pagination in memory at the application layer.
That behavior was correct, but it could be expensive. A query intended to load only 10 orders might still read many more rows if the table contained a large number of orders and order items.
Before Hibernate 7.4, the generated SQL would look like the following:
select
o1_0.id, i1_0.order_id, i1_0.id, i1_0.product_code,
i1_0.quantity, o1_0.order_number, o1_0.status
from
orders o1_0
join
order_items i1_0
on o1_0.id=i1_0.order_id
As you can see, the limit(pagination) is not applied at the SQL query level. So, it will load all the orders and their associated order_items, which could be a very expensive operation and may result in OutOfMemoryException.
You can see a WARNING logged by Hibernate as follows:
[WARN] HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
One option to prevent Hibernate performing pagination in memory is by setting the following property:
hibernate.query.fail_on_pagination_over_collection_fetch=true
By configuring this property, Hibernate throws an exception instead of performing pagination in memory.
Hibernate 7.4 fixes this problem by using nested queries. Instead of applying the limit directly to the joined result set, Hibernate first determines the limited set of parent entity identifiers and then fetches the associated collection for only those parent rows.
This allows pagination to happen in the database while still returning complete items collections for each selected Order.
With Hibernate 7.4, the SQL will be generated as follows:
select
o1_0.id, i1_0.order_id, i1_0.id, i1_0.product_code,
i1_0.quantity, o1_0.order_number,o1_0.status
from
(select
o1_0.id, o1_0.order_number, o1_0.status
from
orders o1_0
where
exists(select
1 from order_items i1_0
where
o1_0.id=i1_0.order_id)
offset
? rows
fetch
first ? rows only) o1_0(id, order_number, status)
join
order_items i1_0
on o1_0.id=i1_0.order_id
This improvement makes fetch joins more practical for paginated screens, such as an order listing page that displays each order with its line items, without forcing the application to load the full result set first.
History and Audit Tables
Hibernate 7.4 adds built-in support for temporal history tables and audit tables. Both features help track changes to entity data, but they serve slightly different use cases: history tables let us query the state of an entity at a point in time, while audit tables record the sequence of changes that happened to an entity.
Consider the following Product entity:
@Entity
@Table(name = "products")
class Product {
//fields id, code, name, price
}
History Tables
To enable temporal history for Product, annotate the entity with @Temporal and optionally specify the history table name using @Temporal.HistoryTable.
@Entity
@Table(name = "products")
@Temporal
@Temporal.HistoryTable(name="products_history")
class Product {
//fields id, code, name, price
}
With this mapping, Hibernate stores previous versions of product rows in the products_history table. The table includes the entity columns plus two temporal columns: effective, which marks when a version became active, and superseded, which marks when that version was replaced.
products_history table:
| id | code | name | price | effective | superseded |
|---|---|---|---|---|---|
| 2251 | P1000 | Product-1000 | 40.00 | 2026-05-15 08:21:39.949001 +00:00 | null |
| 2301 | P1001 | Product-1001 | 90.00 | 2026-05-15 08:22:24.765883 +00:00 | 2026-05-15 08:22:24.778067 +00:00 |
| 2301 | P1001 | Product-1001 | 100.00 | 2026-05-15 08:22:24.778067 +00:00 | null |
We can get the Product entity data at a given point of time as follows:
Instant someTime = ...
try (var session = sessionFactory.withOptions().asOf(someTime).open()) {
var product = session.find(Product.class, productId);
}
This makes temporal queries feel like normal entity lookups while Hibernate resolves the correct historical row behind the scenes.
Hibernate offers several different strategies(NATIVE, SINGLE_TABLE, HISTORY_TABLE) for mapping temporal entities. For more info check out the Temporal data section.
Audit Tables
Previously, Hibernate-based applications typically used the separate Hibernate Envers library for auditing entity changes. Hibernate 7.4 brings audit table support into Hibernate ORM itself, so applications can use auditing features natively without adding Envers for this use case.
Audit support is enabled by adding @Audited and can be mapped to a custom table using @Audited.Table.
@Entity
@Table(name = "products")
@Audited
@Audited.Table(name="products_aud_log")
class Product {
//fields id, code, name, price
}
When auditing is enabled, Hibernate writes one row per change into the audit table. Unlike the history table, the audit table focuses on recording what operation happened and when.
| id | code | name | price | rev | revtype |
|---|---|---|---|---|---|
| 2001 | P1002 | Product-1002 | 90.00 | 2026-05-13 14:58:17.505775 +00:00 | 0 |
| 2001 | P1002 | Product-1002 | 100.00 | 2026-05-13 14:58:17.518194 +00:00 | 1 |
The rev values are the timestamps at which the change happened. The revtype values are represented using ModificationType enum as follows:
public enum ModificationType {
/**
* Creation, encoded as 0
*/
ADD,
/**
* Modification, encoded as 1
*/
MOD,
/**
* Deletion, encoded as 2
*/
DEL
}
For more info check out the Audit logs section.
Summary
Most of the applications use pagination to show a list of resources, and we used to write custom logic to load paginated data along with the associated child collection. Now this is being handled at the framework level itself. Also, we used to rely on external libraries like Envers to implement auditing, which is now provided by Hibernate itself.
Hibernate 7.4 brings practical improvements that address real problems in JPA/ Hibernate-based applications. Whether we are optimizing pagination query behavior or tracking historical data, Hibernate 7.4 reduces the amount of custom infrastructure needed and provides better support out of the box without requiring additional libraries.
Go ahead and explore these new features using this GitHub repository.
