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()ormerge()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
LazyInitializationExceptionthe 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 commonmerge()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
LazyInitializationExceptiontrap. - 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 usedetachedEntityafterward — 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
LazyInitializationExceptionbecause 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.
- 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. - Detach an entity, mutate it, and confirm the mutation is NOT persisted (since it's no longer tracked).
- Reproduce this chapter's
merge()trap exactly: callmerge()on a detached, mutated entity, and confirm the ORIGINAL object stays unpersisted whilemerge()'s RETURN value is correctly saved. - Deliberately reproduce
LazyInitializationExceptionusing 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 →