intermediate~4h

Entity Lifecycle — Persist, Merge, Detach, Remove & Proxies

Every entity is always in exactly one of four states — knowing which one, and how persist/merge/detach/remove transition between them, prevents the majority of real-world Hibernate bugs, LazyInitializationException chief among them.

Learning objectives

  • Name the four entity lifecycle states and the operations that transition between them.
  • Explain why merge() returns a new object rather than mutating the one passed into it.
  • Explain what a proxy is and why accessing one after its persistence context closes throws LazyInitializationException.

Every entity your code touches is, at any given moment, in exactly one of four states — and not knowing which one is responsible for a big share of real Hibernate bugs, LazyInitializationException chief among them. This chapter makes those four states concrete, with real code showing each transition.

📖 Story

Think of a package moving through a shipping system. A package sitting in your living room, never dropped off, doesn't exist in the system at all — that's transient. Once you drop it off and it's scanned in, the system actively tracks it — that's managed. If you print a tracking slip and take it home, it still has the package's data, but it's no longer being live-updated — that's detached. And once a package is marked for disposal, it's gone — removed.

Now here's where this trips almost every developer up at least once:

Customer customer = entityManager.find(Customer.class, id); // customer is now MANAGED entityManager.close(); // the persistence context closes // customer is now DETACHED — still has its data, but Hibernate stopped watching it customer.setName("Updated Name"); customer = entityManager.merge(customer); // ⚠️ merge() does NOT make the object you passed in managed! // It returns a DIFFERENT, managed object with the copied data.

If you keep using the ORIGINAL customer variable after calling merge(), expecting your change to be saved — it won't be. Only the object merge() RETURNS is actually managed.

Transient — a plain new Customer(), never seen by Hibernate at all. Managed (persistent) — an entity currently tracked by an active persistence context; its changes get dirty-checked. Detached — an entity that WAS managed, but whose persistence context has since closed — it still has data, but nobody's watching it anymore. Removed — marked for deletion; the actual DELETE runs at the next flush. Proxy — a Hibernate-generated stand-in object for data that hasn't actually been loaded from the database yet (used for lazy loading).

Let's walk through all four transitions with real code, one at a time.

Transient → Managed, via persist()

Customer customer = new Customer("Aisha"); // transient — Hibernate has never seen this entityManager.persist(customer); // now MANAGED — will be INSERTed at next flush

Managed → Detached, automatically

Customer customer = entityManager.find(Customer.class, 1L); // managed entityManager.close(); // context closes // customer is now DETACHED — same object, same data, but no longer tracked

Detached → Managed, via merge() — and the trap from this chapter's story

customer.setName("New Name"); // mutating a DETACHED object does NOTHING by itself Customer managedCopy = entityManager.merge(customer); // returns a NEW, managed object // managedCopy is now tracked and will be saved. `customer` itself never became managed.

Managed → Removed, via remove()

Customer customer = entityManager.find(Customer.class, 1L); // managed entityManager.remove(customer); // marked for deletion // The actual DELETE SQL runs at the NEXT FLUSH, not the instant remove() is called.

The proxy trap: LazyInitializationException

