Hibernate 6 Migration: What Changed and How to Survive It
Migrate from Hibernate 5 to 6 with confidence.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
- ✓Java 17 or later
- ✓Familiarity with Hibernate 5 and JPA annotations
- ✓Existing Hibernate 5 project to migrate (or willingness to learn from examples)
- ✓Maven or Gradle build tool
- Hibernate 6 introduces type-safe query APIs with JPA 3.0, but breaks many existing Hibernate 5 mappings.
- The biggest pain points: removal of deprecated Hibernate types (e.g.,
org.hibernate.type), changes to identifier generation, and new bytecode enhancement requirements. - You must upgrade to Jakarta EE (javax -> jakarta namespace) – no shortcuts.
- New SQM (Semantic Query Model) replaces legacy HQL parsing, improving performance but requiring query rewrites.
- Migration is not drop-in; expect to update annotations, XML mappings, and custom types.
Think of Hibernate as a translator between Java objects and database tables. Hibernate 6 is like upgrading from an old dictionary to a new one – many words (APIs) have changed or been removed. You can't just swap the book; you need to update your sentences (code) to match the new vocabulary. The migration is painful but necessary for long-term compatibility with Java 17+ and Jakarta EE.
If you're reading this, you're probably staring at a mountain of compilation errors after switching to Hibernate 6. I've been there – twice. Once in 2022 when we migrated a fintech platform from Hibernate 5.6 to 6.1, and again in 2023 when we hit a production outage because of a subtle SequenceStyleGenerator change. Here's the hard truth: Hibernate 6 is not a minor upgrade. It's a major rewrite that aligns with JPA 3.0 and Jakarta EE, and it breaks a lot of things that worked in Hibernate 5.
But it's also a huge improvement. The new type system is cleaner, the SQM-based query engine is faster, and the support for Java Records and modern SQL features is a game-changer. The problem? Most migration guides gloss over the real pain points. They'll tell you to 'update your imports' but won't warn you that your custom UserType will silently fail at runtime.
In this article, I'll walk you through the critical changes, share the exact production bugs I've encountered, and give you a no-BS migration checklist. I'll also show you what the official docs won't tell you – the edge cases that will bite you at 3 AM on a Friday.
1. The Jakarta Namespace Migration: No Escape
The most obvious change in Hibernate 6 is the migration from javax.persistence to jakarta.persistence. This is not optional – it's a requirement of JPA 3.0. If you're still using javax.persistence, your code won't compile. But here's what the docs won't tell you: it's not just about renaming imports. Your XML mapping files (orm.xml, persistence.xml) also need to use the jakarta namespace. And if you have any custom JPA providers or interceptors that reference javax, they'll break too.
In our fintech app, we had a custom javax.persistence.spi.PersistenceProvider that we used for testing. That class literally disappeared. We had to rewrite it to implement jakarta.persistence.spi.PersistenceProvider. The same goes for any javax.transaction dependencies – switch to jakarta.transaction.
Here's a practical tip: use a Maven enforcer rule to ban javax.persistence imports. Add this to your pom.xml:
``xml <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-enforcer-plugin</artifactId> <version>3.4.1</version> <executions> <execution> <id>enforce-no-javax</id> <goals><goal>enforce</goal></goals> <configuration> <rules> <bannedImports> <bannedImports> <import>javax.persistence</import> </bannedImports> </bannedImports> </rules> </configuration> </execution> </executions> </plugin> ``
But be careful: some libraries like Hibernate Validator still use javax.validation in older versions. You'll need to upgrade to the Jakarta-compatible versions (e.g., Hibernate Validator 8.x).
javax.persistence transitively. Use mvn dependency:tree to find and exclude them.2. The New Type System: Goodbye `org.hibernate.type.Type`
Hibernate 6 introduces a completely revamped type system. The old org.hibernate.type.Type interface and its implementations (like org.hibernate.type.IntegerType) are deprecated and may be removed in future versions. Instead, Hibernate now uses org.hibernate.type.descriptor.java.JavaType and org.hibernate.type.descriptor.jdbc.JdbcType.
If you've written custom UserType implementations – and let's be honest, many of us have for legacy databases or custom serialization – you need to update them. The new UserType interface has different method signatures. For example, nullSafeGet and nullSafeSet now take a JavaType parameter instead of a Type.
Here's a before-and-after example for a simple JsonNodeUserType:
```java // Hibernate 5 public class JsonNodeUserType implements UserType { @Override public Object nullSafeGet(ResultSet rs, String[] names, SessionImplementor session, Object owner) throws SQLException { String json = rs.getString(names[0]); return json == null ? null : new ObjectMapper().readTree(json); } @Override public void nullSafeSet(PreparedStatement st, Object value, int index, SessionImplementor session) throws SQLException { if (value == null) { st.setNull(index, Types.VARCHAR); } else { st.setString(index, new ObjectMapper().writeValueAsString(value)); } } }
// Hibernate 6 public class JsonNodeUserType implements UserType<JsonNode> { @Override public JsonNode nullSafeGet(ResultSet rs, int position, SessionImplementor session, Object owner) throws SQLException { String json = rs.getString(position); return json == null ? null : new ObjectMapper().readTree(json); } @Override public void nullSafeSet(PreparedStatement st, JsonNode value, int index, SessionImplementor session) throws SQLException { if (value == null) { st.setNull(index, Types.VARCHAR); } else { st.setString(index, new ObjectMapper().writeValueAsString(value)); } } } ```
Notice the generics: UserType<JsonNode>. Also, the nullSafeGet signature changed from String[] names to int position. This is a breaking change that will cause compilation errors.
What the official docs won't tell you: if you're using @Type annotation with a fully qualified class name, the new type system may not find it automatically. You might need to use @Type(JsonNodeUserType.class) explicitly.
nullSafeGet was called with wrong column index because we forgot to update from the old String[] names API. The result was reading the wrong column silently.nullSafeGet and nullSafeSet signatures have changed significantly.3. SQM: The New Query Engine That Breaks Your HQL
Hibernate 6 introduces the Semantic Query Model (SQM) as the new query engine. It replaces the legacy HQL parser with a more standards-compliant one. The good news: SQM is faster and supports more SQL features. The bad news: it's stricter, and many HQL queries that worked in Hibernate 5 will now fail or return different results.
The most common breaking change is with implicit joins. In Hibernate 5, you could write:
``hql from Order o where o.customer.name = 'John' ``
This would implicitly join Order to Customer. In Hibernate 6, this is no longer allowed. You must explicitly declare the join:
``hql from Order o join o.customer c where c.name = 'John' ``
If you don't, you'll get an IllegalArgumentException at runtime. This is a good thing – it makes queries more readable – but it will break a lot of existing code.
Another issue: Hibernate 6 no longer supports the hibernate.query.passTranslation setting that allowed you to bypass the parser. If you were using native SQL fragments in HQL, you need to rewrite them as native queries.
Here's a debugging tip: set hibernate.query.sqm_problems_are_thrown=true in your application.properties. This will cause Hibernate to throw exceptions with detailed error messages instead of silently failing. In production, you might want to log these warnings first.
Also, watch out for SELECT NEW mappings. The package names must be fully qualified. If you have a DTO projection like select new com.example.OrderDTO(o.id, o.total), ensure the class is in the classpath and has a matching constructor.
sqm_problems_are_thrown revealed the issue.4. Identifier Generation Changes: The Sequence Saga
Identifier generation is one of the most impactful changes in Hibernate 6. The default strategy for @GeneratedValue(strategy = GenerationType.SEQUENCE) changed from SequenceStyleGenerator (which used pooled-lo optimizer) to a new generator that uses pooled optimizer by default. This sounds like a detail, but it can cause duplicate key errors in production, as I learned the hard way.
Here's the technical explanation: pooled-lo pre-allocates a range of IDs in memory (e.g., 50 at a time) and uses the database sequence as a 'high' value. The pooled optimizer, on the other hand, updates the sequence on every allocation and uses a different algorithm. If your application is clustered, or if you have multiple instances, the new behavior can cause ID collisions.
To restore the old behavior, you have two options:
- Explicitly configure the generator with
pooled_lo: - ```java
- @Entity
- public class MyEntity {
- @Id
- @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "my_seq")
- @GenericGenerator(name = "my_seq", strategy = "sequence",
- parameters = @Parameter(name = "pooled_lo", value = "true"))
- private Long id;
- }
- ```
- Set the global property
hibernate.id.new_generator_mappings=false(but this is deprecated and may be removed in future versions).
Another change: the hilo generator is now deprecated. If you were using @GeneratedValue(strategy = GenerationType.TABLE), that's also deprecated in favor of sequence or UUID.
What the official docs won't tell you: even if you set pooled_lo=true, the initial sequence value may be off. Hibernate 6's SequenceGenerator expects the sequence to start at 1, not at the pre-allocated value. If your existing sequence has a different increment, you'll need to adjust it.
pooled optimizer. Explicitly configure pooled_lo to maintain Hibernate 5 behavior and avoid duplicate keys.5. Bytecode Enhancement and Lazy Loading Changes
Hibernate 6 changes the default bytecode provider from javassist to bytebuddy. This is generally a good thing – ByteBuddy is more modern and performant – but it can cause issues if you have custom bytecode manipulation or if you rely on Hibernate's lazy loading without enhancement.
One gotcha: if you use @OneToMany(fetch = FetchType.LAZY) and you're not using bytecode enhancement, Hibernate 6 may throw LazyInitializationException even with spring.jpa.open-in-view=true. This is because Hibernate 6 changed the default proxy creation mechanism. The fix is to enable bytecode enhancement explicitly via Maven or Gradle plugin.
For Maven, add this to your pom.xml:
``xml <plugin> <groupId>org.hibernate.orm.tooling</groupId> <artifactId>hibernate-enhance-maven-plugin</artifactId> <version>6.3.1.Final</version> <executions> <execution> <id>enhance</id> <goals><goal>enhance</goal></goals> <configuration> <failOnError>true</failOnError> <enableLazyInitialization>true</enableLazyInitialization> <enableDirtyTracking>true</enableDirtyTracking> <enableAssociationManagement>true</enableAssociationManagement> </configuration> </execution> </executions> </plugin> ``
If you can't use bytecode enhancement (e.g., due to build constraints), you can fall back to the old proxy mechanism by setting hibernate.bytecode.provider=javassist (but javassist is no longer bundled, so you'd need to add it manually).
Another change: Hibernate 6 now supports Java Records as embeddable types. This is great for value objects like Address or Money. But be careful: Records are immutable, so Hibernate will use constructor injection. Your Record class must have a constructor that matches the fields, and you must provide @Column annotations on the record components.
6. What the Official Docs Won't Tell You
After spending countless hours debugging migrations, here are the gotchas that the official Hibernate migration guide glosses over:
- XML mapping files are silently ignored if they use old namespace. If you forget to update
orm.xmlfromhttp://xmlns.jcp.org/xml/ns/persistence/orm_2_2.xsdtohttps://jakarta.ee/xml/ns/persistence/orm_3_0.xsd, Hibernate will not parse them. No error, no warning – your mappings just disappear. We discovered this after a week of mysterious missing columns. hibernate.hbm2ddl.automay drop tables. In Hibernate 5,updatewould add missing columns but rarely drop them. In Hibernate 6, the schema management tool is more aggressive. We had a test environment whereupdatedropped a column because the mapping changed slightly. Always usevalidatein production.- The
@Typeannotation no longer accepts fully qualified class names as strings. You must use@Type(MyType.class). If you have@Type(type = "com.example.MyType"), it will fail silently. - Hibernate 6 changes the default batch size for JDBC batching. In Hibernate 5, the default was 0 (no batching). In Hibernate 6, it's 1. This can cause performance degradation if you were relying on batching. Set
hibernate.jdbc.batch_size=20explicitly if you need batching. - The
hibernate.query.fail_on_pagination_over_collection_fetchproperty now defaults totrue. If you have queries that combine pagination withJOIN FETCHon collections, they will now fail with an error. You need to either remove theJOIN FETCHor use@BatchSize. - Custom
Dialectimplementations must be updated. Hibernate 6 introduced a newDialecthierarchy. If you have a custom dialect for a niche database, it likely needs to extendorg.hibernate.dialect.Dialect(new) instead of the old one. The new dialect has many abstract methods that you must implement. - Second-level cache providers need updating. If you use Ehcache, you must upgrade to the Jakarta-compatible version (Ehcache 3.10+). The old Ehcache 2.x integration is removed.
update DDL auto-mode dropped it. The column was used by a legacy process, and we only caught it because a nightly job failed.7. Migration Checklist: From Hibernate 5 to 6
Based on my experience migrating multiple projects, here's a battle-tested checklist:
- Update dependencies: Replace
hibernate-corewithhibernate-core6.x (which uses Jakarta). Update all related libraries (Hibernate Validator, Ehcache, etc.) to Jakarta-compatible versions. - Replace imports: Use IDE refactoring to replace
javax.persistencewithjakarta.persistence. Also updatejavax.transactiontojakarta.transaction. - Update XML files: Change namespace in
orm.xml,persistence.xml, and any other JPA XML files. - Fix custom UserTypes: Rewrite to implement the new
UserType<T>interface. Update method signatures. - Update identifier generators: Explicitly configure
pooled_looptimizer if you were using sequence. Test with a real database. - Enable bytecode enhancement: Add the Maven/Gradle plugin. Test lazy loading.
- Rewrite HQL queries: Enable SQM problem reporting. Fix all implicit joins. Test all named queries.
- Review schema generation: Set
ddl-auto=validateand compare expected schema with actual. - Check second-level cache: Upgrade to Ehcache 3.10+ or other Jakarta-compatible cache.
- Run full integration tests: Use Testcontainers with a real database to catch issues early.
- Update custom dialects: If you have one, extend the new
Dialectclass. - Monitor performance: Check batch sizes, connection pooling, and query execution plans.
- Gradual rollout: Deploy to a staging environment first. Monitor for duplicate key errors, lazy loading exceptions, and unexpected schema changes.
- Review logging: Enable
org.hibernate.SQL=DEBUGandorg.hibernate.type.descriptor.sql.BasicBinder=TRACEto see actual SQL and parameter bindings.
What the official docs won't tell you: you should also check for any use of Hibernate's deprecated APIs like org.hibernate.Criteria (removed in 6.0) or org.hibernate.Query (use jakarta.persistence.Query instead). Also, if you use @FilterDef or @Filter, ensure they are still working – the filter API had some changes.
The Case of the Vanishing Sequence IDs on EKS
SEQUENCE from SequenceStyleGenerator to SequenceGenerator with a different pooling algorithm. The old generator used pooled-lo by default; the new one uses pooled. This caused the sequence to jump ahead, but the in-memory values didn't match the database sequence, leading to collisions.@GenericGenerator(name = "seq", strategy = "sequence", parameters = @Parameter(name = "pooled_lo", value = "true")) to restore the old behavior. Then manually reset the sequence to a safe value and cleared the Hibernate sequence cache.- Always verify identifier generation strategy after migration – don't assume defaults are the same.
- Use integration tests with a real database to catch sequence issues before production.
- Read the Hibernate 6 migration guide's section on 'Identifier generators' – it's not optional.
- Monitor sequence gaps in production – a sudden jump in sequence values is a red flag.
- Consider using
UUIDorSnowflakeIDs to avoid sequence headaches altogether.
javax.persistence not foundjavax.persistence with jakarta.persistence in all imports. Use your IDE's refactoring tool to batch rename.UserType or CompositeUserType implements deprecated types. Implement org.hibernate.usertype.UserType (new package) and update methods to use org.hibernate.type.descriptor.java.JavaType instead of org.hibernate.type.Type.hibernate.query.sqm_problems_are_thrown=true to get detailed errors. Rewrite implicit joins: Hibernate 6 requires explicit JOIN FETCH or JOIN in FROM clause for entity associations – no more implicit joins in WHERE clause.LazyInitializationException even with spring.jpa.open-in-view=truebytebuddy. Ensure hibernate.bytecode.provider is set correctly. Use @Transactional or explicitly fetch associations. If using Spring Boot, set spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true only as a last resort.hibernate.id.new_generator_mappings=false (temporary) to revert to Hibernate 5 behavior. Better: explicitly define generator with pooled_lo parameter. Then manually fix sequence values in DB.find . -name '*.java' -exec sed -i 's/javax.persistence/jakarta.persistence/g' {} +mvn clean compile| File | Command / Code | Purpose |
|---|---|---|
| JakartaMigrationExample.java | @Entity | 1. The Jakarta Namespace Migration |
| JsonNodeUserType.java | public class JsonNodeUserType implements UserType | 2. The New Type System |
| SQMExample.java | String hql = "from Order o where o.customer.name = :name"; | 3. SQM |
| SequenceGeneratorExample.java | @Entity | 4. Identifier Generation Changes |
| BytecodeEnhancementExample.java | @Entity | 5. Bytecode Enhancement and Lazy Loading Changes |
| OrmXmlExample.xml | 6. What the Official Docs Won't Tell You | |
| MigrationChecklist.java | TypedQuery | 7. Migration Checklist |
Key takeaways
hibernate.query.sqm_problems_are_thrown=true and hibernate.id.new_generator_mappings=false (temporary) during migration to surface issues.Interview Questions on This Topic
What is the most significant breaking change in Hibernate 6 for custom type users?
org.hibernate.type.Type interface is deprecated. Custom UserType implementations must now implement org.hibernate.usertype.UserType<T> with generics, and the nullSafeGet/nullSafeSet methods have different signatures (using int position instead of String[] names).Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
That's Hibernate & JPA. Mark it forged?
7 min read · try the examples if you haven't