Skip to main content

Hibernate 7 merge() vs refresh(): Which One Fails Loudly?

merge() and refresh() get described as opposite directions of the same tool — one pushes, one pulls. Run them against a real @Version-ed entity and that framing misses the actual question: which one fails loudly when the state it’s holding is stale? Three named experiments against Hibernate 7.4.1.Final, including a result that overturned the article’s own original assumption.

merge() and refresh() both reconcile a Java object with the database, and the usual framing treats them as two ends of the same tool — one pushes, one pulls, pick whichever direction you need. That framing is about data flow. It says nothing about entity state, which is what actually decides which of these two methods is safe to call when the object you’re holding is already stale. Run both against a real @Version-ed entity and the answer isn’t the one most write-ups give.

This piece works through three named experiments against Hibernate 7.4.1.Final: what merge() actually returns when a managed instance for the same row already exists, precisely when an optimistic-lock conflict surfaces, and what refresh() does to an edit that was never flushed. Every result below came from a JUnit test in the companion repository linked in the callout just below — including one that overturned my own working assumption while writing it.

Versions used in this article. Hibernate ORM 7.4.1.Final (GA 2026-06-09) on Spring Boot 4.1.0 (GA 2026-06-10). Spring Boot 4.1.0’s own dependency management resolves hibernate.version to 7.4.1.Final exactly, so no POM override is needed to get this pin. The runnable project, the full JUnit test suite, and every captured transcript live in the hibernate-demo companion repository.

This is about entity state, not “data consistency”

merge() and refresh() don’t know or care about your application’s notion of consistency. Each cares about exactly one thing: what identity state — an @Version value, or an unflushed field on a currently-managed instance — the object handed to it holds at the moment it’s called. Everything below follows from that mechanic, not from anything data-consistency-flavored. merge() takes a detached entity’s state and copies it onto whatever managed instance this session is tracking for that row. refresh() takes a managed entity and overwrites its state with what the database currently holds. Both are ordinary operations. The question worth answering with code instead of assumption is what each one does when the state it’s working from is already stale.

Experiment 1 — what merge() actually returns

Config: a session already holds its own managed instance of a Book row, fetched via get(), before merge() is ever called on a separately-detached copy of the same row.

Expected: merge() returns some object carrying the updated state.

Observed: merge() returns the exact, identity-equal managed instance the session already had — not a new object, and not the detached instance passed in:

Book result = session.merge(detached);
assertThat(result).isSameAs(managed);       // true
assertThat(result).isNotSameAs(detached);   // true

This is the precise version of “merge() returns a managed copy” — it isn’t just a managed copy, it’s the one instance this persistence context already committed to tracking for this row, reused rather than replaced.

Experiment 2 — optimistic-lock conflict: WHEN does it surface?

Config: a detached Book holds version=0. A second, independent session has since updated the same row and committed, advancing the database to version=1. The stale detached instance is edited and merged.

The common but imprecise claim: “merge() throws OptimisticLockException.” True, and not the whole story — the useful question is when.

Observed, precisely: on 7.4.1.Final, the exception surfaces at the merge() call itself, not at flush() and not at tx.commit():

OptimisticLockException surfaced directly from the merge() call.
exception: jakarta.persistence.OptimisticLockException: Row was already updated or deleted by
another transaction for entity [com.ankurm.hibernatedemo.model.Book with id '4']

The test doesn’t assume this — it wraps session.merge(detached) in a try/catch first and only falls through to asserting the exception at commit() if merge() itself didn’t throw, logging whichever path actually happened. merge() re-selects the row as part of copying state and compares versions right there, before any flush is scheduled — so the failure is as early as it can possibly be. This matters for code that wraps a merge() call expecting the exception only at commit time: on this version, it never gets that far.

Experiment 3 — refresh() silently discards an unflushed edit

Config: a Book.status column starts at USER_EDIT. A separate admin process loads the row, sets status=ADMIN_EDIT, and commits. A second session then loads the row (now ADMIN_EDIT in the database), makes a local, unflushed edit back to USER_EDIT, and calls session.refresh() on the managed instance.

managed.setStatus("USER_EDIT");           // local edit, never sent to the database
session.refresh(managed);
assertThat(managed.getStatus()).isEqualTo("ADMIN_EDIT"); // the local edit is gone

