Back to JPA Fundamentals
Q3Question 3 of 5

What is persist() vs merge()?

Key Concepts
  • persist() makes a new transient entity managed and schedules an INSERT, returning void.
  • merge() copies the state of a detached entity into a managed instance and returns that managed copy.
  • The object passed to merge() stays detached: you must use the returned managed instance.
  • persist() throws PersistenceException if called on a detached entity.
  • Spring Data save() delegates to persist() for new entities and merge() for existing ones based on the ID or @Version field.
  • Always reassign after save(): `account = repo.save(account)` because merge may return a different object.
  • merge() may trigger an extra SELECT to load the managed state, making it slower for bulk updates than direct JPQL.

Definition

persist() attaches the same new object to the persistence context and schedules an INSERT, while merge() copies a detached object's state into a managed copy and returns that copy.
  • 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).
JAVA
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)
    }
}
TEXT
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.
JAVA
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
    }
}
TEXT
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 |

JAVA
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.

JAVA
// 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
    }
}
JAVA
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
    }
}
TEXT
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.

JAVA
// 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
}
JAVA
// 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
}
JAVA
// 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.

JAVA
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.
}
TEXT
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.

JAVA
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 Persistable and override isNew() 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.PERSIST and CascadeType.MERGE control whether child entities are automatically persisted or merged when the parent is.
JAVA
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

DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate