What is dirty checking, and why did my entity save without a save() call?
The persistence context keeps a snapshot of every entity it loads and compares it against the live object at flush time, issuing an UPDATE for anything that differs. Mutating a fetched entity is therefore a write, whether or not you called save. It also means the session holds a second copy of everything you load, and by default updates every column rather than the one you changed.
The Answer
Say this in the room. 45 seconds.
- When the persistence context loads an entity it keeps a snapshot — a copy of every persistent field.
- At flush, it compares each managed entity against its snapshot and issues an
UPDATEfor anything that differs. Nothing calls save. - So mutating a fetched entity inside a transaction is a write. A setter is a database statement with a delay.
- It only applies to managed entities. A detached one — returned from the service, deserialised,
newed — is compared to nothing and silently does not save. - The cost is that the session holds two copies of everything you load, and flush is O(entities × fields). That is why loading 50,000 rows in one transaction hurts twice.
@Transactional(readOnly = true)lets Hibernate skip the snapshot. Faster and lighter — and any change you make is then silently discarded.- By default Hibernate updates every column, not the one you changed.
@DynamicUpdatechanges that, and it matters more than it sounds.
Understand It
Everything below runs against a model of a persistence context, not Hibernate: a snapshot per loaded entity, a field-by-field comparison at flush, and the managed/detached distinction. Not modelled: SQL, cascades, or flush ordering.
A setter is a write
// A method that reads a product and adjusts a field. There is no save() call
// anywhere in it, and there is no repository in sight.
var session = new Session().seed(new Product(1, "Widget", 1999, 40));
var product = session.find(1);
product.priceMinor = 1799; // a discount, applied in memory
System.out.println(" statements before commit : " + session.statements.size());
var issued = session.commit();
System.out.println(" issued at commit : " + issued); statements before commit : 1
issued at commit : [update Product set name = ?, priceMinor = ?, stock = ? where id = 1]One statement before the commit — the select. Then an UPDATE appears out of a method that never asked for one.
This is the single most surprising thing about JPA for people arriving from JDBC, and it is deliberate: the persistence context's job is to make the database match the object graph at the end of the transaction, and it does that by remembering what the graph looked like when it handed it over. Calling save() on an already-managed entity is a no-op — the entity was going to be written anyway.
It follows that there is no such thing as a read-only method that mutates an entity. A helper that normalises a name, a mapper that trims whitespace, a getOrCreate that fills in a default — each of them writes to the database if the entity is managed and the method is inside a transaction.
Notice the statement too: three columns updated, one of them changed. That is the default, and there is a section on it below.
Managed, detached, and read-only
// The same mutation, three times, under three different conditions.
var managed = new Session().seed(new Product(1, "Widget", 1999, 40));
managed.find(1).priceMinor = 1799;
System.out.println(" managed entity -> " + describe(managed.commit()));
var detached = new Session().seed(new Product(1, "Widget", 1999, 40));
var entity = detached.find(1);
detached.detach(1); // returned from the service, session over
entity.priceMinor = 1799;
System.out.println(" detached entity -> " + describe(detached.commit()));
var readOnly = new Session(true).seed(new Product(1, "Widget", 1999, 40));
readOnly.find(1).priceMinor = 1799;
System.out.println(" readOnly = true -> " + describe(readOnly.commit())); managed entity -> 1 update: update Product set name = ?, priceMinor = ?, stock = ? where id = 1
detached entity -> no statement issued
readOnly = true -> no statement issuedIdentical code, three outcomes, and the difference is invisible at the point of mutation.
Detached is the one that bites in the other direction. An entity that has left its transaction is an ordinary object; setting a field on it does nothing at all. This is why "I set the value and it didn't save" and "I set the value and it saved without asking" are the same question — both are about whether the object is still managed, and nothing in the code says which.
Read-only is the interesting one. @Transactional(readOnly = true) lets Hibernate skip taking the snapshot, which is a genuine saving on both memory and flush time. The price is that a mutation is not merely unwritten — it is unnoticed, with no error, no warning, and no way to tell from the calling code. That is the right trade for a query method and a trap for anything ambiguous.
What the snapshot costs
// What the session is holding while it does this. One snapshot per entity,
// compared field by field on every flush.
for (int n : new int[] { 10, 1_000, 50_000 }) {
var session = new Session();
var rows = new Product[n];
for (int i = 0; i < n; i++) rows[i] = new Product(i, "p" + i, 100, 1);
session.seed(rows);
for (int i = 0; i < n; i++) session.find(i);
session.find(0).stock = 99; // change exactly one field
var issued = session.flush();
System.out.printf(" %6d loaded -> %d snapshots kept, %d field comparisons, %d update%n",
n, session.snapshotCount(), session.comparisons, issued.size());
} 10 loaded -> 10 snapshots kept, 30 field comparisons, 1 update
1000 loaded -> 1000 snapshots kept, 3000 field comparisons, 1 update
50000 loaded -> 50000 snapshots kept, 150000 field comparisons, 1 updateFifty thousand entities loaded, one field changed, and the flush compares a hundred and fifty thousand fields to find it.
Two consequences worth carrying. Memory: the session holds the entity and its snapshot, so a transaction that loads a large result set uses roughly twice what the entities alone would. This is a common cause of an out-of-memory error in a batch job that "only reads".
Time: flush is proportional to what the session has loaded, not to what changed. And flush does not only happen at commit — before most queries, Hibernate flushes so the query sees pending changes. A loop that loads a batch, then queries, then loads another batch pays the whole comparison on every iteration, which is how a batch job becomes quadratic without anything in it looking wrong.
The fixes are the same two things: use readOnly = true where you are not writing, and clear() or a stateless session in a batch so the context does not accumulate.
Every column, not the one you changed
// Change one field. Compare the statement Hibernate builds by default with
// the one it builds under @DynamicUpdate.
for (boolean dynamicUpdate : new boolean[] { false, true }) {
var session = new Session().seed(new Product(1, "Widget", 1999, 40));
session.find(1).stock = 39; // one field, out of three
var issued = session.flush(dynamicUpdate);
System.out.printf(" %-16s %s%n", dynamicUpdate ? "@DynamicUpdate" : "default", issued.get(0));
} default update Product set name = ?, priceMinor = ?, stock = ? where id = 1
@DynamicUpdate update Product set stock = ? where id = 1The default builds one UPDATE statement per entity type at startup and reuses it, which is why it lists every column. That is a reasonable trade — the statement is cached and the database parses it once — and it has three consequences people meet without connecting them to this.
Audit triggers fire for columns that did not change, because as far as the database is concerned they were all written. updated_at columns and row-version checks behave the same way. And on a wide table, writing forty columns to change one is real I/O.
@DynamicUpdate builds the statement per flush from the fields that actually differ. It costs statement-cache efficiency and is worth it for wide tables, for tables with column-level triggers, and anywhere concurrent updates to different columns would otherwise conflict.
Reference
The correct implementation, the configuration, and the migration path. Copy from here.
Making writes visible
// This writes to the database. Nothing here says so.
@Transactional
public void applyDiscount(Long id, int newPrice) {
Product p = repository.findById(id).orElseThrow();
p.setPriceMinor(newPrice);
} // UPDATE issued here, at commit
// Calling save() adds nothing for a managed entity — but it does tell the
// next reader that a write happens, which is worth something.
@Transactional
public void applyDiscount(Long id, int newPrice) {
Product p = repository.findById(id).orElseThrow();
p.setPriceMinor(newPrice);
repository.save(p); // redundant to JPA, informative to humans
}
There are two schools on the redundant save(). It is genuinely a no-op, and it also stops a reviewer having to know this mechanism to see that the method writes. Pick one and be consistent; what is not defensible is a codebase where its presence or absence means nothing.
Read-only, and what it actually turns off
// Skips the snapshot, so no dirty checking and no flush. Lighter and faster,
// and any mutation is silently discarded.
@Transactional(readOnly = true)
public List<ProductView> search(String term) { ... }
# Hibernate also honours this at the query level.
# In a repository: @QueryHints(@QueryHint(name = HINT_READ_ONLY, value = "true"))
Use it on every query method. The saving is real, and the risk — a lost update — only exists in methods that were mutating an entity while claiming to be a read, which is a bug either way.
Note it does not make the transaction read-only at the database level on every provider; on PostgreSQL Spring does set the JDBC connection read-only, on others it may be advisory. If you need the database to refuse writes, say so explicitly.
Batch work, where the context is the problem
// The context accumulates every entity, and flushes compare all of them.
@Transactional
public void reprice(List<Long> ids) {
for (int i = 0; i < ids.size(); i++) {
Product p = repository.findById(ids.get(i)).orElseThrow();
p.setPriceMinor(recalculate(p));
if (i % 50 == 0) {
entityManager.flush();
entityManager.clear(); // detach everything; snapshots go with it
}
}
}
// Or skip the persistence context entirely for a pure batch.
StatelessSession session = sessionFactory.openStatelessSession();
// No first-level cache, no dirty checking, no cascades. You issue every write.
-- Or do not load the rows at all. This is one statement and no snapshots.
update Product p set p.priceMinor = p.priceMinor * 0.9 where p.category = :c
The bulk UPDATE is the right answer far more often than people reach for it. Its cost is that it bypasses the persistence context — entities already loaded keep their stale values, and no entity callbacks or version increments happen — so it wants a clear() afterwards and care with optimistic locking.
Dynamic update
@Entity
@DynamicUpdate // build the UPDATE from the changed fields
class Product { ... }
Worth it for wide tables, for tables with column-level audit triggers, and where two transactions routinely change different columns of the same row. Not worth it for narrow tables that are written often, where the cached statement is the better trade.
Which mode when
| Situation | Use |
|---|---|
| Any query method | @Transactional(readOnly = true) |
| Read one entity, change a field | Plain @Transactional; the setter is the write |
| Change one column on many rows | A bulk JPQL update, then clear() |
| A long batch loading many entities | flush() + clear() every N, or a StatelessSession |
| Wide table, or column-level triggers | @DynamicUpdate |
| Anything returned to a caller | A DTO — a detached entity's setters do nothing |
Scenarios
Real situations, with the decision and the argument.
1. A field changed in production and nobody can find the save().
There will not be one. Look instead for a method that fetches the entity and passes it somewhere that mutates it — a mapper, a validator that normalises, a helper that fills a default. Anything mutating a managed entity inside a transaction is a write.
@DynamicUpdate helps the investigation as much as the performance: with it on, the SQL log shows which column actually changed, rather than an UPDATE naming every column on every write. Turning it on temporarily is a reasonable diagnostic step.
2. "I set the value and it didn't save."
The mirror image, and the same underlying question: the entity was detached. It came back from a service method whose transaction had already ended, or from a cache, or from deserialisation.
The confusing part for the person reporting it is that both behaviours exist in the same codebase — one place saves without asking, another silently does not — and neither is visible at the point of mutation. The durable fix is not to remember which is which but to stop passing entities across the boundary: a DTO cannot be accidentally dirty-checked or accidentally not.
3. A batch job runs out of memory, and it only reads.
It does not only read — it accumulates. The persistence context holds every entity it has loaded plus a snapshot of each, so a transaction that walks a large table uses about twice the entities' own footprint and never releases any of it until commit.
flush() and clear() every few hundred rows fixes it, or a StatelessSession if there is nothing to write. And if the job is genuinely read-only, readOnly = true removes the snapshot half of the cost for free.
4. Someone proposes readOnly = true on every service method.
Right for query methods, dangerous as a blanket rule. On a method that mutates an entity, the change is not rejected — it is silently ignored, with no error to notice in testing and nothing in the log.
Apply it deliberately per method, and treat any method it breaks as a finding rather than a reason to remove it: a method that was mutating an entity while presenting itself as a read is a bug that this setting has just surfaced.
5. An audit table is full of rows where nothing changed.
Column-level triggers firing because the default UPDATE writes every column. From the database's point of view all forty were assigned, whether or not the value differs.
@DynamicUpdate fixes it at the source. Before adding it everywhere, note the trade — statements are built per flush rather than cached — so apply it to the wide, audited tables where it earns its cost, not globally.
Interviewer's Next Move
The question after the question. This is what the round is actually testing.
1. "What is dirty checking?"
The persistence context keeps a snapshot of every entity it loads and compares it against the live object at flush time, issuing an UPDATE for whatever differs. It is why a setter on a managed entity is a database write.
2. "So why did my entity save without a save() call?"
Because it was managed and inside a transaction. save() on an already-managed entity is a no-op — the entity was going to be written at flush regardless.
3. "When does the flush happen?" At commit, and before most queries, so the query sees pending changes. That second one surprises people: a read in the middle of a method can trigger writes from earlier in it.
4. "When does it not happen?"
When the entity is detached — returned from a service, deserialised, constructed — because there is no snapshot to compare against. And under readOnly = true, where Hibernate skips the snapshot entirely.
5. "What does dirty checking cost?" Two copies of every loaded entity in memory, and a flush proportional to what the session has loaded rather than to what changed. Loading fifty thousand rows to change one field compares every field of all of them.
6. "How do you make a batch job survive that?"
flush() and clear() every few hundred entities, or a StatelessSession, or — usually best — do not load the rows at all and issue a bulk update statement instead.
7. "What does @Transactional(readOnly = true) actually do?"
Lets Hibernate skip the snapshot, so there is no dirty checking and no flush. It is a real saving, and it means a mutation in that method is discarded silently.
8. "Which columns does the UPDATE set?"
All of them, by default — one cached statement per entity type. @DynamicUpdate builds it per flush from the changed fields, which matters for wide tables and for column-level audit triggers.
9. "Why not return entities from a service?" Because whether a setter on them writes depends on state the caller cannot see. Inside the transaction it writes; outside it does nothing. A DTO removes the ambiguity.
Code traps
Trap A — predict before you run:
@Transactional(readOnly = true)
public ProductDto get(Long id) {
Product p = repository.findById(id).orElseThrow();
p.setName(p.getName().trim()); // tidy up a legacy value
return mapper.toDto(p);
}
Answer
The trim is applied to the DTO and never to the database, silently. readOnly = true skips the snapshot, so there is nothing to compare and no UPDATE — and no error, no warning, and nothing in the log.
What makes this hard is that the method is correct-looking under both readings: if someone removes readOnly later, it starts writing to the database on every read, which is a different bug with the same code.
Decide which it is. If the trim should persist, it belongs in a writable method; if it is presentation, do it on the DTO.
Trap B:
@Transactional
public void deactivateAll(List<Long> ids) {
for (Long id : ids) {
Product p = repository.findById(id).orElseThrow();
p.setActive(false);
}
}
Answer
Correct, and it degrades badly. Every entity stays managed with its snapshot, so memory grows with the list and each flush compares everything loaded so far. At a hundred thousand ids it will run out of memory, and before that it will be slow in a way that profiles as "Hibernate".
There is also an N+1 hiding in findById inside a loop — one select per id.
A bulk update Product p set p.active = false where p.id in :ids is one statement and no snapshots. If entity callbacks or versioning make that unacceptable, flush() and clear() every few hundred.
Trap C:
@Transactional
public void rename(Long id, String name) {
Product p = repository.findById(id).orElseThrow();
p.setName(name);
if (!isValid(name)) {
return; // give up without saving
}
auditLog.record(id, name);
}
Answer
The early return does not undo anything. The entity is managed and mutated, so the transaction commits and the UPDATE is issued — the invalid name is saved, and the audit entry that would have recorded it is skipped.
Returning early is not a rollback. Either validate before mutating, which is the right shape here, or make the failure explicit with an exception so the transaction actually rolls back.
This is the most damaging version of the surprise, because the code reads as an early exit that prevents the write, and there is no save() call anywhere to suggest otherwise.
Common wrong answers
| Said in interviews | Reality |
|---|---|
"You need save() to persist a change." | Not for a managed entity; it is a no-op. |
| "Dirty checking compares against the database." | Against an in-memory snapshot taken at load. |
| "Flush only happens at commit." | Also before most queries, so a read can trigger writes. |
| "It's free." | Two copies of everything loaded, and a flush proportional to that. |
"readOnly = true is just a hint." | It skips the snapshot, so writes are silently discarded. |
| "The UPDATE sets the column I changed." | It sets every column unless @DynamicUpdate is on. |
| "Returning early prevents the write." | The transaction still commits. Throw, or don't mutate. |
| "Detached entities save if you set a field." | Nothing compares them to anything. |
Check Yourself
Q1. A method fetches an entity, changes one field, and never calls save(). Does the change persist?
Answer
Yes, if the entity is managed and the method runs in a transaction. The persistence context snapshotted every field when it loaded the entity, compares them at flush, and issues an UPDATE for the difference — save() on an already-managed entity is a no-op. The same code persists nothing if the entity is detached, and nothing under readOnly = true, and neither is visible at the point of mutation.
Q2. A read-only batch job runs out of memory. It does not write anything. Why?
Answer
The persistence context retains every entity it loads and a snapshot of each, so the footprint is roughly double the entities themselves and nothing is released until the transaction ends. Flush also becomes proportional to everything loaded rather than to what changed. Fix it with flush() and clear() every few hundred rows, a StatelessSession, or readOnly = true — which removes the snapshot half of the cost outright.
Q3. Why does an audit trigger fire on columns nobody changed?
Answer
Because Hibernate builds one UPDATE statement per entity type at startup and reuses it, so it sets every column whether or not the value differs — the database has no way to know only one was meaningfully changed. @DynamicUpdate builds the statement per flush from the fields that actually differ, at the cost of statement-cache reuse. It is worth it for wide tables and column-level triggers, and not worth it for narrow tables written often.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Write to the database without calling save | 10 min |
| Challenge | Six methods, three of them write | 25 min |
| Production | The read-only job that ran out of memory | 45 min |
| Interview | Full round replay | 10 min |
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up10 min
Write to the database without calling save
One concept, guided. Near-impossible to fail.
- Challenge25 min
Six methods, three of them write
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The read-only job that ran out of memory
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — dirty checking
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- jpa entity lifecycle — not written yet
- How does @Transactional actually work, and when does it silently do nothing?
- What is the N+1 problem, and how do you detect it?
Questions that lead here
What are the fetch types, and which is the default for each mapping?
LAZY loads on first access, EAGER loads with the parent — and the defaults are not the same on both sides: @ManyToOne and @OneToOne default to EAGER, collections default to LAZY. Touching a lazy association after the session closes is LazyInitializationException, and Spring Boot's open-in-view setting hides that by keeping the session alive through rendering.
Asked constantlyintermediate1–15 yrs11 min readJpa hibernate
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-30.