CodeGym /Courses /Hibernate deep-dive /Comparing merge ( ) and find ( ) + mutate

Comparing merge ( ) and find ( ) + mutate

Hibernate deep-dive
Level 5, Lesson 4
Available

1. Comparison on one scenario

Now the picture is complete: you can either bring a detached state into the system and try to merge it through merge(), or first load a managed order and apply a change command to it. Let us reduce both approaches to one order-editing scenario in backoffice — not for the sake of pretty words, but for observable behavior: which objects will be managed, where SELECT will appear, and exactly what Hibernate will decide to update during flush().

Imagine the screen “Edit order.” The operator changes the status and adjusts the quantity of several specific line items. This is a typical “tweak the aggregate in place” operation, and it is exactly on such partial updates that the magic of “why did it update the wrong thing?” usually shows up.

The service receives not a full snapshot of the entity, but a command with only what the user actually changed. And this is where the main difference starts: merge() treats the input as a source state to copy from, while find() + mutate treats it as a set of specific changes. These two models sound similar right up until they run into incomplete input.

2. One input for both paths

Let us take the same command and run it through both approaches. It does not contain the full list of order fields and does not contain the full list of items — only what the operator actually changed.

package com.example.commerce.orders.dto;

import java.util.List;

public record OrderEditRequest(
        Long orderId,                  // order id being edited
        String newStatus,              // new status (for example, "PAID", "CANCELLED")
        List<ItemQtyPatch> itemPatches // patches for line items: change quantity only for the listed itemId values
) {
    public record ItemQtyPatch(
            Long itemId,  // order line item id (OrderItem)
            int newQty    // new quantity for this line item
    ) {}
}

This very request will go both into the branch with merge() and into the branch with find() + mutate. No separate DTOs for each approach — otherwise the comparison turns into a change of conditions far too quickly.

3. Option A: merge()

merge() flow — “copy the state of the detached object”

Let us start with the flow that interprets the input as detached state. We keep the basic contract short in our heads: merge() copies state into a managed instance, and if you want to keep working after that, you should work with the returned object. But what matters more right now is something else: what happens if such a state is a partial order graph.

package com.example.commerce.orders.service;

import com.example.commerce.orders.dto.OrderEditRequest;
import com.example.commerce.orders.entity.OrderItem;
import com.example.commerce.orders.entity.OrderStatus;
import com.example.commerce.orders.entity.PurchaseOrder;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderEditingMergeService {
    private final EntityManager entityManager;

    public OrderEditingMergeService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional
    public void editOrderWithMerge(OrderEditRequest request) {
        PurchaseOrder detached = new PurchaseOrder();
        detached.setId(request.orderId());
        detached.setStatus(OrderStatus.valueOf(request.newStatus()));

        detached.setItems(
                request.itemPatches().stream()
                        .map(patch -> {
                            // The line item has only id and qty: this is a partial graph, not a full snapshot of the order
                            OrderItem item = new OrderItem();
                            item.setId(patch.itemId());
                            item.setQuantity(patch.newQty());
                            return item;
                        })
                        .toList()
        );

        entityManager.merge(detached);
        entityManager.flush();
    }
}

At the Java level, everything looks compact: we built an object from the request and passed it to the ORM. But for Hibernate, this is no longer “change the status and these qty values,” but “here is the state of the order and its line items.” And that state is incomplete: the order does not have its other fields, and the items do not have the full relationship context and the rest of the data.

That is exactly why itemPatches here can easily stop looking like a list of point changes and start looking like a new version of the items collection that needs to be synchronized.

SQL with merge()

From the code alone, it is tempting to believe that what comes next will be “one UPDATE for the order and a couple more UPDATE statements for the items.” In practice, merge() often first reads the current order and its collection in order to obtain managed representatives and understand what exactly needs to be synchronized.

So the typical SQL profile of a merge scenario looks roughly like this:

-- merge gets the managed order root
select po.id, po.status
from purchase_order po
where po.id = ?;

-- then it deals with the current contents of the items collection
select oi.id, oi.order_id, oi.quantity
from order_item oi
where oi.order_id = ?;

