Here is the table that every Hibernate tutorial promises and none of them finish:
| Method | Entity state: Transient | Entity state: Managed | Entity state: Detached |
|---|---|---|---|
persist() | Schedules INSERT; ID assigned at flush (SEQUENCE) or immediately (IDENTITY) | No-op — entity already tracked | Throws PersistentObjectException |
save() | Schedules INSERT; returns ID immediately (may fire INSERT for IDENTITY) | Returns existing ID; entity already tracked | Treats as new: ignores existing ID, generates new one — duplicate insert |
merge() | Copies state to new managed entity; returns the managed copy | Returns same instance (identity) | Issues SELECT to load current DB state; merges over it; returns managed copy |
update() | Throws TransientPropertyValueException | No-op if already in session; throws NonUniqueObjectException if different instance same ID | Re-attaches; schedules UPDATE; throws NonUniqueObjectException if conflicting instance exists |
saveOrUpdate() | Calls save() path | No-op if already in session | Calls update() path; same NonUniqueObjectException risk |
Most of the bugs that come to production code review — duplicate inserts, silent data loss, unexpected exceptions — trace to a mismatch between what the developer expected from the column above and what the table actually says. The rest of this post is a catalog of the five most common mismatches, with code showing exactly what goes wrong and why.
JPA-Only vs Hibernate-Extended: The Deprecation Trajectory
persist() and merge() are defined by the Jakarta Persistence specification. They work against any JPA provider. save(), update(), and saveOrUpdate() are Hibernate-specific methods on the Session interface. They predate JPA and exist partly for backward compatibility.
save(), update(), and saveOrUpdate() were formally deprecated in Hibernate 6.0 — and in Hibernate 7.0 they are removed outright. Code that calls them no longer compiles against Hibernate 7; the replacements are persist() (for transient entities) and merge() (for detached ones). The related org.hibernate.annotations.CascadeType#SAVE_UPDATE is gone too. So on Hibernate 7 the question is no longer “which should I prefer” — it is “what did the removed method do, and which replacement preserves the behaviour my legacy code depended on”.
The practical implication: if you are maintaining legacy code on Hibernate 5/6 that uses save() and saveOrUpdate(), the table above tells you how they behave — and what to watch for when the Hibernate 7 upgrade forces you onto persist()/merge(). If you are writing new code, use persist() and merge(). The anti-patterns below cover both worlds because the bugs appear in both.
Anti-pattern 1: Calling merge() as if it Were save()
This is the most common merge() bug in production code. merge() does not modify the object you pass in. It returns a new managed instance that reflects the merged state. Any mutations you make to the original detached object after calling merge() are invisible to Hibernate.
@Transactional
public void updateOrder(Order detachedOrder) {
em.merge(detachedOrder); // return value discarded!
detachedOrder.setStatus("PAID"); // sets status on detached copy
// No UPDATE fires for status; the managed copy has whatever
// status the DB row held when merge() loaded it
}
The fix is mechanical: use the return value.
@Transactional
public void updateOrder(Order detachedOrder) {
Order managedOrder = em.merge(detachedOrder); // use the return value
managedOrder.setStatus("PAID"); // tracked; UPDATE fires at flush
}
This trips up developers who come from Spring Data JPA’s save(), which behaves like merge() and also returns the managed copy — but they ignore the return value because “save always works”. Switch to raw EntityManager and the same habit causes silent failures.
Anti-pattern 2: saveOrUpdate() on a Transient with a Manually Set ID
saveOrUpdate() determines the entity’s intended state by inspecting the identifier. If the ID is non-null and not the configured unsaved-value (usually null or 0), Hibernate calls the update() path — which schedules an UPDATE and expects the row to exist.
If you are writing an import job and assign IDs from an external system:
Product importedProduct = new Product();
importedProduct.setId(externalSystemId); // ID from CSV import
importedProduct.setName("Widget Pro");
importedProduct.setPrice(new BigDecimal("29.99"));
session.saveOrUpdate(importedProduct); // takes UPDATE path
// If no row with externalSystemId exists: zero rows updated, no exception,
// no INSERT — the record vanishes silently
For imports with externally-assigned IDs, use merge(). It issues a SELECT first, and if no row is found it inserts. The SELECT overhead is the price of correctness; optimise with batch hints if volume justifies it.
Anti-pattern 3: persist() or save() in a Loop Without flush+clear
Every entity passed to persist() enters the persistence context. Its snapshot is stored alongside it. In a loop processing 100,000 rows, by iteration 80,000 the persistence context is a 80,000-entry map, each entry holding the entity plus a field-by-field snapshot. Heap exhaustion follows. If the job survives, the flush at commit walks all 100,000 entities for dirty checking — taking anywhere from 10 to 90 seconds depending on entity width.
// Bad: persistence context grows to 100,000 entities
@Transactional
public void importProducts(List<ProductDto> dtos) {
for (ProductDto dto : dtos) {
em.persist(toEntity(dto));
// no flush, no clear
}
} // OOM at ~80k rows; commit flush takes 60+ seconds if it survives
// Fixed: flush and clear in batches
@Transactional
public void importProducts(List<ProductDto> dtos) {
int count = 0;
for (ProductDto dto : dtos) {
em.persist(toEntity(dto));
count++;
if (count % 50 == 0) {
em.flush(); // write current 50 entities to DB
em.clear(); // drop from identity map; entities become detached
}
}
} // identity map stays at max 50 entries throughout the job
Combine this with JDBC batch settings for maximum throughput. Set hibernate.jdbc.batch_size=50 and hibernate.order_inserts=true in application.properties. Without ordering, inserts for different entity types interleave and the JDBC driver cannot batch them. With ordering, Hibernate groups all Product inserts, all Category inserts, etc., and the driver sees a clean sequence it can batch. For the full batch configuration and the IDENTITY generator problem that silently disables JDBC batching, see Batch Processing with Hibernate 7.
Anti-pattern 4: persist() on a Detached Entity
persist() is not safe for detached entities. The JPA spec requires providers to throw PersistenceException if the entity is in detached state, because persist() means “this is new and I want an INSERT”. Hibernate 7 throws org.hibernate.PersistentObjectException with a message like “detached entity passed to persist”.
@Transactional
public Customer load(Long id) {
return em.find(Customer.class, id); // returns managed; then tx ends: DETACHED
}
@Transactional
public void save(Customer customer) {
em.persist(customer); // customer is DETACHED here
// throws: PersistentObjectException: detached entity passed to persist: Customer
}
The fix depends on intent. If you want to re-attach and update: use merge() and operate on its return value. If you want to ensure the entity is tracked in the current transaction without an unconditional INSERT, use merge(). If you know you are inside the same transaction (e.g., nested REQUIRED propagation), you do not need either call — dirty checking handles the update automatically.
Anti-pattern 5: Operating on the Detached Original After merge()
This is anti-pattern 1 in a more subtle form. After merge() returns a managed copy, developers sometimes make further changes to the original detached object and call a downstream method that calls merge() again, expecting the second call to capture those changes.
@Transactional
public void processOrder(Order detached) {
Order managed = em.merge(detached);
// ... do some work ...
detached.setProcessedAt(Instant.now()); // wrong object
detached.setStatus("COMPLETE"); // wrong object
auditService.record(detached); // audit sees the new values
// DB sees old values from the merge snapshot
// API response and database are out of sync
}
The invariant to enforce: after calling merge(), discard the original detached reference. Name the return value managed or tracked and use it for everything from that point on. If you find yourself touching the original, you have a bug.
Under the Hood: What EntityKey Lookup Happens for Each Method
When any persistence method is called, Hibernate checks the first-level cache (the identity map) using an EntityKey constructed from the entity class and the identifier value. What happens next differs by method:
persist(): Checks the identity map. If found (managed already): no-op. If not found and entity is transient: adds to map, takes snapshot, schedules INSERT. If entity is detached (non-null ID, not in map): throws PersistentObjectException.
merge(): Checks the identity map. If found (managed): merges the detached state onto the managed instance, returns the managed instance. If not found: issues a SELECT to load from DB; if row exists, merges onto it; if no row, copies to a new transient entity and schedules INSERT. Always returns the managed copy.
save(): Does not check whether the row exists. Assigns an identifier (from sequence, or defers to flush for IDENTITY), adds to identity map, schedules INSERT. If the entity is detached with an existing ID, save() treats it as transient and generates a new ID — the original ID is silently ignored.
update(): Checks the identity map. If the same Java reference is already there: no-op. If a different Java reference with the same EntityKey is there: NonUniqueObjectException. If not found: adds to map with UPDATE scheduled, no SELECT issued (Hibernate trusts you that the row exists).
The SELECT that merge() issues — and update() skips — is the core design difference between the two re-attachment methods. merge() is safe because it verifies the DB state; update() is slightly faster but trusts the caller. (The first-level cache mechanics that underlie all of this are covered in The Hibernate First-Level Cache Explained.)
See Also
- 📘 The Hibernate 7 Persistence Context — entity states in depth, dirty checking, the four transitions that trigger writes
- 📘 The Hibernate First-Level Cache Explained — identity map, EntityKey, flush/clear patterns for batch jobs
- 📘 Batch Processing with Hibernate 7 — JDBC batch settings, the IDENTITY generator problem, 1M-row benchmark
- 📘 merge() vs refresh() in Hibernate 7 — when to use each, the concurrency-aware decision guide
- 📘 JPA Cascade Types in Hibernate 7 — how cascade propagates persist/merge/remove through the object graph
Frequently Asked Questions
What is the difference between persist() and save() in Hibernate 7?
persist() is the JPA standard method; it is void and may defer ID assignment until flush. save() is Hibernate-specific; it returns the identifier immediately, which can force an early INSERT if using GenerationType.IDENTITY. save() was deprecated in Hibernate 6 and removed in Hibernate 7 — on Hibernate 7 you must use persist(). The critical behavioural difference is how they treat a detached entity: persist() throws, save() generates a new ID and inserts a duplicate.
Is merge() safe for both inserts and updates?
Yes. merge() handles all three entity states: transient (schedules INSERT), managed (merges state), detached (SELECT + UPDATE). It is the most defensively correct write method in JPA. The cost is the SELECT it issues for detached entities. In high-volume write paths, avoid merge() for bulk inserts of known-new records — use persist() with SEQUENCE generators and JDBC batching instead.
Why does saveOrUpdate() sometimes insert instead of update?
saveOrUpdate() uses the identifier value and the unsaved-value configuration to decide which path to take. If the ID is null, zero, or matches the configured unsaved-value, it calls save(). Otherwise it calls update(). With manually assigned IDs from external systems, every entity has a non-null ID, so saveOrUpdate() always takes the update() path — and if the row doesn’t exist, the update silently affects zero rows. Use merge() for externally-identified entities.
Conclusion
The five anti-patterns in this post share a common thread: each one results from treating a Hibernate write method as a synonym for another when the entity state is different from what the developer expected. The matrix at the top is the map. merge()‘s return value is the invariant. flush()+clear() in loops is the batch discipline. persist() belongs with transient entities, merge() with detached ones. Keep these straight and the duplicate inserts, phantom updates, and PersistentObjectExceptions disappear — not because of configuration, but because the right method was called against the right state.