Hibernate ORM Nightmares: Vanishing Records & Missing Commits in Spring 6
Learn why Hibernate records vanish and commits fail silently in Spring 6.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Spring Boot 3.2+ with spring-boot-starter-data-jpa
- ✓Hibernate 6.x (included with Spring Boot 3.x)
- ✓MySQL 8.0 or PostgreSQL 15+ for testing
- ✓Basic knowledge of @Transactional annotation
• Vanishing records often stem from detached entities or missing flush() calls before query execution • Missing commits are typically caused by unchecked exceptions or incorrect transaction propagation • Always verify transaction boundaries and flush mode when records disappear • Use @Transactional(readOnly=true) explicitly for read operations • Enable SQL logging to catch silent rollbacks early
Think of Hibernate as a busy waiter who takes orders but sometimes forgets to tell the kitchen. Vanishing records are like customers who ordered but the waiter never submitted the ticket. Missing commits are like the waiter writing orders on a napkin that gets thrown away. You need to ensure the waiter always hands the ticket to the chef (flush) and the chef confirms the meal is cooked (commit).
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
You've been there. You call save() on your repository, check the database, and nothing. The record is gone. Vanished. Or worse, you think you saved data, but the commit never happened, and your users are staring at stale data. After 15 years of debugging Hibernate in Spring Boot applications, I've seen these patterns destroy production deployments. In Spring 6 with Hibernate 6.x, the ORM has gotten smarter, but that intelligence introduces new traps. The persistence context, flush modes, and transaction boundaries are the usual culprits. I once spent three days chasing a bug where records appeared in the database during debugging but vanished in production. The root cause? A missing @Transactional annotation on a service method that triggered a flush before a native query. This article walks through the exact scenarios where Hibernate lies to you, how to diagnose them with Spring Boot 3.2+, and how to prevent data loss. We'll cover dirty checking, flush modes, and the dreaded LazyInitializationException that masks real issues. By the end, you'll know how to interrogate Hibernate's session and make it behave predictably.
The Persistence Context: Your Invisible Buffer
Hibernate's persistence context is like a staging area. When you call save(), the entity isn't written to the database immediately. It sits in the session's first-level cache. This is by design: Hibernate batches writes for performance. But this buffer becomes a nightmare when you expect data to be visible immediately. In Spring 6, the default FlushMode is AUTO, which means Hibernate flushes automatically before any query execution—but only for HQL, JPQL, and Criteria queries. Native SQL queries bypass this check entirely. I've seen teams spend days debugging why a freshly saved record doesn't appear in a native query result. The fix is simple: either use JPQL or call entityManager.flush() before your native query. But here's the trap: if you call flush() and then an exception occurs later, that flush is still committed to the database unless you explicitly roll back. Always pair flush() with proper transaction boundaries.
What the Official Docs Won't Tell You
The official Hibernate documentation says FlushMode.AUTO flushes before 'query execution'. What they don't tell you is that 'query' means HQL, JPQL, or Criteria queries—not native SQL. This distinction has caused countless production outages. Also undocumented: if you use @Transactional on a service method and call another method within the same class (self-invocation), Spring's proxy mechanism bypasses the transaction entirely. The @Transactional annotation is ignored. This means your save() call might not even participate in a transaction. I've debugged systems where developers thought they had transaction boundaries but were actually running without them. Another hidden gem: Hibernate 6.x changed the default flush mode for some operations. In Hibernate 5, certain queries would trigger a flush; in Hibernate 6, they don't. Always verify your Hibernate version's behavior. The docs also gloss over the fact that calling entityManager.clear() after a flush can cause detached entities to become stale, leading to NonUniqueObjectException or stale data updates.
Dirty Checking: The Silent Update Killer
Hibernate's dirty checking mechanism detects changes to managed entities and flushes them automatically at transaction commit. But this feature can work against you. If you load an entity, modify it, and then call save() again, Hibernate might issue an UPDATE on commit even if you didn't intend to change anything. Worse, if you modify a field that has a @Version annotation for optimistic locking, Hibernate increments the version even if the field didn't actually change. This leads to StaleObjectStateException in high-concurrency scenarios. In Spring 6, the default dirty checking strategy is 'auto', which compares the current state with a snapshot taken at load time. This comparison is expensive for large entities. I've seen systems where dirty checking alone consumed 30% of CPU time. The fix: use @Immutable for read-only entities or @DynamicUpdate to only update changed columns. But beware: @DynamicUpdate can cause issues with inheritance hierarchies. Another trap: if you detach an entity (e.g., by closing the session), modifications are lost. Always merge() detached entities back into the persistence context before saving.
Transaction Boundaries: Where Commits Go to Die
In Spring 6, @Transactional creates a proxy around your method. If an unchecked exception (RuntimeException) is thrown, the transaction rolls back. But checked exceptions (like SQLException) do NOT trigger rollback by default. This is the #1 cause of missing commits. You write a service that catches a checked exception, logs it, and returns normally. The transaction commits—without your data. Why? Because Hibernate might have already flushed the data before the exception, but the commit succeeds because no runtime exception occurred. The fix: always use @Transactional(rollbackFor = Exception.class) to roll back on any exception. Another boundary issue: transaction propagation. If you call a REQUIRES_NEW method from within an existing transaction, the inner transaction commits independently. If the outer transaction rolls back, the inner transaction's changes persist. This can lead to partial updates. I once debugged a payment system where the invoice was saved (inner transaction) but the payment record was rolled back (outer transaction), leaving orphaned invoices. The solution: use NESTED propagation or restructure the logic to avoid nested transactions.
The LazyInitializationException Masquerade
LazyInitializationException occurs when you access a lazy-loaded association outside of a Hibernate session. But this exception often masks a deeper problem: the session might have already flushed and closed, or the transaction might have completed. In Spring 6, the default behavior is to close the EntityManager at the end of the transaction. If you try to access a lazy collection in a view template after the service method returns, you get this exception. Developers often 'fix' this by adding @Transactional to the controller, which extends the session lifetime—but also extends the transaction, leading to database locks. I've seen production systems where every GET request held a database connection open for seconds because of this. The proper fix: use JOIN FETCH in JPQL to eagerly load required associations, or use Spring's OpenEntityManagerInViewFilter (but beware of performance implications). Another hidden issue: if you use @Transactional(readOnly = true) on a service method, Hibernate might skip dirty checking but still keep the session open. Accessing lazy collections outside the method still fails because the session is closed after the method returns.
Cascading: The Domino Effect of Data Loss
Cascade types in Hibernate can cause unintended data loss. For example, CascadeType.ALL on a parent entity means that deleting the parent also deletes all children. But if you accidentally remove a child from the collection and then save the parent, Hibernate might delete that child from the database. I've seen this in a SaaS billing system where removing an invoice line item from a list caused the line item to be permanently deleted, even though the developer only intended to unlink it. The fix: use CascadeType.MERGE and CascadeType.PERSIST explicitly, and never use CascadeType.ALL or CascadeType.REMOVE unless you fully understand the implications. Another cascade trap: orphanRemoval = true. If you set this on a OneToMany relationship, removing a child from the collection triggers a DELETE statement. This is useful for composition but dangerous for aggregation. In Spring 6, the default cascade type is NONE, but many developers add CascadeType.ALL without thinking. I always tell my teams: 'Cascade is a promise to delete data. Don't make that promise lightly.'
Batch Insertion: When Performance Kills Consistency
Hibernate's batch insertion is a performance optimization that groups multiple INSERT statements into a single JDBC batch. But it introduces a nightmare: if one insert in the batch fails, the entire batch is rolled back, and you might not know which record caused the failure. In Spring 6, you can configure batch size with spring.jpa.properties.hibernate.jdbc.batch_size. But if you use IDENTITY generation strategy, Hibernate disables batch insertion entirely because it needs to retrieve the generated ID after each insert. This is a common performance pitfall. The fix: use SEQUENCE or TABLE generation strategies for batchable inserts. Another issue: batch insertion can cause constraint violations to surface later, after the transaction commits, making debugging difficult. I've seen a system where a duplicate email constraint was violated in a batch of 1000 records, but the error message didn't indicate which record caused the issue. The solution: validate data before batch insertion and use smaller batch sizes in production.
Debugging Vanishing Records: A Systematic Approach
When records vanish, don't panic. Follow this systematic debug process. First, enable SQL logging: spring.jpa.show-sql=true and spring.jpa.properties.hibernate.format_sql=true. This shows every SQL statement Hibernate executes. If you don't see an INSERT, the record was never flushed. Second, check transaction boundaries: add logging around @Transactional methods to see when they begin and commit. Use TransactionSynchronizationManager.isActualTransactionActive() to verify a transaction is active. Third, inspect the persistence context: call entityManager.contains(entity) to check if the entity is managed. If it returns false, the entity is detached and changes won't be saved. Fourth, check for silent rollbacks: enable spring.jpa.properties.hibernate.generate_statistics=true to see transaction commit and rollback counts. Fifth, verify flush mode: call entityManager.getFlushMode() to ensure it's AUTO. Sixth, check for exception swallowing: add @Transactional(rollbackFor = Exception.class) and log all exceptions. I once used this process to find a bug where a @PreUpdate listener threw an exception that was caught by Hibernate and logged at DEBUG level, causing the entire transaction to roll back without any ERROR log.
The Phantom Invoice: How Hibernate Swallowed $50k in Revenue
JpaRepository.save() appeared in the database during debugging but vanished after the next read operation in production. Users reported missing records within minutes.- Never assume Hibernate flushes before native queries
- Always test with SQL logging enabled (spring.jpa.show-sql=true)
- Use JPQL or Criteria queries when mixing writes and reads in the same transaction
- Explicitly set rollbackFor on @Transactional to avoid silent rollbacks
save()spring.jpa.show-sql=trueentityManager.flush() before native queryflush() or switch to JPQL| File | Command / Code | Purpose |
|---|---|---|
| PersistenceContextExample.java | @Service | The Persistence Context |
| SelfInvocationTrap.java | @Service | What the Official Docs Won't Tell You |
| DirtyCheckingExample.java | @Entity | Dirty Checking |
| TransactionBoundaryExample.java | @Service | Transaction Boundaries |
| LazyLoadFix.java | @Repository | The LazyInitializationException Masquerade |
| CascadeExample.java | @Entity | Cascading |
| BatchInsertConfig.java | @Entity | Batch Insertion |
| DebuggingUtils.java | @Service | Debugging Vanishing Records |
Key takeaways
Interview Questions on This Topic
Explain the Hibernate persistence context and how it affects transaction behavior in Spring 6.
save(), the entity becomes managed but isn't written to the database until flush. The flush happens automatically before HQL/JPQL queries (FlushMode.AUTO) or at transaction commit. If you clear the persistence context with clear(), entities become detached and changes are lost. In Spring 6, the EntityManager is typically closed at the end of the transaction, so accessing entities outside the transaction requires eager loading.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Hibernate & JPA. Mark it forged?
5 min read · try the examples if you haven't