Look at this Spring service. Eight lines, nothing exotic, no annotations missing as far as a junior reviewer can tell. Before reading on, predict what happens when something calls markActive(42) against a real database.
@Service
public class UserService {
private final UserRepository userRepo;
public UserService(UserRepository r) { this.userRepo = r; }
public User markActive(Long id) {
User u = userRepo.findById(id).orElseThrow();
u.setStatus("ACTIVE");
return u;
}
}
If you said “an UPDATE statement fires”, you would be wrong. There is no @Transactional on the method, so Spring Data opens a session for the duration of findById, returns the entity, and closes the session. By the time u.setStatus("ACTIVE") runs, u is no longer managed. The mutation exists only in JVM memory. There is no flush, no dirty check, no UPDATE.
This is the kind of bug you can stare at for an hour, because the code is almost right. It would be right if Hibernate worked the way most people imagine it does. The actual mechanics are stranger and more interesting — and once you see them, a long list of mysterious behaviours suddenly make sense: phantom UPDATEs, missing UPDATEs, NonUniqueObjectException, LazyInitializationException, the JPQL query that fires SQL you didn’t expect.
This post is a deep-dive on the persistence context: the in-memory state machine that decides what gets written, when, and why.
1. The Four States as a State Machine
Every entity Hibernate has ever heard of is in one of four states: transient, managed (also called persistent), detached, or removed. The persistence context is the registry. Transitions between states are triggered by specific operations, and the state determines exactly what happens when you mutate a field.

