Definition
- Static UPDATE -- Hibernate caches one pre-generated UPDATE per entity that sets every mapped column, so even a single field change writes all columns.
- Dirty checking -- at flush, Hibernate compares each managed entity's current fields to its load-time snapshot; if anything differs, the static UPDATE runs.
- @DynamicUpdate -- tells Hibernate to generate the UPDATE at runtime with only the dirty columns, reducing unnecessary writes.
- Statement reuse -- the default static UPDATE can be prepared once and reused, cooperating well with the JDBC statement cache.
- merge() null-overwrite --
merge()copies the entire detached entity's state onto the managed entity, so any field that is null on the detached object overwrites the database value with null.
Imagine National Bank of India's update form always has every field printed on it, and Rajesh re-writes Priya's entire record -- name, address, phone, balance -- every time even if only the phone number changed. It is faster to use one pre-printed form than to custom-print a new form each time. But if Priya hands in a form where she only filled the phone number and left name and address blank, and Rajesh copies the whole form over the record, the blanks wipe out her existing name and address.
Understanding the Concepts
Why does Hibernate UPDATE set every column?
Dirty checking decides whether to update. The static UPDATE decides what to write. When an entity becomes managed, Hibernate snapshots its fields. At flush, it compares current values to the snapshot. If anything differs, the entity is dirty and needs an UPDATE. By default Hibernate uses a pre-generated UPDATE that includes every column.
package com.bank.service;
import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class FullUpdateDemo {
private final EntityManager em;
public FullUpdateDemo(EntityManager em) {
this.em = em;
}
@Transactional
public void updatePhoneOnly() {
Account account = em.find(Account.class, "SAV2001");
account.setPhone("9876543210"); // only phone changed
// Default SQL at flush:
// UPDATE accounts SET holder_name=?, balance=?, phone=?, status=?
// WHERE id=?
// ALL columns written, even though only phone changed
}
}
Expected SQL:
UPDATE accounts SET holder_name='Priya', balance=50000.00,
phone='9876543210', status='ACTIVE' WHERE id='SAV2001'
-- All 4 columns written, only phone actually changed
Why does Hibernate use a static UPDATE?
Preparing one statement at startup and reusing it is cheaper than building a custom statement on every flush. The reused prepared statement also cooperates well with the JDBC driver's statement cache and the database's query plan cache.
Static UPDATE advantages:
1. Prepared once at startup -- no per-flush SQL generation cost
2. JDBC statement cache hit -- same SQL string every time
3. Database query plan cache -- same execution plan reused
4. Simpler code path -- no per-flush column diff logic
Static UPDATE downsides:
1. Writes columns that did not change
2. Can trigger database column-level triggers unnecessarily
3. Slightly more network bytes for wide tables
4. Audit-column logic may record unchanged fields as "modified"
How does @DynamicUpdate fix this?
@DynamicUpdate on the entity tells Hibernate to generate the UPDATE at runtime with only the dirty columns. For a phone-only change, the SQL writes only the phone column.
package com.bank.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.annotations.DynamicUpdate;
import java.math.BigDecimal;
@Entity
@Table(name = "accounts")
@DynamicUpdate
public class Account {
@Id
private String id;
private String holderName;
private BigDecimal balance;
private String phone;
private String status;
// With @DynamicUpdate:
// Changing only phone generates:
// UPDATE accounts SET phone=? WHERE id=?
}
Without @DynamicUpdate:
UPDATE accounts SET holder_name=?, balance=?, phone=?, status=? WHERE id=?
With @DynamicUpdate:
UPDATE accounts SET phone=? WHERE id=?
-- Only the changed column is written
When should you use @DynamicUpdate and when should you not?
Use @DynamicUpdate selectively. It is most valuable on wide tables, tables with column-level triggers, or tables where unnecessary writes cause problems. It has a small cost because Hibernate must generate SQL per flush and loses the reusable prepared statement.
| Scenario | @DynamicUpdate? | Reason |
|----------|----------------|--------|
| Wide table (30+ columns) | Yes | Reduces network + trigger firing |
| Table with column-level triggers | Yes | Avoids unnecessary trigger execution |
| Narrow table (3-5 columns) | No | Cost of dynamic SQL > benefit |
| Bulk update of same columns | No | Static UPDATE batches better |
| Optimistic locking (@Version) | Either | Version column always included |
// Good candidate: wide table with many triggers
@Entity
@DynamicUpdate
public class CustomerProfile {
@Id private Long id;
private String firstName;
private String lastName;
private String email;
private String phone;
private String address;
private String city;
private String state;
private String zip;
// 20+ more fields -- @DynamicUpdate saves significant bytes
}
// Poor candidate: narrow table
@Entity
public class AccountStatus {
@Id private String accountId;
private String status;
// Only 2 columns -- @DynamicUpdate overhead not worth it
}
Rule of thumb:
- Wide tables or trigger-heavy tables: use @DynamicUpdate
- Narrow tables or bulk-update paths: skip it
- Always measure with Hibernate statistics
How can merge() accidentally overwrite columns with null?
merge(detached) copies the entire state of the detached entity onto the managed entity loaded from the database. If the detached entity has null fields, those nulls overwrite the existing database values.
package com.bank.service;
import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MergeNullOverwriteDemo {
private final EntityManager em;
public MergeNullOverwriteDemo(EntityManager em) {
this.em = em;
}
@Transactional
public void dangerousMerge() {
// Detached entity built from API request -- only phone set
Account fromRequest = new Account();
fromRequest.setId("SAV2001");
fromRequest.setPhone("9876543210");
// holderName = null, balance = null, status = null
em.merge(fromRequest);
// merge loads SAV2001 from DB, copies ALL fields from fromRequest
// holderName BLANKED to null!
// balance BLANKED to null!
// status BLANKED to null!
}
}
Before merge:
DB: id=SAV2001, holderName='Priya', balance=50000, phone='1111', status='ACTIVE'
Detached entity:
id=SAV2001, holderName=null, balance=null, phone='9876543210', status=null
After merge:
DB: id=SAV2001, holderName=null, balance=null, phone='9876543210', status=null
-- Priya's name and balance WIPED OUT!
How do you safely do partial updates?
The correct pattern is to load the managed entity and set only the fields you intend to change, letting dirty checking handle the UPDATE.
package com.bank.service;
import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SafePartialUpdateService {
private final AccountRepository accountRepo;
public SafePartialUpdateService(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
// CORRECT: load managed entity and set only what changed
@Transactional
public void updatePhone(String accountId, String newPhone) {
Account account = accountRepo.findById(accountId).orElseThrow();
account.setPhone(newPhone); // only phone changes
// dirty checking generates UPDATE -- other fields untouched
}
// ALSO CORRECT: JPQL update for targeted column change
@Transactional
public void updatePhoneJpql(String accountId, String newPhone) {
accountRepo.updatePhone(accountId, newPhone);
// UPDATE accounts SET phone=? WHERE id=?
}
}
// WRONG: mapping a request DTO to an entity and merging
@Transactional
public void updateFromRequestWrong(AccountUpdateRequest request) {
Account entity = new Account();
entity.setId(request.getId());
entity.setPhone(request.getPhone()); // only phone in request
em.merge(entity); // DANGER: null fields overwrite existing data!
}
// CORRECT: load and set
@Transactional
public void updateFromRequestCorrect(AccountUpdateRequest request) {
Account entity = accountRepo.findById(request.getId()).orElseThrow();
entity.setPhone(request.getPhone()); // only set what the request provides
// dirty checking handles the rest safely
}
Safe partial update patterns:
1. Load managed entity + set fields (dirty checking)
2. JPQL UPDATE query for targeted column changes
3. NEVER build a partial entity and merge -- null fields overwrite!
What are common mistakes with dynamic update and merge?
The most common mistake is merging a partially-populated entity from an API request without realizing that null fields overwrite existing data. The second is applying @DynamicUpdate globally when most tables are narrow.
// WRONG: @DynamicUpdate on every entity
@Entity @DynamicUpdate // unnecessary for a 3-column table
public class Flag { @Id Long id; String name; boolean active; }
// CORRECT: @DynamicUpdate only on wide/trigger-heavy tables
@Entity @DynamicUpdate // 30+ columns, column-level audit triggers
public class CustomerProfile { /* ... many fields ... */ }
Common mistakes:
1. Merging partial entities -- null fields overwrite existing data
2. Applying @DynamicUpdate everywhere -- small cost adds up
3. Not knowing that @DynamicUpdate still checks @Version
4. Confusing dirty checking (decides IF) with @DynamicUpdate (decides WHAT)
What should a senior engineer know?
A senior engineer understands that the default all-columns UPDATE is intentional for statement reuse. They apply @DynamicUpdate selectively on wide tables. They never merge a partially-populated entity from an API request. For partial updates they prefer load-and-mutate or a JPQL update query. They know that @DynamicUpdate and JDBC batching can conflict because dynamic updates produce varying SQL that cannot batch.
package com.bank.service;
import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SeniorUpdatePattern {
private final AccountRepository accountRepo;
public SeniorUpdatePattern(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
@Transactional
public void partialUpdate(String id, String phone) {
Account account = accountRepo.findById(id).orElseThrow();
account.setPhone(phone);
// @DynamicUpdate on Account -> UPDATE accounts SET phone=? WHERE id=?
// Without @DynamicUpdate -> UPDATE accounts SET all_columns WHERE id=?
// Both safe; @DynamicUpdate just reduces the SQL width
}
}
Senior checklist:
1. Default all-columns UPDATE is intentional -- statement reuse
2. @DynamicUpdate for wide tables and trigger-heavy tables only
3. Never merge a partially-populated entity -- null overwrites
4. Partial updates: load + set fields, or JPQL UPDATE
5. @DynamicUpdate and batching can conflict (varying SQL)
6. @Version column is always included regardless of @DynamicUpdate
Visual Overview
Dirty Checking + UPDATE generation:
┌─────────────────────────────────────────────┐
│ Persistence Context │
│ │
│ Snapshot: Current: │
│ name = 'Priya' name = 'Priya' (same) │
│ balance = 50000 balance = 50000 (same) │
│ phone = '1111' phone = '9876' CHANGED │
│ status = 'ACTIVE' status = 'ACTIVE'(same) │
└──────────────────────┬──────────────────────┘
│
┌─────────────┴────────────────┐
│ │
Without @DynamicUpdate With @DynamicUpdate
│ │
UPDATE accounts UPDATE accounts
SET name=?, SET phone=?
balance=?, WHERE id=?
phone=?,
status=?
WHERE id=?
(all columns) (only changed)
merge() null-overwrite trap: Database row: Detached entity: ┌─────────────────┐ ┌─────────────────┐ │ name = 'Priya' │ │ name = null │ │ balance = 50000 │ ◄── │ balance = null │ merge copies │ phone = '1111' │ │ phone = '9876' │ ALL fields │ status = 'ACTIVE'│ │ status = null │ └─────────────────┘ └─────────────────┘ Result: name, balance, status WIPED to null!
Key Takeaway
By default Hibernate generates one static UPDATE per entity that sets every mapped column, even when only one field changed. This is intentional: preparing one statement once and reusing it is cheaper than building custom SQL on every flush, and it cooperates with the JDBC statement cache. Dirty checking decides whether to update by comparing the current state to the load-time snapshot; the static UPDATE decides what columns to include.
@DynamicUpdate tells Hibernate to generate the UPDATE at runtime with only the dirty columns, which is valuable on wide tables, tables with column-level triggers, or tables where unnecessary writes cause problems. Apply it selectively because it loses the reusable prepared statement and adds per-flush SQL generation cost.
The separate and more dangerous issue is the merge null-overwrite trap. merge() copies the entire detached entity's state onto the managed entity, so if you build an entity from an API request that only set the phone and left name and balance null, merge copies those nulls and wipes the existing data. At National Bank of India, a profile update API did exactly this and blanked customers' names. The fix is to load the managed entity and set only the fields you intend to change, letting dirty checking persist the partial update safely.
Remember one sentence: the default all-columns UPDATE is for statement reuse, @DynamicUpdate writes only changed columns for wide tables, and never merge a partially-populated entity because null fields overwrite existing data.
---