💾 Section 16 · Question #14

What is the Difference Between persist() and merge()

In JPA and Hibernate, persist() and merge() are core methods of the EntityManager interface used to bring entity objects under the management of the PersistenceContext. However,...

In JPA and Hibernate, persist() and merge() are core methods of the EntityManager interface used to bring entity objects under the management of the PersistenceContext. However, they are designed for fundamentally different entity lifecycle states, exhibit distinct memory semantics, and trigger entirely different sequences of SQL operations.


🟢 Junior Level

Core Differences: Executive Summary

  • persist(entity): Designed exclusively for new entities in the Transient state (objects that do not yet exist in the database). It transitions the passed Java instance directly into the Managed state in-place and returns void.
  • merge(entity): Designed for entities in the Detached state (objects previously persisted whose persistence context has closed). It locates or loads an existing entity from the database, copies the state from the detached object onto the managed instance, and returns a reference to that new managed instance.
                   PERSIST VS MERGE: HEAP MEMORY BEHAVIOR
                   
    1. INVOCATION OF PERSIST:
       [ user (Transient) ] ──persist(user)──> [ user (Managed) ]
       (The exact same Java instance becomes managed in-place; returns void)

    2. INVOCATION OF MERGE:
       [ detachedUser ] ──merge(detachedUser)──> [ managedUser (Managed) ]
                                                         │
       (detachedUser REMAINS Detached!                   ▼
        All subsequent modifications must be made to managedUser!)

Comparative Analysis

Feature entityManager.persist(entity) entityManager.merge(entity)
Target Lifecycle State Transient (New entity) Detached (Detached entity)
Return Value void Reference to the Managed instance ($T$)
Passed Object Status Becomes Managed Remains Detached
Generated SQL Only SQL INSERT (during flush) SQL SELECT, then UPDATE or INSERT
Behavior if ID Exists in L1 Throws EntityExistsException Updates the existing managed instance

Practical Code Examples

// 1. Using persist():
User newUser = new User();
newUser.setName("Maxim");

entityManager.persist(newUser); 
// newUser is now MANAGED. You can mutate its fields directly:
newUser.setName("Maxim Updated"); // Dirty checking automatically persists this on flush!

// 2. Using merge():
User detachedUser = fetchFromExternalRestService(); // Entity with ID=1, outside session
detachedUser.setName("New Name");

// ❌ COMMON JUNIOR BUG:
// entityManager.merge(detachedUser);
// detachedUser.setEmail("test@example.com"); // NEVER SAVED TO DATABASE!

// ✅ PRODUCTION STANDARD:
User managedUser = entityManager.merge(detachedUser);
managedUser.setEmail("test@example.com"); // Successfully persisted by Dirty Checking!

🟡 Middle Level

Internal Execution Algorithm of merge()

When an application invokes entityManager.merge(detachedEntity):

  1. Hibernate extracts the primary key (id) from the passed object.
  2. It inspects the First-Level Cache (entitiesByKey) for a managed entity with that ID.
  3. If the entity is absent from the active persistence context, Hibernate executes an SQL SELECT against the database.
  4. If the database record is found:
    • Hibernate retrieves or instantiates the Managed entity instance.
    • It copies all property values from detachedEntity onto this Managed instance.
    • It schedules an SQL UPDATE in the ActionQueue (if dirty checking detects state changes).
  5. If the database record is not found (or the ID was null):
    • Hibernate creates a new Managed entity instance.
    • It copies property values from detachedEntity.
    • It schedules an SQL INSERT in the ActionQueue.
  6. Finally, it returns the reference to the Managed instance.

Why Calling merge() for New Entities is an Anti-Pattern

Developers often default to using merge() universally because “it handles both new and existing entities”:

User newUser = new User();
newUser.setName("Oleg");
entityManager.merge(newUser); // Technically succeeds, BUT...

The Hidden Overhead:

  • If the ID generation strategy relies on manual assignment or is not null, Hibernate fires an unnecessary penalty SQL SELECT to confirm whether the row exists in the database.
  • It allocates an extra duplicate object instance on the JVM heap.
  • For new entities, always invoke persist().

Cascading Rules: CascadeType.PERSIST vs CascadeType.MERGE

When entities manage relationships:

@OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST)
private List<OrderItem> items = new ArrayList<>();
  • Invoking entityManager.persist(order) cascades to all elements in items.
  • However, calling entityManager.merge(order) will not cascade to items unless CascadeType.MERGE (or ALL) is explicitly included. The child elements will remain detached, and modifications made to them will be silently lost.

🔴 Senior Level

Under the Hood of Spring Data JPA: How repository.save() Operates

Many developers believe Spring Data JPA’s save() executes a specialized internal operation. In reality, SimpleJpaRepository implements a conditional routing mechanism:

@Transactional
public <S extends T> S save(S entity) {
    if (entityInformation.isNew(entity)) {
        entityManager.persist(entity);
        return entity;
    } else {
        return entityManager.merge(entity);
    }
}

The “Assigned ID” Penalty Problem:

How does Spring Data determine entityInformation.isNew(entity)?

  1. Default Strategy: If the @Id property is null (or 0 for numeric primitives), the entity is recognized as new (isNew() == true), and Spring calls persist().
  2. The Assigned ID Dilemma: When using manually assigned identifiers (business UUIDs, national IDs, or composite keys), the primary key is already populated before saving (id != null).
  3. Spring Data misinterprets the entity as Detached (isNew() == false)!
  4. Instead of issuing a fast direct SQL INSERT, Spring invokes entityManager.merge(entity).
  5. Hibernate executes a parasitic preliminary SQL SELECT, discovers no row exists, and only then issues the SQL INSERT.

