Hibernate 7.4: What's new for Spring Boot developers
Hibernate 7.4 introduces temporal entities, auditing, and safer pagination—key upgrades for Spring Data JPA developers.
Hibernate 7.4 introduces some powerful improvements that can help our daily development activity.
First, it introduces (still incubating features meaning that it could change) ambitious new features around temporal and audited data. These features move historical data management closer to Hibernate ORM itself.
Second, it improves one common Hibernate pain point: using pagination together with collection join fetch.
Note: This article focuses on features that are useful for Spring Boot and Spring Data JPA developers. I skip some features, for the full list, read the release note please.
Spring Boot 4.1 includes Hibernate 7.4
For Spring developers, one important detail is that Hibernate 7.4 is not only a standalone Hibernate release. It is also included in Spring Boot 4.1's managed dependency set.
Spring Boot 4.1 manages org.hibernate.orm:hibernate-core version 7.4.1.Final. It also manages related Hibernate modules such as hibernate-envers with the same version.
This means that if your application uses Spring Boot 4.1 with spring-boot-starter-data-jpa, Hibernate 7.4 is the Hibernate version provided by Boot's dependency management.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
flowchart TD
A[Spring Boot 4.1] --> B[spring-boot-starter-data-jpa]
B --> C[Spring Data JPA repositories]
C --> D[Hibernate 7.4]
D --> E[(Database: H2, PostgreSQL, MySQL, etc.)]
C --> F[Standard CRUD methods]
C --> G[Custom repository methods]
G --> H[Hibernate SessionFactory]
H --> I[Temporal or audit-specific queries]
This matters because the Hibernate improvements are directly relevant for Spring Data JPA applications running on Spring Boot 4.1. Repository queries, fetch joins, entity mappings, and Hibernate specific annotations all run on top of the Hibernate version selected by Spring Boot.
However, not every Hibernate feature becomes a Spring Data feature automatically. For example, the fetch join pagination improvement can benefit Spring Data repository queries naturally, but temporal "as-of" queries still require Hibernate specific APIs, usually hidden behind a custom repository implementation or service.
Note: For the users still in Spring Boot 3.5: you are using Hibernate 6.6.x. The mentioned changes will apply when you upgrade your Spring version.
1. Temporal entities: querying data as it existed in the past
Hibernate 7.4 introduces a new org.hibernate.annotations.Temporal annotation for temporal entity state.
This is not the old JPA jakarta.persistence.Temporal annotation used for Date and Calendar fields. This new annotation is about historical entity state.
A temporal entity keeps track of how its state changes over time. That means the application can query the entity "as of" a specific instant.
For example, imagine a Contract entity:
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import org.hibernate.annotations.Temporal;
import java.math.BigDecimal;
@Temporal
@Entity
public class Contract {
@Id
private Long id;
private String customerName;
private BigDecimal price;
// getters and setters
}
In a normal table, when the price changes, the old value is lost unless we manually store it somewhere else.
With temporal data, we can think in terms of validity:
id | customer_name | price | row_start | row_end
---+---------------+-------+---------------------+---------------------
1 | Acme | 1000 | 2025-01-01 10:00:00 | 2025-06-01 09:00:00
1 | Acme | 1200 | 2025-06-01 09:00:00 | null
The current row is the one where row_end is null. Older rows represent previous versions.
timeline
title Contract price over time
2025-01-01 : price = 1000
2025-03-01 : as-of query returns 1000
2025-06-01 : price changes to 1200
2025-07-01 : as-of query returns 1200
The interesting part is the query model. Instead of manually joining history tables, the application can open a temporal session and ask for the entity state at a specific point in time:
Instant instant = Instant.parse("2025-03-01T00:00:00Z");
try (var session = sessionFactory
.withOptions()
.asOf(instant)
.openSession()) {
Contract contract = session.find(Contract.class, 1L);
System.out.println(contract.getPrice());
}
This kind of query answers questions like:
What did this contract look like on March 1st, 2025?
That is very different from storing createdAt and updatedAt columns. Those columns tell us when the row was created or modified, but they do not preserve the previous state.
flowchart LR
subgraph Normal["Normal table"]
N1["id = 1<br/>price = 1200"]
N2["Old value is gone"]
N1 --> N2
end
subgraph Temporal["Temporal table"]
T1["id = 1<br/>price = 1000<br/>Jan 1 → Jun 1"]
T2["id = 1<br/>price = 1200<br/>Jun 1 → now"]
T1 --> T2
end
Hibernate 7.4 supports temporal data using different storage strategies, including database-native temporal tables when the database supports them, a single table with effectivity columns, or a separate table for historical data.
The feature is introduced as incubating, so the API may still evolve in future Hibernate versions. This does not mean it is unavailable for production, but you should evaluate it carefully before adopting it in business critical code.
2. Core-level audited entities
Hibernate users may already know @Audited from Hibernate Envers:
import org.hibernate.envers.Audited;
That annotation has existed for years in the Envers module. Envers is the classic Hibernate solution for auditing and versioning entities.
Hibernate 7.4 introduces a new annotation with the same simple name, but in a different package:
import org.hibernate.annotations.Audited;
Example:
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import org.hibernate.annotations.Audited;
@Audited
@Entity
public class Customer {
@Id
private Long id;
private String name;
private String status;
// getters and setters
}
This new @Audited is part of the Core module. It is designed for entities whose modifications are tracked in a separate audit log.
So auditing itself is not new in the Hibernate ecosystem. What is new is that Hibernate 7.4 moves this concept into the new Core state-management model.
To explain the difference
// Classic Envers auditing
import org.hibernate.envers.Audited;
// New Hibernate 7.4 auditing
import org.hibernate.annotations.Audited;
3. Temporal versus audited: what is the difference?
Temporal and audited data are related, but they are not the same thing.
Temporal data answers:
What was the state of this entity at a specific point in time?
Audited data answers:
What changed, when did it change, and what kind of change was it?
For example, suppose a customer's status changes from PENDING to ACTIVE.
A temporal model is useful when we want to query:
What was the customer's status on 2025-04-01?
An audit model is useful when we want to query:
Who changed the customer's status?
Was it a creation, update, or deletion?
When did the change happen?
In simple terms:
| Feature | Main goal |
|---|---|
@Temporal |
Query historical state as of a point in time |
@Audited |
Track modifications in an audit log |
| Spring Data JPA auditing | Store metadata like created date and last modified date |
| Envers | Classic Hibernate audit/versioning module |
This distinction is important because many developers already use Spring Data JPA auditing:
@CreatedDate
private Instant createdDate;
@LastModifiedDate
private Instant lastModifiedDate;
That is useful, but it is not the same as historical state management. It tells us when the current row was created or modified. It does not automatically let us reconstruct the full previous state of the entity.
Capabilities summary
| Feature | Stores metadata | Stores history | Query past state |
|---|---|---|---|
| Spring Data JPA auditing | Yes | No | No |
| Hibernate Envers | Yes | Yes | Yes |
Hibernate 7.4 @Audited |
Yes | Yes | Yes |
Hibernate 7.4 @Temporal |
No/change-focused | Yes | Yes |
4. Safer pagination with collection join fetch
This is a very practical improvement in the release 7.4.
For years, combining pagination with a collection join fetch was dangerous.
Imagine this query:
select p
from Post p
left join fetch p.comments
order by p.createdAt desc
And then:
query.setMaxResults(20);
At first glance, this looks like a normal query:
Give me the latest 20 posts with their comments.
But a collection join produces multiple SQL rows for the same parent entity:
Post 1 | Comment A
Post 1 | Comment B
Post 1 | Comment C
Post 2 | Comment D
Post 3 | Comment E
flowchart TD
A["Java code asks for 20 posts"] --> B["HQL: Post left join fetch comments"]
B --> C["SQL result rows are multiplied"]
C --> D["Post 1 + Comment A"]
C --> E["Post 1 + Comment B"]
C --> F["Post 1 + Comment C"]
C --> G["Post 2 + Comment D"]
C --> H["Post 3 + Comment E"]
D --> I["LIMIT on joined rows is not the same as LIMIT on parent posts"]
E --> I
F --> I
G --> I
H --> I
If the database applies LIMIT 20 directly to this result, it may limit the joined rows, not the parent posts. That can produce incomplete collections or incorrect results.
Because of this, older Hibernate versions often had to apply pagination in memory when collection fetch joins were involved. This was one of those classic Hibernate performance traps: the code looked fine, but production logs could reveal that Hibernate was loading too much data.
Hibernate 7.4 improves this. It is now possible to safely combine an HQL limit, setMaxResults(), scrolling, or streaming with a collection join fetch on databases that support limits and offsets in subqueries.
Example:
List<Post> posts = entityManager.createQuery("""
select p
from Post p
left join fetch p.comments
order by p.createdAt desc
""", Post.class)
.setMaxResults(20)
.getResultList();
This is a big deal because many applications have queries like this in REST APIs:
@GetMapping("/posts")
public List<PostDto> latestPosts() {
return postRepository.findLatestPostsWithComments(PageRequest.of(0, 20));
}
In my opinion, this is the best day-to-day improvement in the release. Safer pagination with fetch joins can help a much larger number of existing applications.
5. CacheMode.REFRESH_SESSION
Hibernate also adds a new cache mode:
CacheMode.REFRESH_SESSION
This mode is useful when the persistence context may already contain stale entity instances and we want query results to refresh the state of those managed entities.
Normally, the first-level cache returns the already-managed entity instance. That is usually what we want, but sometimes another transaction or external process may have changed the database, and we want to refresh what is already in the current session.
An example:
SelectionQuery<Customer> query = session.createSelectionQuery("""
from Customer c
where c.status = :status
""", Customer.class);
query.setParameter("status", "ACTIVE");
query.setCacheMode(CacheMode.REFRESH_SESSION);
List<Customer> customers = query.getResultList();
6. Reverse engineering is now integrated into Hibernate ORM
Hibernate 7.4 also integrates reverse engineering capabilities that were previously part of Hibernate Tools.
This is useful for database-first projects, where the database schema already exists and we want to generate Hibernate artifacts from it.
The release notes mention support for Gradle, Maven, and Ant plugins. These plugins can generate Java entity classes, DAO classes, mapping files, hibernate.cfg.xml, DDL scripts, and HTML documentation.
7. Better support for @Any and @ManyToAny
Hibernate-specific polymorphic associations also get improvements.
@Any and @ManyToAny mappings can now be used more naturally in joins, treated joins, and join fetching. The semantics are now aligned with @ManyToOne and @ManyToMany, respectively. Join fetching can be requested using HQL join fetch or an EntityGraph.
A simplified @Any association looks like this conceptually:
@Any
private Object target;
This kind of mapping is useful when one association can point to different entity types. It is not something most applications should use casually, but when you need it, better query support is welcome.
This release also adds cascade to @Any and @ManyToAny, replacing the deprecated Hibernate-specific @Cascade usage.
8. @OneToMany with @JoinFormula
Hibernate now supports combining @OneToMany with @JoinFormula.
This is useful for advanced mappings where the relationship is not based on a simple foreign key column, but on a SQL expression.
For example, a simplified mapping might look like this:
@OneToMany
@JoinFormula("(select ...)")
private List<Order> recentOrders;
This is a niche feature, but it helps remove workarounds in advanced domain models.
9. Database-generated columns inside composite primary keys
Release 7.4 improves support for database-generated columns inside composite primary keys.
This is not a feature every application will need. Many modern applications use a simple generated primary key:
@Id
@GeneratedValue
private Long id;
But many database-first systems use composite primary keys.
For example, imagine an order_line table:
CREATE TABLE order_line (
order_id BIGINT NOT NULL,
line_number BIGINT GENERATED BY DEFAULT AS IDENTITY,
product_name VARCHAR(255),
quantity INTEGER,
PRIMARY KEY (order_id, line_number)
);
Here the primary key is made of two columns:
(order_id, line_number)
The application knows the order_id, but the database generates the line_number.
flowchart LR
A["Composite primary key"] --> B["order_id<br/>assigned by application"]
A --> C["line_number<br/>generated by database"]
D["Hibernate insert"] --> E["Database generates line_number"]
E --> F["Hibernate reads generated key part"]
F --> G["Composite identifier is completed"]
G --> H["Entity remains managed"]
Conceptually, the Java identifier could look like this:
@Embeddable
public class OrderLineId {
private Long orderId;
private Long lineNumber;
// constructors, getters, setters, equals, hashCode
}
And the entity could use that composite identifier:
@Entity
public class OrderLine {
@EmbeddedId
private OrderLineId id;
private String productName;
private Integer quantity;
}
The difficult part is that Hibernate has to insert the row, retrieve the generated part of the primary key, update the composite identifier, and keep the entity correctly managed in the persistence context.
With 7.4 it improves this scenario by allowing a database generated column, such as an identity column, to be part of a composite primary key.
This matters especially for existing schemas where changing the primary key design is not easy. Before this improvement, teams often needed workarounds such as generating the value manually in the application, using custom insert logic, changing the schema, or adding a surrogate key only to make the ORM mapping easier.
@Entity
@Table(name = "order_line")
@IdClass(OrderLineId.class)
public class OrderLine {
@Id
private Long orderId;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long lineNumber;
// rest of the implementation
}
Sources
Notes
- Hibernate 7.4 release date: May 26, 2026