Transient
A transient entity is a plain Java object Hibernate has never seen. It lives entirely in JVM memory, with no row in the database and no row reserved for it. The primary key is typically null — or zero, if you mapped it as a primitive.
- Entry trigger: the
newkeyword - Exit triggers:
persist()promotes the object to managed;merge()returns a managed copy and the original stays transient - Mutation tracking: none — no snapshot exists to compare against
// brand-new transient instance
User u = new User();
u.setName("Ankur");
u.setStatus("PENDING");
// Hibernate has no idea u exists.
// No SELECT, no INSERT, no snapshot, no dirty checking.
// All mutations live only in JVM memory until persist() is called.
Managed (Persistent)
A managed entity is registered in the persistence context, keyed by (EntityClass, id). This is the only state in which Hibernate is actively watching the entity, and it is the state every other operation either flows into or flows out of.
- Entry triggers:
persist(), the return value ofmerge(),find(),getReference(), or any JPQL/HQL/Criteria query that returns rows of this type - Exit triggers: session close, end of the surrounding
@Transactional, explicitdetach(),clear(), orremove() - Mutation tracking: every field you set is detected at the next flush and translated into the appropriate
UPDATE
@Transactional
public void promote(Long id) {
User u = em.find(User.class, id); // MANAGED, snapshot taken
u.setStatus("ADMIN"); // tracked
u.setLastModified(Instant.now()); // tracked
} // commit -> flush -> dirty check -> UPDATE
Notice what is not in that method: no save, no update, no flush. The UPDATE fires automatically because the entity was managed when the setters ran. This is dirty checking earning its keep — and the moment you take it for granted is the moment you write a method without @Transactional and wonder why nothing saved.
Detached
A detached entity was once managed but is no longer. It still has its primary key and field values, but its persistence context is gone. The biggest source of confusion in production: any entity returned from a @Transactional method is detached the moment that method returns.
- Entry triggers: session close, end of
@Transactional, explicitdetach(), orclear() - Exit trigger:
merge()returns a managed copy — and you must use the return value - Mutation tracking: none
@Transactional
public User load(Long id) {
return em.find(User.class, id); // managed inside the tx
} // tx ends -> returned entity is DETACHED
// somewhere outside any transaction:
User u = userService.load(42L);
u.setStatus("INACTIVE"); // mutates a detached entity
// no flush, no UPDATE; the database still says the old status
The setter ran. The field changed in memory. The database is untouched. To get the change written, the entity has to be re-attached — and the original detached reference must be discarded:
@Transactional
public void save(User detached) {
User managed = em.merge(detached); // managed copy comes back
// mutate 'managed' from this point on, never 'detached'
}
Removed
A removed entity is marked for deletion but the actual DELETE is deferred until flush. The entity stays in the persistence context with a “scheduled-for-deletion” flag, which means Hibernate still considers it tracked but is committed to deleting it on the next flush.
- Entry trigger:
remove()on a managed entity - Exit triggers: flush sends the
DELETE; callingpersist()on a removed entity before flush re-attaches it as managed and cancels the deletion - Mutation tracking: still tracked until flush, but mutations to fields after
remove()are silently discarded — there is no row left to apply them to
@Transactional
public void deactivate(Long id) {
User u = em.find(User.class, id); // MANAGED
em.remove(u); // REMOVED (still in PC)
// u.setStatus("INACTIVE") here would be silently discarded.
// No DELETE has been sent yet; a JPQL query against User in
// this same tx may still see the row, depending on flush mode.
} // commit -> flush -> DELETE
The One Rule That Matters Most
One sentence is worth more than the rest of this section: changes to detached entities do not write to the database. Every “why didn’t my UPDATE fire?” question collapses into “the entity was detached when you mutated it”. For the specific surprises around save(), update(), and saveOrUpdate() — which mix these state transitions in slightly different ways — see save() vs update() vs saveOrUpdate().
2. Under the Hood: How Dirty Checking Actually Works
This is the part that feels like magic. You loaded an entity, called a setter, and on commit Hibernate emitted an UPDATE — without any save, update, or flush call from your code. How does it know?
When find() materialises an entity, Hibernate does two things atomically. First, it stores the managed instance in the persistence context, keyed by (EntityClass, id). Second — and this is the part most developers don’t picture — it stores a snapshot: an array of the entity’s field values at load time. The snapshot is held separately from the live entity. It is, essentially, Hibernate’s memory of “what the database said”.
At flush time (we’ll get to when that is in section 4), Hibernate iterates every managed entity and compares the live state to the snapshot, field by field. Any field where Objects.equals(snapshot[i], live[i]) returns false is “dirty”. If at least one field is dirty, Hibernate generates an UPDATE for that row. The WHERE clause is the primary key (or, if you have a @Version, the primary key plus the version).
This comparison is the cheapest reflection-based diff Hibernate can manage, but it isn’t free. For an entity with 30 fields, every flush walks all 30. For a session holding 50,000 entities, the dirty check itself can dominate response time. Nothing in your code looks expensive, but the framework is doing 1.5 million field comparisons before each flush.
The bytecode enhancement alternative
Instead of comparing snapshots, Hibernate can instrument your entity classes at build time so that every setter records the field as dirty as you call it. With enhancement enabled there is no snapshot scan; the dirty fields are already known by flush time, recorded in a per-entity bitset.
// build.gradle
hibernate {
enhancement {
enableDirtyTracking = true
enableLazyInitialization = true
enableAssociationManagement = true
}
}
The trade-off is an extra branch and a bitset write inside every setter. For most apps the dirty-check scan is the bottleneck and enhancement wins; for apps with very wide entities and very high mutation rates, profile both. Bytecode enhancement also changes how lazy associations are proxied, which is covered in Hibernate 7 Proxies and the LazyInitializationException.
3. The Persistence Context is an Identity Map
Within one persistence context, Hibernate guarantees: for any given (EntityClass, id) pair, at most one managed Java object exists. This is why find(User.class, 1L) == find(User.class, 1L) is reference-equal within the same session. The second call doesn’t go to the database; it returns the cached managed instance.
This guarantee is what makes dirty checking sane. If two User instances both with id=1 could be managed at once, a flush would have to decide which one’s state to write. Hibernate sidesteps the question by simply refusing to allow two.
When you try to violate the guarantee — typically by calling merge() or the legacy update() on a detached object whose id matches a managed one already in the context — you get NonUniqueObjectException. The error message is precise: “a different object with the same identifier value was already associated with the session”. Hibernate is telling you that two Java references claim to be the same database row, and it will not pick a winner.
This is also why the first-level cache and the persistence context are the same thing. The cache exists because the identity map exists. Both are byproducts of the same Map<EntityKey, Object> data structure. The deep mechanics of that map and how it interacts with flush() and clear() are covered in Hibernate 7 First Level Cache.
4. Flush Semantics: AUTO, COMMIT, MANUAL
A flush is the moment Hibernate translates the in-memory state of the persistence context into SQL. Most developers assume a flush happens “at commit time”. That is true, but it is only one of three triggers, and the other two are where the surprises live.
FlushModeType.AUTO is the default. Hibernate flushes (a) just before transaction commit, and (b) just before any JPQL/HQL/Criteria query that might be affected by pending changes. The second trigger is the one that catches people:
User u = em.find(User.class, 1L);
u.setStatus("ACTIVE");
// This query forces an auto-flush BEFORE running the SELECT
List<User> active = em.createQuery(
"select u from User u where u.status = 'ACTIVE'", User.class)
.getResultList();
When the query runs, Hibernate detects that there’s a pending UPDATE on User and flushes it first — to ensure the query sees a consistent state. You’ll see an UPDATE in the SQL log before the SELECT, even though no transaction has committed and no flush() was called. Hibernate is being correct, not eager. But if you’re stepping through with a SQL trace and didn’t expect a write at that line, it looks bizarre.
FlushModeType.COMMIT tells Hibernate to flush only at transaction commit. Queries do not see your in-memory changes. This is faster but can return stale results. Use only when you understand the consequence.
FlushMode.MANUAL (Hibernate’s org.hibernate.FlushMode) tells Hibernate to flush only when you call session.flush() explicitly. Useful in long conversations where you want to defer all writes to the very end and roll back trivially without any database hit.
You can override flush mode per query: query.setFlushMode(FlushModeType.COMMIT) skips the auto-flush for that one query. And calling em.flush() manually forces SQL to execute now while still inside the transaction — useful when you need a database trigger to fire so a subsequent refresh() can pick up the trigger’s side effects. (For when to combine flush with refresh, see merge vs refresh.)
5. Detachment Scenarios in REST: Where Your DTO Mapper Sees Frozen Time
The persistence context lives inside a Session/EntityManager. In a Spring Boot REST application, that lifecycle is controlled by @Transactional. The moment a @Transactional method returns, Spring closes the EntityManager (or releases it back to the pool). Every entity that came out of that method is now detached.
This matters enormously for the layer above your service: the controller, the JSON serialiser, the DTO mapper. They are all running outside the transaction.
The flow that breaks production: the controller receives GET /users/42; it calls userService.findById(42), which is annotated @Transactional; inside the method Hibernate loads User and any eagerly-fetched associations into the persistence context, and the entity is managed; the method returns; the persistence context closes; the entity is now detached; the controller hands the entity to Jackson; Jackson walks the object graph; Jackson tries to serialise user.getOrders(), a lazy collection; the proxy says “not yet loaded, let me fetch from the session”; the session is closed; LazyInitializationException.
This is the most common Hibernate exception in the wild, and it is fundamentally a state-machine problem, not a query problem. The cure — entity graphs, DTO projections, fetch joins — is its own topic; see Proxies and LazyInitializationException.
The DTO-mapper scenario is less catastrophic but more insidious:
public UserResponse toResponse(User u) {
u.setLastSeen(Instant.now()); // mutates DETACHED entity
return new UserResponse(
u.getId(), u.getName(), u.getLastSeen());
}
The setter mutates the detached object’s in-memory state. The DTO sees the new value. The database does not. The API response shows lastSeen=now, the database keeps the old value, and a downstream cron job that reads lastSeen from the database makes a different decision than the one your API just announced. A junior debugging this will swear the system is haunted.
The rule is mechanical, not stylistic: any state mutation on an entity outside a @Transactional boundary is invisible to the database.
6. The Phantom UPDATE: A Production Debugging Story
Production noticed that the user_settings table was being written to on every page load. Every read endpoint, every health check that touched a User entity emitted an UPDATE user_settings. Database CPU was up 30%. The team had not changed the schema in months.
The setup: User had a Map<String, Object> attributes field, mapped via a JPA AttributeConverter that serialised to JSON for a TEXT column. The converter used Jackson with default settings.
Step one was turning on org.hibernate.SQL=DEBUG and org.hibernate.orm.jdbc.bind=TRACE. That confirmed that yes, every read fired update user_settings set attributes_json = ? where id = ?, with the new value byte-for-byte identical to a human reader.
Step two was enabling org.hibernate.event.internal.AbstractFlushingEventListener=DEBUG. Hibernate logs the dirty fields per flush. The dirty field was attributes_json. Hibernate was convinced the column value had changed.
Step three was the actual bug. On load, the converter’s convertToEntityAttribute parsed the JSON column into a HashMap. On dirty check, Hibernate needed to compare against the snapshot. The snapshot was stored as the converted column value — the JSON string — at load time. To compare, Hibernate called convertToDatabaseColumn on the live Map, got a JSON string back, and compared string-to-string.
Jackson’s default HashMap serialisation does not preserve key order. The JSON after re-serialisation read {"theme":"dark","lang":"en"} even though the original column held {"lang":"en","theme":"dark"}. The strings differed. Hibernate saw a dirty field. UPDATE fired. Every. Single. Request.
The fix had three plausible options. Use a LinkedHashMap plus Jackson’s SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS so that serialisation is deterministic. Implement the converter’s equality semantics by overriding the column comparator (or use a custom UserType with a proper equals). Or — if the field genuinely never changes during a request — mark it @Column(updatable = false) so Hibernate skips it from the UPDATE entirely.
The team picked the deterministic-serialisation fix. Phantom UPDATE gone, database CPU back to baseline by the next deploy. The lesson: dirty checking is only as accurate as the equality semantics on the column-form of your fields. Mutable types, custom converters, byte arrays, and floating-point fields are all places this can bite.
Bad → Improved: Don’t Assume the Persistence Context Survives Across @Transactional
The most common version of the entity-state bug assumes the persistence context survives across @Transactional boundaries. It does not. Each @Transactional opens its own EntityManager.
Bad — assumes the entity stays managed across method calls:
@Service
public class CheckoutService {
@Transactional
public Order load(Long id) {
return orderRepo.findById(id).orElseThrow();
}
@Transactional
public void process(Long id) {
Order o = load(id); // returns DETACHED
o.setStatus("PAID"); // not tracked
o.setProcessedAt(Instant.now()); // not tracked
} // commit fires NO update
}
The mental model implied by “load() then process()” is “I loaded it, I’m modifying it, the modification will be saved”. Reality: load() opens a persistence context, returns the entity, closes the persistence context. process() opens a new persistence context, but o was never put into it, so dirty checking has nothing to track.
Improved — single transaction, mutations on a managed entity:
@Service
public class CheckoutService {
@Transactional
public void process(Long id) {
// managed in THIS persistence context
Order o = orderRepo.findById(id).orElseThrow();
o.setStatus("PAID"); // tracked
o.setProcessedAt(Instant.now()); // tracked
} // commit -> dirty check -> UPDATE
}
If you genuinely need loading and processing in separate methods (for testing, reuse, or because the operations are at different transaction-isolation levels), use merge() to re-attach the detached entity, and always use the return value: Order managed = em.merge(o), then mutate managed, never o.
See Also
- 📘 save(), update(), and saveOrUpdate() in Hibernate 7 — how the legacy methods mix the four state transitions
- 📘 Merging vs. Refreshing Entities — when to use merge() to re-attach and refresh() to discard local changes
- 📘 Hibernate 7 Proxies and the LazyInitializationException — how detachment turns lazy collections into runtime errors
- 📘 Hibernate 7 First Level Cache — the persistence context as identity map, in depth
- 📘 Mastering Lazy Loading in Hibernate 7 — fetch strategies, OSIV, and EntityGraphs
- 📘 Hibernate 7 Lifecycle Callbacks — how @PrePersist and @PostLoad hook into state transitions
- 📘 Inserting Objects Efficiently in Hibernate 7 — persist() and batch inserts
- 📘 JPA Cascade Types in Hibernate 7 — how cascading propagates state transitions through the object graph
- 📘 Hibernate 7 Second Level Cache — caching beyond the session boundary
- 📘 Batch Processing with Hibernate 7 — flush/clear patterns and StatelessSession for batch jobs
Frequently Asked Questions
Why does Hibernate fire an UPDATE I didn’t ask for?
Almost always, automatic dirty checking detected a difference between the snapshot taken at load time and the live entity at flush time. The most common surprise causes are mutable fields with non-deterministic equality (Map/List/byte[] backed by custom converters), @PostLoad callbacks that mutate state, or floating-point precision drift on numeric columns. Enable org.hibernate.event.internal.AbstractFlushingEventListener=DEBUG to see exactly which field Hibernate considers dirty.
Why doesn’t my setter trigger an UPDATE?
The entity is detached at the moment you called the setter. The most common cause is that you mutated the entity outside any @Transactional method, or after a @Transactional method has already returned. Detached entities have no persistence context to flush against, so Hibernate has nowhere to write the change.
When exactly does Hibernate flush?
Under FlushModeType.AUTO (the default), Hibernate flushes immediately before any JPQL/HQL/Criteria query that may be affected by pending changes, and once more at transaction commit. Under COMMIT, only at commit. Under MANUAL, only when you call session.flush() explicitly. The query-triggered flush is the source of most “why did SQL fire on this line?” surprises.
Can two managed entities have the same ID?
No. The persistence context is an identity map. For any (EntityClass, id) pair there is at most one managed instance. Trying to attach a second one — usually via merge() or the legacy update() on a detached object — throws NonUniqueObjectException. The fix is either to evict the existing instance first or to use the managed copy returned by merge() and discard the detached original.
What’s the difference between flush() and commit()?
flush() sends the pending SQL (INSERTs, UPDATEs, DELETEs) to the database while the transaction is still open — the changes are visible to your own connection but can still be rolled back. commit() ends the transaction, makes the changes permanent, and releases locks. Hibernate auto-flushes before commit, so calling flush() manually is only useful when you need a database-side trigger or generated value to fire before a subsequent operation in the same transaction.
AI Prompts You Can Use
Prompt 1: Trace Entity States Across a Request
What it does: Walks through every step of a controller-service-DAO request and labels which Hibernate state each entity is in, when the persistence context opens and closes, and where flushes occur.
When to use it: When debugging silent data loss, lazy-loading exceptions, or surprising SQL ordering in a Spring Boot REST endpoint.
Trace the Hibernate 7 entity lifecycle through this Spring Boot request: [paste controller, service, repository, and entity]. For each line, label the state of each entity (transient/managed/detached/removed), when the persistence context opens, when it closes, when an auto-flush occurs, and where mutations are tracked vs ignored. Flag any line where a setter is called on a detached entity.
Prompt 2: Diagnose a Phantom UPDATE
What it does: Identifies which field is being flagged dirty by Hibernate’s snapshot comparison and explains why — usually a mutable type, custom converter, or non-deterministic serialisation.
When to use it: When SQL logs show repeated UPDATEs on entities you did not intentionally modify.
Hibernate 7 is firing an UPDATE on this entity even when no business code mutates it. Entity: [paste]. Converters: [paste]. Logs: [paste]. Identify which field the dirty checker thinks has changed, why the snapshot comparison returns false, and recommend a fix using @Column(updatable=false), a deterministic AttributeConverter, or a custom UserType. Show the corrected code.
Prompt 3: Fix a NonUniqueObjectException
What it does: Locates the second instance of the same database row in the persistence context and rewrites the code to either evict it, use merge() correctly, or restructure the flow so only one managed instance exists.
When to use it: When a save or merge fails with “a different object with the same identifier value was already associated with the session”.
My Hibernate 7 service throws NonUniqueObjectException. Code: [paste]. Trace where the duplicate (EntityClass, id) pair is being introduced into the persistence context — is it a load followed by a merge of a detached copy from a JSON request? Recommend the right fix: evict before merge, use the merge() return value, or restructure so only one managed instance exists. Show corrected code.
Prompt 4: Decide Between AUTO, COMMIT, and MANUAL Flush
What it does: Recommends the right FlushMode for a given workload and explains the trade-offs in query freshness vs write latency.
When to use it: When designing long-running conversations, batch jobs, or read-heavy endpoints where the auto-flush is hurting performance.
For this Hibernate 7 workload — [describe scenario, e.g., 100k-row batch import, multi-step wizard, read-heavy reporting endpoint] — recommend FlushModeType.AUTO, COMMIT, or MANUAL. Explain when queries will and won't see in-memory changes under each mode, what the performance implications are, and show the configuration code. Include the gotcha around JPQL queries triggering auto-flush.
Prompt 5: Eliminate Detachment Bugs in a Spring REST Layer
What it does: Reviews a controller-service-DTO mapper chain and flags every place an entity is mutated outside a @Transactional boundary, then refactors the flow so only DTOs cross transaction boundaries.
When to use it: When debugging “my API response shows the change but the database doesn’t” or LazyInitializationException in a JSON serializer.
Review this Spring Boot REST flow for entity-detachment bugs: [paste controller, service, mapper, entity]. Flag every line where a setter is called on a detached entity, every place a lazy collection is accessed outside @Transactional, and every leaked entity reaching Jackson serialization. Refactor so only DTOs cross transaction boundaries and the persistence context is fully closed when the controller returns.
Conclusion
The persistence context is the single most important mental model in Hibernate. Almost every “surprising” behaviour — phantom UPDATEs, missing UPDATEs, NonUniqueObjectException, LazyInitializationException, JPQL queries firing SQL out of order — reduces to a question about which state an entity is in, where the persistence context begins and ends, and what the dirty checker sees when it compares the snapshot to the live entity. Once you can label the state of every entity at every line, the framework stops being magic and starts being a predictable state machine. Keep your transactions short, treat @Transactional boundaries as the persistence context’s lifetime, never mutate detached entities, and trust dirty checking instead of fighting it. The framework was designed to do most of the work for you — but only when you have given it the chance.