No exception. No warning. refresh() re-runs the SELECT and overwrites every field on the managed instance with what the database currently holds — including the field carrying the pending edit that was never flushed. The edit isn’t rejected. It’s erased.

The LAZY collection correction

A fourth check, run alongside the three named experiments: Book.notes is a FetchType.LAZY collection cascading CascadeType.MERGE. A Book is loaded and detached without ever touching .getNotes() — the collection proxy is confirmed uninitialized before detachment — then edited and merged.

The plausible-sounding claim going in was: an unfetched LAZY collection is never touched by merge(), since it was never loaded. Measured, that’s backwards:

Book merged = session.merge(detached);
assertThat(Hibernate.isInitialized(merged.getNotes())).isTrue(); // true -- NOT false

The cause is cascading, not laziness: CascadeType.MERGE on notes means merging the parent requires merging each element of that collection too, and Hibernate can’t cascade to elements it hasn’t loaded — so it loads them first. Remove cascade = CascadeType.MERGE from Book.notes and re-run this exact test and the result flips: the collection stays uninitialized, because nothing requires Hibernate to look at it. Cascading decides whether merge() touches an unfetched collection — laziness alone does not.

What surprised me building this

Going in, the plan was to show “merge() can silently overwrite concurrent changes” as the headline risk — that’s the framing most write-ups use, and it’s the framing this article originally carried. Running Experiments 2 and 3 back to back showed the opposite. With a real @Version column in place, merge() is the one that refuses to write a stale change — loudly, at the earliest possible point. refresh() is the one that destroys data without a sound, and it does it to an edit that was never even sent to the database. The risk isn’t “which method can overwrite the database” — both can, that’s their job. It’s “which one fails loudly when the state it’s holding is stale,” and on a versioned entity that’s merge(), not refresh() — backwards from how the pairing is usually described. Strip the @Version column out and Experiment 2 flips: an unversioned merge() would apply the stale write without complaint. The column isn’t incidental to the result; it’s the entire reason the result comes out this way.

The LAZY-collection result was the other correction — and the one I got wrong in an earlier draft of this test before running it. Full transcript and source: docs/02-merge-vs-refresh.md. Reproduce it yourself:

$ git clone https://ankurm.com/git.app/asmhatre/hibernate-demo.git
$ cd hibernate-demo
$ ./mvnw -Dtest=MergeRefreshTest,OptimisticLockTest test

Decision tree

Do you have a DETACHED instance you want written to the database?
โ”œโ”€ Yes โ†’ merge()
โ”‚         Does the entity carry @Version?
โ”‚         โ”œโ”€ Yes โ†’ a stale write throws OptimisticLockException AT THE merge() CALL -- loud, safe
โ”‚         โ””โ”€ No  โ†’ a stale write silently overwrites the current row -- no different from update()
โ”‚
โ””โ”€ No, you have a MANAGED instance and want it to reflect the current database row
          โ†’ refresh()
            Does it have an unflushed local edit?
            โ”œโ”€ Yes โ†’ that edit is silently discarded, no exception -- refresh() is NOT reversible
            โ””โ”€ No  โ†’ refresh() is a safe, ordinary re-read

Pessimistic locking, briefly

Pessimistic locking (LockModeType.PESSIMISTIC_WRITE, issuing SELECT ... FOR UPDATE) solves the same underlying problem differently — it prevents the conflict from existing at all rather than detecting it after the fact, at the cost of holding a database lock for the duration of the transaction. Reach for it only when the retry cost of an OptimisticLockException is genuinely unacceptable — real-time seat or ticket reservation, high per-row contention — not as a default. @Version is the right default for ordinary request-scoped code, including a REST API with real user think-time between load and save, which is precisely the case pessimistic locking handles badly.

Frequently Asked Questions

Can I call refresh() on a detached entity?

No. refresh() requires a currently managed entity. If you have a detached object, call merge() first to get a managed reference, then call refresh() on that managed instance.

What happens to the detached object I pass into merge() — is it safe to keep using it?

It stays detached and untracked after the call — Hibernate does not adopt it into the persistence context. Always use the returned managed instance for further operations in the same session (Experiment 1 above is exactly why: the returned instance may not even be the object you passed in). Continuing to work with the original detached object is one of the most common Hibernate mistakes, and it leads to silent data loss where later changes are never flushed.

Further Reading & Cross-References

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.