-- only after that do the changes go out
update purchase_order
set status = ?
where id = ?;

update order_item
set quantity = ?
where id = ?;

And this is still the optimistic version. If the detached graph turned out to be poorer than the real order in the database, extra reads, unexpected UPDATE statements, and copy conflicts can easily be added here.

There is another effect that is often skipped in such comparisons: if the order is already loaded in the current persistence context, merge() will copy the state into that object. In other words, a poor detached patch can overwrite a more complete managed entity that you were already working with in the same transaction.

import jakarta.persistence.EntityManager;

// managed1 is already in the persistence context (tracked by Hibernate)
PurchaseOrder managed1 = entityManager.find(PurchaseOrder.class, 100L);

// detached is a separate instance with the same id (for example, it came from a mapper/DTO)
PurchaseOrder detached = new PurchaseOrder();
detached.setId(100L);

// merge will return a reference to the same managed object that was already in the persistence context
PurchaseOrder managed2 = entityManager.merge(detached);

System.out.println(managed1 == managed2); // true

This means the following: if you already changed something in managed1 within the same transaction, and then accidentally called merge() with detachedPatch, you can overwrite your own changes with the poorer state of the detached object. And it will look like “Hibernate went crazy,” although in fact it just honestly fulfilled the contract of merge(): it copied state.

4. Option B: find() + mutate

Now let us run the same OrderEditRequest through a different flow. Here the request is not “almost-an-entity,” but a command: first we read the current order from the database, then we apply only the changes that actually arrived.

package com.example.commerce.orders.service;

import com.example.commerce.orders.dto.OrderEditRequest;
import com.example.commerce.orders.entity.PurchaseOrder;
import com.example.commerce.orders.entity.OrderItem;
import com.example.commerce.orders.entity.OrderStatus;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderEditingService {
    private final EntityManager entityManager;

    public OrderEditingService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional
    public void editOrderFindAndMutate(OrderEditRequest request) {
        // Explicitly load the order: now it is a managed entity (the source of truth)
        PurchaseOrder order = entityManager.find(PurchaseOrder.class, request.orderId());

        // Apply changes point by point: change only what arrived as the "command"
        order.setStatus(OrderStatus.valueOf(request.newStatus()));

        // Important: update only the listed itemId values; do not touch the other line items
        for (var patch : request.itemPatches()) {
            OrderItem item = entityManager.find(OrderItem.class, patch.itemId());
            item.setQuantity(patch.newQty());
        }

        // Force SQL to appear and see the effect of dirty checking
        entityManager.flush();
    }
}

The direct find(OrderItem.class, patch.itemId()) here is needed only for an honest one-to-one SQL comparison. In a real update scenario, it is still important to verify that the line item actually belongs to this order, or to change it through the aggregate and a helper method. Otherwise even the safe flow quickly turns back into the story “we found any child by id and quietly changed it.”

Yes, there are more lines. But each line reflects a specific change: we found the order, changed the status, found the listed line items, changed the quantity. If the request does not contain other fields and other items, Hibernate is not given a reason to treat them as the new state of the entire graph.

5. Comparison: SQL and meaning

Now we are comparing not two abstractions, but Hibernate’s reaction to the same OrderEditRequest. The question is no longer only about the number of queries, but also about how predictably each branch interprets partial input.

From the SQL point of view, find() + mutate often looks like this:

-- explicitly read the order as the current truth
select ... from purchase_order where id = ?;

-- then read only the line items that actually came in the patch
select ... from order_item where id = ?;

-- and on flush only what changed goes out
update purchase_order set status = ? where id = ?;
update order_item set quantity = ? where id = ?;

From the business point of view, the difference is this: in find() + mutate you more often think in terms of change commands, while in merge() you think in terms of transferring state. As long as the input is complete, that is tolerable. As soon as the request carries only the status and a few itemPatches, merge() starts guessing for you what you did not want to touch.

To make the comparison as clear as possible, let us put the “difference sheet” into a table.