Customer customer; try (EntityManager em = emf.createEntityManager()) { customer = em.find(Customer.class, 1L); // customer.getOrders() is LAZY — not loaded yet, just a proxy } // <- persistence context closes here customer.getOrders().size(); // 💥 LazyInitializationException! // The proxy needed to go fetch the real data, but the session // that could have done that is already closed.

This is one of the most common real-world Hibernate errors — and now you know exactly why it happens.

The managed-to-detached transition is implemented by Hibernate simply removing the entity from the persistence context's internal tracking map when the context closes — the Java object itself is completely untouched; only Hibernate's AWARENESS of it changes. merge() works by looking up (or loading) the real managed entity by ID, then copying every field from your detached object onto THAT instance — which is exactly why the object you passed in is discarded, and the method's RETURN VALUE is the one that matters. Proxies are generated via runtime bytecode generation — a subclass of your entity that intercepts field access and triggers a real database fetch the first time a field beyond the ID is actually touched, PROVIDED the persistence context is still open to do that fetch.

  • A typical Spring MVC "edit" flow loads an entity in one request (managed), and the user submits changes in a SEPARATE request — the updated data arrives detached (or even transient), and needs merge() to reconnect it, exactly like this chapter's story.
  • REST APIs deserializing a JSON request body into a Java object create a completely transient object with zero Hibernate awareness — persist() or merge() is what actually connects it.
  • Any caching layer holding entities across requests is, by definition, holding detached entities — any lazy association not already loaded before caching will throw LazyInitializationException the moment something later tries to access it.
  • ALWAYS use the object merge() returns, never the object you passed into it — this chapter's story is the single most common merge() mistake, and it's genuinely counter-intuitive the first time you hit it.
  • Fully load any lazy association you'll need later BEFORE the entity becomes detached — accessing it afterward is exactly this chapter's LazyInitializationException trap.
  • Remember remove()'s timing: the row is only actually deleted at the next flush, not the instant you call it.

⚠️ Why this keeps happening

merge()'s "returns a different object" behavior directly contradicts how almost every other Java method works (mutate in place) — which is exactly why this one design choice causes more real bugs than any other single thing in this chapter.

  • Calling merge(detachedEntity) and continuing to use detachedEntity afterward — exactly this chapter's opening story — expecting it to now be managed, when it never becomes managed at all.
  • Accessing a lazy proxy field on a detached entity, triggering LazyInitializationException because the session that could have fetched the real data is already closed.
  • Assuming remove() deletes immediately, then being surprised the row still technically exists from another connection's perspective until the next flush actually runs.
  • Treating a JSON-deserialized request body as if it were already managed, forgetting it's transient (or ambiguously so) and needs an explicit persist()/merge() to actually connect it.

merge() costs an extra database round-trip to find (or confirm) the real managed entity before copying state onto it — for a genuinely NEW entity, persist() is both simpler and cheaper, since it skips that lookup entirely. Proxies exist specifically to avoid loading data you may never access — this chapter's LazyInitializationException is the direct cost of that tradeoff when it goes wrong.

Blindly merge()-ing an entire object deserialized straight from an untrusted client request (a classic "mass assignment" bug) can let a client silently overwrite fields it should never touch, like an isAdmin flag — validate and map only the explicitly permitted fields from client input, rather than merging a whole client-supplied object wholesale.

Track LazyInitializationException occurrences in your production logs as a direct, actionable signal — each one points to a specific entity/association accessed after its persistence context closed, worth fixing at the root (fetch it eagerly where genuinely needed, or restructure the code) rather than working around symptomatically.

Establish a team convention for how incoming API payloads map to entity-lifecycle transitions — explicit persist() for genuinely new records, explicit field-by-field updates for existing ones — rather than blindly merge()-ing a whole client payload, which prevents both the mass-assignment risk above and a whole category of subtle lifecycle bugs.

  1. Create a transient entity, persist() it, then mutate it and confirm (via SQL logging) that the mutation triggers an UPDATE at flush time — because it's now managed.
  2. Detach an entity, mutate it, and confirm the mutation is NOT persisted (since it's no longer tracked).
  3. Reproduce this chapter's merge() trap exactly: call merge() on a detached, mutated entity, and confirm the ORIGINAL object stays unpersisted while merge()'s RETURN value is correctly saved.
  4. Deliberately reproduce LazyInitializationException using the exact pattern from this chapter's Concept Overview — load an entity with a lazy association, let the context close, then access it.

✓ Quick recap

  • Every entity is always in exactly one of four states: transient, managed, detached, or removed.
  • persist() makes a transient entity managed; merge() copies a detached entity's state onto a NEW managed object (never the one you passed in); remove() marks a managed entity for deletion at the next flush.
  • Proxies are lazy-loading stand-ins; accessing one after its persistence context has closed throws LazyInitializationException.
  • Always use merge()'s return value, never the object passed into it — the single most common entity-lifecycle mistake in real Hibernate code.

Want a visual for this concept?

Generate a diagram tailored to “Entity Lifecycle — Persist, Merge, Detach, Remove & Proxies” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Spring Data JPA — Repositories, Derived Queries & Auto-Configuration← Back to all Spring Data JPA & Hibernate Mastery chapters