Definition
- persist() -- makes a new transient entity managed in the persistence context and schedules an INSERT on flush. Returns void. The passed object itself becomes managed.
- merge() -- copies the state of a detached (or new) entity into a managed instance and returns that managed copy. The original argument stays detached.
- Transient entity -- a new object that has never been persisted and has no persistence context association.
- Detached entity -- an object that was previously managed but is no longer associated with a persistence context (e.g., from a closed transaction or serialized from a web layer).
- Spring Data save() -- checks if the entity is new (null ID or null @Version) and delegates to persist() or merge() accordingly, always returning the managed instance.
At National Bank of India, Rajesh walks in with a brand new account-opening form. persist() is the clerk stamping that exact form and filing it in the vault: the form Rajesh holds IS the official record. merge() is different: Rajesh brings a photocopy of an old form with edits scribbled on it. The clerk does NOT file the photocopy. Instead, the clerk pulls the official file from the vault, copies Rajesh's edits onto it, and files the official copy back. Rajesh's photocopy stays unofficial. Spring Data save() is the smart front-desk person who checks whether Rajesh already has an account number and decides whether to file a new form (persist) or copy edits (merge).
Understanding the Concepts
What is persist()?
persist() takes a transient entity (a new object that has never been saved) and makes it managed in the persistence context. It schedules an INSERT that will execute on flush or commit. The passed object itself becomes managed, so any later field changes on the same object are tracked by Hibernate's dirty checking.
- Returns void: the same object is now managed.
- The entity must be transient (new). Calling persist on a detached entity throws PersistenceException.
- The INSERT may not hit the database until flush (write-behind).
- After persist, the generated ID is available (if using SEQUENCE strategy; with IDENTITY, flush happens immediately).
package com.bank.service;
import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class AccountCreationService {
@PersistenceContext
private EntityManager em;
@Transactional
public void openAccount() {
Account account = new Account(); // transient
account.setAccountId("SAV2001");
account.setHolderName("Rajesh Kumar");
account.setBalance(new BigDecimal("50000.00"));
em.persist(account); // now managed, INSERT scheduled
// Same object is managed: changes are tracked
account.setBalance(new BigDecimal("55000.00"));
// Dirty checking detects the change
// On flush: INSERT with balance=55000 (not 50000)
}
}
SQL generated on flush:
INSERT INTO accounts (account_id, holder_name, balance)
VALUES ('SAV2001', 'Rajesh Kumar', 55000.00)
Note: only ONE insert, not insert + update.
Dirty checking merged the change before flush.
What is merge()?
merge() is for detached entities. It loads (or finds in the persistence context) the current managed instance, copies the detached object's state onto it, and returns the managed instance. The original detached object stays detached. This is the critical trap: you must use the returned object, not the one you passed in.
- Returns the managed instance (may be a different object than the argument).
- If no managed instance exists in the context, Hibernate issues a SELECT to load it.
- If the entity is new (ID is null), merge works like persist but still returns a managed copy.
- Changes on the original detached object after merge are NOT tracked.
package com.bank.service;
import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class AccountUpdateService {
@PersistenceContext
private EntityManager em;
@Transactional
public void updateAddress(Account detachedAccount) {
// detachedAccount came from a previous transaction or web layer
Account managed = em.merge(detachedAccount);
// MUST use 'managed', not 'detachedAccount'
managed.setBalance(new BigDecimal("75000.00"));
// This change IS tracked by dirty checking
detachedAccount.setBalance(new BigDecimal("99999.00"));
// This change is LOST: detachedAccount is still detached
}
}
SQL generated:
1. SELECT * FROM accounts WHERE account_id = 'SAV2001'
(merge loads the managed state)
2. UPDATE accounts SET holder_name=?, balance=75000.00
WHERE account_id = 'SAV2001'
(dirty checking detected the balance change on the managed copy)
Note: the extra SELECT is the performance cost of merge.
How is persist() different from merge()?
| Aspect | persist() | merge() |
|---|---|---|
| Input entity state | Transient (new) | Detached or new |
| Return type | void | Managed instance |
| Object identity | Same object becomes managed | Returns a different managed copy |
| On detached entity | Throws PersistenceException | Works correctly |
| Extra SELECT | No | Yes (to load managed state) |
| Dirty checking | On the same object | On the returned copy only |
| Use case | Creating new entities | Reattaching edited detached entities |
package com.bank.service;
import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class PersistVsMergeDemo {
@PersistenceContext
private EntityManager em;
@Transactional
public void demonstratePersist() {
Account account = new Account();
account.setAccountId("SAV2001");
em.persist(account);
// account == managed instance (same object)
account.setBalance(new BigDecimal("50000.00")); // tracked!
}
@Transactional
public void demonstrateMerge(Account detached) {
Account managed = em.merge(detached);
// managed != detached (different objects)
managed.setBalance(new BigDecimal("50000.00")); // tracked!
detached.setBalance(new BigDecimal("99999.00")); // LOST!
}
}
How does Spring Data save() work internally?
Spring Data JPA's SimpleJpaRepository.save() checks whether the entity is new using the isNew() method. If the ID is null or the @Version field is null, it calls em.persist() and returns the same instance. Otherwise, it calls em.merge() and returns the merged managed instance.
// From SimpleJpaRepository (simplified):
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity; // same object
} else {
return em.merge(entity); // may be different object
}
}
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;
import java.math.BigDecimal;
@Service
public class AccountService {
private final AccountRepository repo;
public AccountService(AccountRepository repo) {
this.repo = repo;
}
@Transactional
public void createAccount() {
Account account = new Account();
account.setAccountId("SAV2001");
account.setBalance(new BigDecimal("50000.00"));
// ALWAYS reassign! save() may return a different object via merge
account = repo.save(account);
// If new: persist() called, same object returned
// If existing: merge() called, different object may be returned
}
}
IsNew check logic:
1. ID is null -> new -> persist()
2. @Version field is null -> new -> persist()
3. ID is not null and @Version is not null -> existing -> merge()
For entities with manually assigned IDs (like account_id = "SAV2001"),
ID is never null, so save() always calls merge() even for new entities.
Fix: implement Persistable<String> and override isNew().
What are common mistakes with persist() and merge()?
The biggest mistakes are calling persist on detached entities and ignoring the return value of merge.
// WRONG: persist() on a detached entity
@Transactional
public void updateAccount(Account detachedFromWeb) {
em.persist(detachedFromWeb);
// Throws: PersistenceException
// "detached entity passed to persist"
}
// CORRECT: use merge() for detached entities
@Transactional
public void updateAccount(Account detachedFromWeb) {
Account managed = em.merge(detachedFromWeb);
// Use 'managed' for all further operations
}
// WRONG: ignoring merge() return value
@Transactional
public void updateBalance(Account detached, BigDecimal newBalance) {
em.merge(detached);
detached.setBalance(newBalance); // LOST! detached is still detached
}
// CORRECT: use the returned managed instance
@Transactional
public void updateBalance(Account detached, BigDecimal newBalance) {
Account managed = em.merge(detached);
managed.setBalance(newBalance); // Tracked by dirty checking
}
// WRONG: not reassigning after save()
public void createAccount() {
Account account = new Account();
repo.save(account);
System.out.println(account.getId()); // may be null if merge returned different object
}
// CORRECT: always reassign
public void createAccount() {
Account account = new Account();
account = repo.save(account);
System.out.println(account.getId()); // guaranteed to have the ID
}
What about performance considerations?
merge() may trigger an extra SELECT to load the managed state from the database. This makes it slower than persist() for new entities and slower than direct JPQL UPDATE for bulk operations. For large batch updates, prefer a JPQL UPDATE query over loading and merging thousands of detached objects.
package com.bank.service;
import com.bank.repository.AccountRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
public interface AccountRepository {
// SLOW: merge thousands of detached objects
// Each merge triggers a SELECT + UPDATE
// 10,000 accounts = 20,000 SQL statements
// FAST: direct JPQL UPDATE
@Modifying
@Query("UPDATE Account a SET a.balance = a.balance * 1.05 "
+ "WHERE a.status = 'ACTIVE'")
int applyAnnualInterest();
// One SQL statement for all rows. No SELECT per entity.
}
Performance comparison (updating 10,000 accounts):
merge() loop: 10,000 SELECTs + 10,000 UPDATEs = ~20 seconds
JPQL UPDATE: 1 UPDATE statement = ~0.1 seconds
What about merge() and full entity overwrites?
merge() copies the ENTIRE state of the detached object, including fields you did not intend to change. If the mobile app sends a stale balance along with a new address, merge overwrites the balance with the stale value. This is dangerous in banking.
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 SafeUpdateService {
private final AccountRepository repo;
public SafeUpdateService(AccountRepository repo) {
this.repo = repo;
}
// WRONG: blind merge overwrites all fields
@Transactional
public void updateAddressUnsafe(Account fromMobileApp) {
repo.save(fromMobileApp);
// If mobile app sent stale balance, balance is now wrong!
}
// CORRECT: load fresh entity, update only intended fields
@Transactional
public void updateAddressSafe(String accountId, String newAddress) {
Account account = repo.findById(accountId).orElseThrow();
account.setAddress(newAddress);
// Only address is updated. Balance stays at its current value.
// Dirty checking generates: UPDATE SET address=? WHERE id=?
}
}
What should a senior engineer know?
A senior engineer should understand the object identity semantics, the Persistable interface for manual ID assignment, and the transactional boundaries that affect persist and merge behavior.
- persist() guarantees the same object reference becomes managed. merge() may return a proxy or a different loaded instance.
- For entities with manually assigned IDs (like account numbers), implement
Persistableand overrideisNew()to control whether save() calls persist or merge. - merge() inside a transaction that already has the entity loaded in the persistence context does NOT issue a SELECT; it reuses the cached instance.
- The cascade setting
CascadeType.PERSISTandCascadeType.MERGEcontrol whether child entities are automatically persisted or merged when the parent is.
package com.bank.model;
import jakarta.persistence.*;
import org.springframework.data.domain.Persistable;
import java.math.BigDecimal;
@Entity
@Table(name = "accounts")
public class Account implements Persistable<String> {
@Id
@Column(name = "account_id")
private String accountId;
@Column(precision = 15, scale = 2)
private BigDecimal balance;
@Transient
private boolean isNew = true;
@Override
public String getId() { return accountId; }
@Override
public boolean isNew() { return isNew; }
@PostLoad
@PostPersist
void markNotNew() { this.isNew = false; }
}
// Now save() correctly calls persist() for new accounts
// even though the ID is already set (non-null).
Visual Overview
persist() flow:
┌────────────────┐ persist() ┌────────────────────────┐
│ New Account │ ───────────> │ Persistence Context │
│ (transient) │ │ ┌──────────────────┐ │
│ accountId=null │ │ │ Account (managed) │ │
│ │ │ │ SAME object │ │
└────────────────┘ │ └──────────────────┘ │
│ INSERT scheduled │
└────────────────────────┘
merge() flow:
┌────────────────┐ merge() ┌────────────────────────┐
│ Detached Acct │ ───────────> │ Persistence Context │
│ (from web) │ │ │
│ stale version │ SELECT │ ┌──────────────────┐ │
│ │ <────────── │ │ Load from DB │ │
└────────────────┘ │ └──────┬───────────┘ │
(stays detached) │ Copy state onto: │
│ ┌──────────────────┐ │
│ │ Managed copy │ │
│ │ (RETURNED) │ │
│ └──────────────────┘ │
└────────────────────────┘
save() decision:
┌───────────┐ isNew()? ┌──────────────┐
│ save() │ ──────────> │ ID == null │──Yes──> persist()
└───────────┘ │ or │
│ @Version │
│ == null │──No───> merge()
└──────────────┘
Key Takeaway
The core difference between persist and merge is object identity. persist() takes a brand new transient entity, schedules an INSERT, and the exact object you passed in becomes managed, so later field changes are tracked by dirty checking. It returns void and will fail if you hand it a detached entity. merge() is designed for detached entities: it loads or finds the managed instance, copies your detached object's state onto it, and returns that managed instance. The object you passed to merge stays detached, which is the single biggest gotcha.
Spring Data JPA's save() sits on top of both. It checks whether the entity is new by looking at a null ID or null @Version field. If it is new, save delegates to persist and returns the same instance. If it is not new, save delegates to merge and returns a possibly different managed instance. Because of this behavior, always write account = repo.save(account) so you never lose the generated ID or managed state.
In our National Bank of India system, when Rajesh opens a fresh savings account we use persist because there is no existing row and the same object stays managed through the transaction. When Priya edits her address in the mobile app and sends the whole account object back, it arrives detached, so merge is used, and the returned managed object is carefully used for all further operations. The performance nuance is that merge can trigger an extra SELECT, so for large bulk updates a direct JPQL UPDATE is far more efficient than loading and merging thousands of detached objects.
Remember one sentence: persist() makes the same new object managed, merge() copies a detached object's state into a managed copy and returns it, so always use the returned value.
---