After understanding the architecture, eliminating performance killers and taming dangerous config defaults in this series, today we come to perhaps the most important question: How do I actually get there?
Migrating an existing application to Hibernate 7 isn’t rocket science - but it’s not a two-line diff either. The bad news: There are breaking changes. The good news: Most of them follow a clear pattern, and anyone who has read the previous articles in this series already knows the thinking behind them.
In this article we will proceed systematically. No blind hope for an upgrade, no “let’s see what the compiler says”. Instead: a structured path from the old code base to the new one - with all the pitfalls you will encounter along the way.
What has actually changed in Hibernate 7?
Before you even touch a dependency, it’s worth taking a quick look at the big picture. Hibernate 7 isn’t an incremental update - it’s a generational update. Three things have changed fundamentally:
Java 17 is now mandatory. No opt-in, no workaround. If you’re still on Java 11, this is your first step - before Hibernate.
Jakarta Persistence 3.2. This is the biggest intervention in the type system. The Criteria API, the Entity Graph API, many central interfaces - they all got new type parameters or were rebuilt because JPA 3.2 standardized things that Hibernate had previously solved in its own extensions. This means: bytecode incompatibilities, i.e. things that compile but crash at runtime.
Cleaning up across the board. Hibernate 7 has consistently removed what had been marked as deprecated for years. Session#save, Session#update, Session#delete, various annotations like @Where, @Proxy, @LazyCollection – gone. If you wanted to “quickly use it”: time is up.
That sounds like a lot. It is too. But the majority of changes follow a pattern: Hibernate native API → JPA standard. When you know what flies, you immediately know what to replace it with.
💡 The official Hibernate 7 Migration Guide is your best friend with this upgrade. Bookmark, leave tab open.
Step 1: Dependency update and first compile
Let’s start with the obvious. In your pom.xml you swap the Hibernate version:
<!-- Vorher -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>6.6.x.Final</version>
</dependency>
<!-- Nachher -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>7.0.x.Final</version>
</dependency>
Gradle users swap the version in their build.gradle accordingly. Anyone who uses the Bytecode Enhancement Plugin also has a nasty surprise: The Maven plugin has got a new groupId and artifactId in Hibernate 7:
<!-- Vorher -->
<plugin>
<groupId>org.hibernate.orm.tooling</groupId>
<artifactId>hibernate-enhance-maven-plugin</artifactId>
...
</plugin>
<!-- Nachher -->
<plugin>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-maven-plugin</artifactId>
<version>7.0.x.Final</version>
<executions>
<execution>
<configuration>
<enableLazyInitialization>true</enableLazyInitialization>
<enableDirtyTracking>true</enableDirtyTracking>
</configuration>
<goals>
<goal>enhance</goal>
</goals>
</execution>
</executions>
</plugin>
Now: Start build and read the error list. Don’t be alarmed - this is not a sign that something is wrong. It is your personal migration backlog, automatically generated by the compiler.
Roughly sort the errors into two categories:
- Removed APIs – Methods and classes that no longer exist (
Session#save,@Where, etc.) - Type mismatches - Signatures changed by Jakarta Persistence 3.2
Process the Removed APIs first. These are usually mechanical fixes – swapping one method name for another. The type conflicts sometimes need a little more thought.
⚠️ Attention, connection pools: Do you have Vibur, Proxool or UCP as a connection pool in the dependency? These are no longer included in Hibernate 7. Switch to HikariCP or Agroal - both are production proven and better tested.
Step 2: Resolve deprecated APIs
These are the most common compile errors you’ll encounter - and their direct solutions.
Session methods: The old classics are gone
That’s the biggest chunk. Hibernate has consistently switched to the JPA standard:
// VORHER
session.save(entity);
session.update(entity);
session.saveOrUpdate(entity);
session.delete(entity);
entity = session.load(Product.class, id);
// NACHHER
session.persist(entity); // war transient → wird managed
session.merge(entity); // war detached → wieder managed
session.persist(entity); // transient → persist
session.merge(entity); // detached → merge
session.remove(entity);
entity = session.getReference(Product.class, id);
The semantics of persist vs. merge are not identical to the old save/update. If you are unsure whether your entity is transient or detached: If in doubt, merge - it handles both cases correctly.
Annotations: The big spring cleaning
Hibernate has removed a number of its own annotations in favor of JPA standards or newer alternatives:
// VORHER
@Where(clause = "deleted = false")
@OneToMany
private List<Order> orders;
@OrderBy("name ASC") // Hibernate-eigene @OrderBy
private List<Tag> tags;
@LazyCollection(LazyCollectionOption.EXTRA)
private Set<Child> children;
// NACHHER
@SQLRestriction("deleted = false")
@OneToMany
private List<Order> orders;
@SQLOrder("name ASC") // oder JPA @OrderBy
private List<Tag> tags;
// @LazyCollection ersatzlos entfernt – Hibernate entscheidet selbst
private Set<Child> children;
And two more common pitfalls:
// VORHER
@Proxy(lazy = false)
@Entity
public class Product { ... }
@Target(ProductImpl.class)
@Embedded
private Product product;
// NACHHER
// @Proxy entfernt → FetchType.EAGER oder @ConcreteProxy verwenden
@Entity
public class Product { ... }
@TargetEmbeddable(ProductImpl.class)
@Embedded
private Product product;
LockOptions: Leaner through direct API
// VORHER
Book book = session.find(
Book.class,
1,
new LockOptions(LockMode.PESSIMISTIC_WRITE, 0)
);
// NACHHER
Book book = session.find(
Book.class,
1,
LockMode.PESSIMISTIC_WRITE,
Timeouts.NO_WAIT
);
💡 Tip for large codebases: Search globally for the method names (
session.save(,@Where(,@LazyCollection) before you start replacing. This will give you a realistic estimate of the effort - and you can set priorities.
Step 3: The Config Trap
Anyone who has read the Config article in this series knows: Hibernate defaults are not always your friend. The same applies to migration - only this time some of the properties you relied on have either disappeared or have new default behavior.
Properties that no longer exist
# VORHER – hat Hibernate erlaubt, detached Entities zu refreshen
hibernate.allow_refresh_detached_entity=true
# NACHHER – gibt es nicht mehr. Hibernate wirft jetzt immer eine
# IllegalArgumentException wenn du eine detached Entity refresht.
# Fix: Entity vorher mit merge() re-attachen.
Properties with new default behavior
This is the more dangerous category - no compile error, no runtime error at startup. Something different happens than before:
# Immutable Entities: Vorher gab es nur eine Warning, jetzt eine Exception
# Wenn du UPDATE-Queries auf @Immutable-Entities fährst, bricht es jetzt.
# Temporärer Workaround während der Migration:
hibernate.query.immutable_entity_update_query_handling_mode=allow
# Native Queries: Vorher kamen java.sql-Typen (Date, Timestamp) zurück,
# jetzt kommen java.time-Typen (LocalDate, LocalDateTime).
# Wenn du noch auf java.sql-Typen castest:
hibernate.query.native.prefer_jdbc_datetime_types=true
# XML-Spalten (PostgreSQL, Oracle): Format hat sich geändert.
# Bestehende Daten müssen migriert oder Kompatibilitätsmodus aktiviert werden:
hibernate.type.xml_format_mapper.legacy_format=true
# Array-Mapping (MySQL/MariaDB): War VARBINARY, ist jetzt JSON
# Bestehende Daten müssen migriert werden, oder:
hibernate.type.preferred_array_jdbc_type=VARBINARY
⚠️ These properties are emergency brakes, not a permanent solution. Use them to avoid blocking the migration - but plan to take them out again.
StatelessSession: Two silent behavior changes
If you use StatelessSession for batch processing, be careful:
// NEU: StatelessSession nutzt jetzt den Second-Level Cache.
// Wenn du das NICHT willst (z.B. bei Bulk-Imports):
statelessSession.setCacheMode(CacheMode.IGNORE);
// NEU: hibernate.jdbc.batch_size hat keinen Effekt mehr auf StatelessSession.
// Stattdessen: explizites Batching nutzen
statelessSession.insertMultiple(entities);
statelessSession.updateMultiple(entities);
statelessSession.deleteMultiple(entities);
Entity Discovery: New module required
If your application is based on automatic classpath scanning for entities (i.e. without explicit listing in persistence.xml), you need an additional module:
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-scan-jandex</artifactId>
<version>7.0.x.Final</version>
</dependency>
Without this module, your entities simply will not be found. This is also a silent error - not a compile problem, but table mappings are suddenly missing at runtime.
Step 4: Testing as a safety net
You’ve changed the APIs and cleaned up the config - but how do you know that your application is still doing what it’s supposed to do? This is exactly where structured migration separates itself from blind optimism.
What you need to test
Hibernate 7 doesn’t just change method names, it also changes behavior. These are the three areas your tests need to cover:
1. Entity mapping and validations
Hibernate 7 is significantly stricter on incorrect annotation combinations. Errors that were previously silently ignored now throw exceptions at startup. Your test context start is your first indicator:
@Test
void applicationContextLoads() {
// Wenn dieser Test fehlschlägt, hat Hibernate ein Mapping-Problem entdeckt.
// Fehlermeldung lesen – sie ist jetzt deutlich präziser als früher.
}
Typical errors that appear here:
- Multiple
PersistentAttributeTypeannotations on one field (@Basic+@ManyToOne) - Annotations in the wrong place (getter instead of field in field access)
AttributeConverteron incompatible fields (@Id,@Version,@Enumerated)
2. Queries – besonders Native Queries
The new default behavior for native queries (returning java.time instead of java.sql) is immediately noticeable in the test:
// VORHER – hat funktioniert
List<Object[]> results = session.createNativeQuery(
"SELECT created_at FROM orders WHERE id = :id"
).setParameter("id", 1L).getResultList();
Timestamp createdAt = (Timestamp) results.get(0)[0]; // java.sql.Timestamp
// NACHHER – ClassCastException zur Laufzeit
// Fix: Expliziter Cast auf java.time oder Property aktivieren
LocalDateTime createdAt = (LocalDateTime) results.get(0)[0]; // java.time
3. Cascade behavior
The changes around CascadeType.SAVE_UPDATE and detached entities are the most treacherous - they don’t show up at compile, not at startup, but only at flush:
@Test
void addingDetachedChildShouldRequireMergeFirst() {
Child detachedChild = getDetachedChild();
Parent parent = session.find(Parent.class, parentId);
// VORHER: hat funktioniert
// NACHHER: EntityExistsException beim flush()
// parent.addChild(detachedChild); ← das bricht jetzt
// Korrekt:
Child managedChild = session.merge(detachedChild);
parent.addChild(managedChild);
session.flush(); // muss durch
}
In-Memory DB oder Testcontainers?
Short answer: Both, with a clear division of tasks.
| H2 / In-Memory | test container | |
|---|---|---|
| For what | Mapping, basic queries, cascade logic | DDL changes, DB-specific behavior |
| Speed | Very fast | Slow (container startup) |
| When necessary | Always | For Oracle/MySQL-specific changes (float DDL, array mapping) |
You cannot test the DDL changes in particular - such as the new binary_float/binary_double on Oracle or the changed array mapping on MySQL - with H2. You need the real database type.
💡 Strategy: Run your existing test suite first before you start changing code. The errors you see are your migration list. Then work through them and watch the red bars turn green.
The most common pitfalls
⚠️
@Idand@MapsIdno longer cascade automaticallyPreviously, Hibernate automatically enabled
cascade=PERSISTfor fields with@Idor@MapsId. Not anymore. If your entities relied on it, you have to add it explicitly - otherwise data will silently disappear during persistence.@MapsId @ManyToOne(cascade = CascadeType.PERSIST) // now explicitly required private user user;
⚠️ Criteria API: Implicit Treats are history
Addressing subtype attributes directly via the Criteria API used to be quietly allowed. Now Hibernate throws an exception. You have to explicitly downcast:
// BEFORE – worked, but was against spec root.get("subTypeAttribute"); // AFTER cb.treat(root, SubType.class).get("subTypeAttribute");
⚠️
isXxx()getters prohibited as property namesThe JavaBean pattern
isDefault()as a property declaration was previously questionable but allowed. Hibernate 7 now firmly denies this. Rename togetDefault()or map the field directly.
⚠️
GenerationType.SEQUENCEwithout@SequenceGenerator- now an errorHibernate used to be generous with the combination
@GeneratedValue(strategy = GenerationType.SEQUENCE)without a matching@SequenceGenerator. Not anymore. Either annotate explicitly or switch toGenerationType.AUTO.
⚠️
ValidationMode.AUTOnow failsIf the
ValidatorFactorycreation throws an exception, it used to be swallowed. Now Hibernate propagates them. If your Bean Validation configuration was quietly broken, you now know it.
⚠️
@OrderColumnin unowned@OneToMany– less SQL, different behaviorHibernate used to generate superfluous
UPDATEstatements to set the order column. This now only happens if the column is not also mapped as a field. Anyone who was implicitly dependent on this: Check whether the order is still correct.
Conclusion
We have reached the home stretch.
In this series, we examined Hibernate 7 from all sides: the architecture, the performance pitfalls, the dangerous config defaults - and today the way to get there. Migration is not a drama if you know what to expect. And now you know.
The most important takeaways for your upgrade path:
- Read first, then touch. The Official Migration Guide is complete and accurate - use it.
- The compiler is your first ally. Let him talk before you start fixing.
- Config changes are the most treacherous. No compile error, no stack trace - just silent misbehavior. Go through the properties consciously.
- Let tests turn red first, then green. That’s not a metaphor, that’s the plan.
Hibernate 7 isn’t an upgrade you put off. Java 17 Baseline, Apache License, consistent JPA alignment – this is the platform for the next few years. Anyone who migrates now pays the price once. Anyone who waits will pay with interest.
That was the Hibernate 7 series. Thanks for reading – and good luck with the upgrade. 🚀
![[EN] Hibernate 7 Migration: How to migrate your application without drama](/images/Hibernate-7-Migration_BlogHeader.jpg)