Architectural Solution: The entity must implement org.springframework.data.domain.Persistable<ID>:

@Entity
public class Payment implements Persistable<String> {
    @Id
    private String transactionUuid;

    @Transient
    private boolean isNewEntity = true;

    @Override
    public String getId() { return transactionUuid; }

    @Override
    public boolean isNew() { return isNewEntity; }

    @PostPersist
    @PostLoad
    void markNotNew() { this.isNewEntity = false; }
}

With Persistable implemented, Spring calls persist() directly, eliminating the redundant database round-trip.

Cascading merge() Pitfalls with Uninitialized Lazy Collections

Consider the following scenario:

  1. In Transaction 1, a Department entity is loaded with an uninitialized lazy collection employees (FetchType.LAZY).
  2. The transaction closes without accessing employees; the collection remains an uninitialized PersistentBag proxy in the detached Department.
  3. In Transaction 2, entityManager.merge(detachedDept) is invoked.
  4. Internal Behavior: Hibernate’s DefaultMergeEventListener inspects collection states. If employees is configured with CascadeType.MERGE or ALL and was left untouched:
    • Hibernate maintains the uninitialized proxy state in the managed copy.
    • However, if the developer replaced the collection on the detached instance (detachedDept.setEmployees(new ArrayList<>())), Hibernate interprets this as the explicit removal of all previous employees (Orphan Removal) and issues SQL DELETE statements against all child records in the database!

4 Tricky Questions

1. What happens if User(id = 1) is already loaded in the active session (Managed), and we invoke entityManager.persist(user2) with the same id = 1? What if we invoke entityManager.merge(user2)?

Answer:

  1. For persist(user2): Hibernate inspects entitiesByKey. Upon finding that an entity with that type and primary key already exists in the PersistenceContext, it immediately throws: jakarta.persistence.EntityExistsException (or NonUniqueObjectException).
  2. For merge(user2): No exception occurs. Hibernate retrieves the existing User(id = 1) instance from the L1 cache, copies all property values from user2 into it, and returns the managed reference. Zero SQL SELECT queries are executed because the entity is already in memory.

2. Why does persist() return void while merge() returns an object of type T?

Answer:

  1. persist(e) mutates the lifecycle state of the passed Java instance in-place. The object reference e you already possess becomes managed in the PersistenceContext. Returning it would be redundant.
  2. merge(e) cannot make the passed instance e managed because an instance representing that database row might already exist in the persistence context (which would break the Identity Map pattern: instance1 != instance2). Thus, merge() must return the specific reference attached to the session while leaving the original parameter e in the Detached state.

3. We pass an entity in the Removed state to persist(), and to merge(). What happens in each case?

Answer:

  1. persist(removedEntity): The JPA specification explicitly permits un-removing an entity. The entity transitions back to the Managed state, and its scheduled EntityDeleteAction in the ActionQueue is canceled.
  2. merge(removedEntity): The JPA specification strictly forbids merging a removed entity. Invoking entityManager.merge(removedEntity) throws java.lang.IllegalArgumentException.

4. What happens if you pass a Transient entity with a null ID to merge(), assuming its primary key strategy is GenerationType.SEQUENCE?

Answer:

  1. merge() identifies the entity as new because id == null.
  2. Hibernate queries the database sequence for the next identifier (SELECT nextval(...)).
  3. It instantiates a brand new entity object, assigns the generated ID, copies all properties, and places the new instance into the Managed state.
  4. The original entity instance passed as an argument retains id == null and remains in the Transient state.
  5. The method returns the newly constructed managed entity with the populated ID.

🎯 Interview Cheat Sheet

30-Second Elevator Pitch

“persist() transitions a new (Transient) entity to Managed in-place, returns void, and schedules an SQL INSERT without reading the database. Conversely, merge() transitions a detached entity to Managed by executing an SQL SELECT, copying property values onto a managed instance, and returning that managed reference while the input parameter remains Detached. In Spring Data JPA, repository.save() uses persist() for new entities (id == null) and merge() for detached entities. For entities with manually assigned identifiers, you must implement Persistable<ID> to prevent Spring Data from executing redundant, penalty SELECT queries before every insert.”

Key Production Metrics & Health Checks

  • SELECT-before-INSERT Ratio: A high volume of SELECT queries immediately preceding INSERT statements indicates improper use of merge() or missing Persistable<ID> implementations on assigned-key entities.
  • save() Execution Duration: Significant latency during save() suggests deep object graph traversals and cascading validations during merge().

Red Flags to Avoid

  • ❌ “Calling merge() makes the original input object managed.” — The input instance remains detached; you must use the returned reference.
  • ❌ “Always use merge() because it is universal and handles both INSERT and UPDATE.” — This introduces redundant SELECT queries and doubles heap memory consumption.
  • ❌ “Calling persist() immediately executes an SQL INSERT over the network.” — Except for GenerationType.IDENTITY, SQL statements are deferred until flush().
  • ❌ “You can safely pass a detached entity to persist().” — Passing a detached entity to persist() throws EntityExistsException or PersistentObjectException.