Your logs show Hibernate issuing an UPDATE for the same entity in transactions where the application never modifies it. What kinds of mapping or type problems cause these repeated no-op updates, and how do you pin down which column is responsible?
answer
- No-op UPDATE = a property differs from the loaded snapshot
- BigDecimal scale, Timestamp precision, timezone asymmetry
- Converter/UserType equality + deepCopy; JSON key order
- CHAR padding, Y/N booleans, null-to-default conversions
- Temporary @DynamicUpdate names the column in the SQL
basics
~20 sSome property compares unequal to its loaded snapshot even though nothing meaningful changed — typically a type whose write-then-read round trip is not value-preserving: BigDecimal scale, date/time precision or class mismatch, a custom or JSON type without correct equality and deep copy, trimmed CHAR padding, or a mutable value replaced on read. Temporarily enabling dynamic updates names the column in the SQL.
solid answer
~60 sA no-op update means dirty checking found a property that differs from the loaded-state snapshot. The usual causes are round-trip asymmetries between the Java value and the stored value: - **Numeric scale**: `BigDecimal` compares by value *and* scale, so `2.50` read back as `2.5` looks changed every time. - **Date/time**: mapping `java.util.Date` onto a column returning `java.sql.Timestamp`, or a database with different fractional-second precision, so the value read differs from the one held. - **Custom types / converters / JSON**: a `UserType` or `AttributeConverter` whose equality or deep copy is wrong, so the round trip never compares equal; JSON columns are notorious because key order or whitespace differs. - **CHAR padding** trimmed on read but not in memory, and `Boolean`/enum conversions that are not symmetric. - **Mutable values** (a collection or `Date`) replaced or normalised inside a getter under property access. Diagnosis: put `@DynamicUpdate` on the entity temporarily — the emitted statement then names the offending column directly — and confirm with SQL logging and entity update counts in Hibernate statistics.
code
java · 6 lines// column is NUMERIC(10,2); the driver returns scale 2
price = new BigDecimal("2.5"); // scale 1
// snapshot holds 2.50 -> BigDecimal.equals is scale-sensitive -> dirty on every flush
// fix
price = new BigDecimal("2.5").setScale(2, RoundingMode.HALF_UP);go deeper
Say that an UPDATE means something compared unequal to the loaded values, and give one concrete cause such as a numeric scale or date precision mismatch.
List the main round-trip asymmetries — BigDecimal scale, temporal precision, converters, JSON, CHAR padding — and know that dynamic updates reveal the column.
Run the investigation end to end: confirm with SQL logs and statistics, isolate with a temporary dynamic update, fix the mapping, and lock it in with a test that asserts no statement on a flush after a plain load.
Treat write amplification as a system property: policies on temporal and decimal types, canonical encoding for semi-structured columns, read-only loading on read paths, and a standing check that a load-and-flush emits nothing.
## Why a no-op update is a symptom worth chasing An UPDATE that changes nothing still costs a round trip, still writes a row version, still fires triggers and change-data events, still bumps `@Version` — which means it can cause optimistic-lock failures for other transactions — and still takes row locks that block writers. At scale, a phantom update per entity per transaction is a serious write amplifier. It is also an early warning that some mapped type does not round-trip faithfully, which usually has correctness implications beyond the extra statement. ## The mechanism Dirty checking compares each property against the value captured when the entity was loaded, using the mapped type's equality. A phantom update means one of those comparisons reports a difference. There are only two ways that happens: something really did change the object (application code, or a lifecycle callback), or the value the property holds is not equal to the value Hibernate believes was loaded, even though they represent the same data. ## Common causes, concretely **BigDecimal scale.** `BigDecimal.equals` is scale-sensitive: `new BigDecimal("2.50")` is not equal to `new BigDecimal("2.5")`. If the application sets a value with one scale and the column returns another (a `NUMERIC(10,2)` always returns scale 2), every flush sees a difference. Fix by matching the column definition and normalising with `setScale` on write. **Date and time types.** Mapping to `java.util.Date` while the driver returns `java.sql.Timestamp`, or storing nanosecond-precision values in a column that truncates to microseconds/milliseconds, means the value in memory never equals the value read back. Modern mappings (`LocalDateTime`, `Instant`, `OffsetDateTime`) with a column precision that matches the Java precision avoid this. Timezone conversion applied asymmetrically has the same effect. **Custom types and converters.** A `UserType` must implement equality and a deep copy consistently; a bad `deepCopy` that returns the same mutable instance means the snapshot mutates along with the entity (changes go undetected — the opposite failure), while a bad `equals` means unchanged values always look different. `AttributeConverter` implementations that are not exact inverses (trimming, case-folding, default substitution) produce a stable mismatch on every load. **JSON / semi-structured columns.** Serialising a map or object to JSON rarely round-trips byte-identically: key order, whitespace, number formatting and null handling all vary. If equality is defined on the serialised string, the column looks changed constantly. Compare on the parsed structure, or store a canonical form. **CHAR padding and string normalisation.** A `CHAR(10)` column returns space-padded values; if the code trims on read (or the driver does) the in-memory value differs from what the snapshot holds. The same applies to any normalisation — trimming, case folding, Unicode normalisation — applied in a getter under property access, since Hibernate then reads the normalised value while the snapshot holds the raw one. **Enums, booleans and defaults.** Asymmetric mappings — an enum stored as a string but compared as an ordinal somewhere, a boolean stored as `'Y'/'N'` with a converter that maps `null` to `false` — turn a null column into a non-null value on every load. **Genuine but invisible mutation.** Before blaming types, rule out code that really does modify the entity: a mapper writing back into the source object, a normalisation step in a service, a defaulting rule (“if country is null set it to the tenant default”) executed on every read path, or a lifecycle callback stamping a field unconditionally. These are frequent and easy to miss because they live far from the persistence code. **Collections.** A mapped collection with `@OrderColumn` whose in-memory order differs from the stored order, or a `Set` whose elements' equality is unstable, produces phantom collection updates — the collection equivalent of the same problem. ## How to find the culprit 1. **Turn on SQL logging** and confirm the statement really is an UPDATE with no meaningful change, and how often it fires. 2. **Add `@DynamicUpdate` to the suspect entity temporarily.** The generated statement now contains only the changed columns — the offending column names itself in one line. This is the fastest step and the one interviewers like to hear. 3. **Check Hibernate statistics** (`entityUpdateCount`, flush counts) to size the problem and to verify the fix. 4. **Bisect by mapping** if needed: mark the suspect property with `@Column(insertable = false, updatable = false)` or drop it from the entity in a scratch build and see whether the update disappears. 5. **Reproduce in a focused test**: load the entity, flush without touching it, and assert that no statement is emitted. Keep that test — it is the regression guard. 6. For repeated cases across a codebase, a flush-time hook that reports which properties were considered dirty can be used as a temporary instrument, but the dynamic-update trick answers the question faster in most situations. ## Fixes Make the round trip faithful: align column definitions with Java types and precision, normalise values at the boundary rather than in getters, implement equality and deep copy correctly in custom types, canonicalise JSON, and stop code from mutating entities on read paths — loading read-only makes that structurally impossible for query results you never intend to write.
- Why are phantom updates more than a cosmetic problem in a busy system?Each one is a real write: a row lock, a new row version, WAL/redo volume, trigger and change-data-capture events, and — on a versioned entity — an increment that can make other transactions fail optimistic-lock checks for no reason. Multiplied across every entity loaded in every transaction, it becomes significant write amplification and a source of spurious conflicts and downstream event noise.
- A custom type causes the opposite problem — real changes are never detected. What is the likely defect?Its deep copy is not a copy: the loaded-state snapshot ends up referencing the same mutable object the entity holds, so mutating the value changes the snapshot too and the comparison always reports equal. Custom mutable types must return an independent copy from deepCopy and implement value-based equality; safer still, make the value type immutable so the question does not arise.
saying these in an interview costs you the question
- Suppressing the symptom by marking the column non-updatable without finding the cause
- Assuming BigDecimal comparison ignores scale
- Blaming the database or the connection pool before checking the mapping and the round trip
- Not knowing that a temporary @DynamicUpdate identifies the column immediately
- Treating a phantom update as harmless because “the values are the same”