Criterion merge() (detached → copy state) find() + mutate (load → apply changes)
Source of truth The detached object you brought in The database → managed entity in the persistence context
What is needed from the input data Preferably a full snapshot of the order and the required graph You can send only what changes
Working with the returned object You must use the object returned by merge() You work with one managed instance
Risk of “accidental overwrite” High, especially with partial DTOs and graphs Lower: you change only what you explicitly wrote in code
Visibility of SQL “from the code” Part of the SELECT is hidden inside merge() SELECT is usually explicit — through find()
Debugging and code review Harder: “what exactly are we copying?” Easier: “these are the fields we touch”

And now the most important point: this is not a table that says “merge() is bad, find() is good.” It is a table that says “merge() requires discipline and complete data, while find() + mutate is a safer default for business changes.”

6. Choosing an approach

To avoid keeping all of this in your head as a set of disconnected thoughts, it is convenient to have a small decision flowchart. It is not about “right / wrong,” but about what you actually control: the completeness of the input and the predictability of the result.

flowchart TD
    A["Need to update the order"] --> B{"Is the input data a full snapshot of the order (all important fields + the entire required graph)?"}
    B -->|Yes| C{"Are you ready to work only with the returned managed instance after merge and read the SQL trace?"}
    C -->|Yes| D["You can use merge (but carefully, without mixing flows)"]
    C -->|No| E["Better find + mutate (less magic, easier to maintain)"]
    B -->|No| E["find + mutate (selective update)"]

In ordinary backoffice editing, the input is almost always incomplete: the operator changed one field, the UI sent a patch. In such a model, find() + mutate wins not in elegance, but in engineering predictability. merge becomes more appropriate where the detached object is truly a clean full snapshot and you are not trying to use an entity as a PATCH document.

There is another important discipline: do not mix the flows in one method. A typical compromise that looks smart but usually leads to confusion sounds like this: “I will do merge() and then manually fix the fields.” The problem is that you now have two references — detached and managed — and you can easily start changing the wrong one. And you can also unintentionally overwrite the result of your own manual changes with another merge() of a different object.

7. Typical mistakes when comparing merge() and find() + mutate

Mistake #1: using merge() as “partial update by id”.
A very common trap: a developer creates a new PurchaseOrder object, sets only id, status, and a couple of itemPatches, calls merge(), and hopes that Hibernate will update only that. Hibernate does not think that way: it copies the state as a whole. If the other fields and relationships are not set, you will either overwrite them with default values or get a constraint error on flush().

Mistake #2: ignoring the returned object from merge().
This is the case where one line of code saves you several hours: always store the result of merge() in a variable and continue working only with it. If you keep changing the old detached instance, those changes will not be picked up by dirty checking, and you will honestly wonder why “Hibernate did not save anything.”

Mistake #3: mixing merge() and find() + mutate in the same update flow “just in case”.
When the same method contains find(), then merge(), then direct changes again, you get double bookkeeping of entities. In such a situation, even an experienced developer may not immediately understand which object is managed now, which one is detached, and what exactly will end up in the database after flush().

Mistake #4: choosing merge() only because “it uses fewer lines”.
The number of lines is a poor criterion for persistence code. With merge(), most of the complexity is hidden not in Java lines, but in hidden actions: creating or finding a managed instance, possible database reads, cascades through the graph, and copying collection state. find() + mutate is usually longer, but it is easier to explain and simpler to diagnose.

Mistake #5: comparing the approaches without looking at the SQL trace.
At the Java code level, both approaches can look roughly the same. The difference often appears in the SQL trace: where extra SELECT statements appeared, why more columns were updated than you expected, or why the update affected not only the root table. If you compare merge() and find() + mutate but do not look at SQL, you are comparing feelings, not behavior.

1
Task
Hibernate deep-dive, level 5, lesson 4
Locked
One partial update in two implementations
One partial update in two implementations
1
Task
Hibernate deep-dive, level 5, lesson 4
Locked
Diagnostics of the working object in two update flows
Diagnostics of the working object in two update flows
1
Survey/quiz
Entity states, level 5, lesson 4
Unavailable
Entity states
Detached, merge, and update
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION