CodeGym /Kursy /Hibernate deep-dive /save() and saveAndFlush()

save() and saveAndFlush()

Hibernate deep-dive
Poziom 27, Lekcja 1
Dostępny

1. save() — a familiar and misunderstood method

If you ask a beginner developer to show “where the data is saved,” they will almost reflexively point a finger at repository.save(...). That is normal: the method name is as human-friendly as possible, almost like the “Save” button in Excel. The problem is that ORM hides several different scenarios behind that word, and each one has its own cost in SQL and its own side effects.

In Spring Data JPA there is one save method, but a domain entity can be in several life-cycle states. An object can be new (transient), it can already be managed inside the current transaction, or it can arrive as detached (a typical story: DTO → mapper → “entity”, but already without a connection to the persistence context). And every time, the same save will mean something slightly different.

The most important “translation” into Hibernate terms sounds like this: save(...) does not “save” an entity in a vacuum; it merely helps place the entity into the correct persistence flow. Sometimes that means persist, sometimes merge. Sometimes it means nothing important at all, apart from one extra line of code (yes, an “extra line” can also be a bug; it is just pretending to be innocent for now).

2. What save(...) does: persist or merge

When we say “Spring Data did CRUD for us,” it is easy to forget that a regular EntityManager still lives underneath. And save in a typical Spring Data JPA project is first and foremost a decision about which JPA operation to perform: should it be persist or merge. In other words, this is not magic, but... well, let us say: magic with the hood open.

Very simplified, but methodologically honest, the logic looks like this:

import jakarta.persistence.EntityManager;

public class PseudoSaveLogic {

    private EntityManager em;

    public <T> T save(T entity, boolean isNew) {
        // If the entity is "new", we register this same object in the persistence context
        if (isNew) {
            em.persist(entity); // the object becomes managed
            return entity;      // we return the same instance
        }

        // If the entity is "not new", this is most often merge:
        // a managed copy will be returned (not necessarily the same object)
        return em.merge(entity);
    }
}

In a real Spring Data JPA project, this choice is made not by an isNew flag, but by the newness-detection mechanism, yet the principle is exactly the same.

To make this visually clear, it is useful to keep a small diagram in mind:

flowchart TD
    A["productRepository.save(entity)"] --> B{"Entity new?"}
    B -- "Yes" --> C["EntityManager.persist(entity)"]
    C --> D["entity becomes managed
INSERT usually on flush/commit"] B -- "No" --> E["EntityManager.merge(entity)"] E --> F["a managed copy is returned
SELECT possible, UPDATE on flush"]

The key detail many people miss is this: in the merge branch, the returned value is critically important. It is not “the same object, just saved.” It is the managed instance in the current persistence context, and that is the object that is safe to continue working with inside the transaction.

3. Newness: how Spring Data determines a new entity

The word newness sounds as if we are discussing the philosophy of “newness,” but in practice it is a down-to-earth thing: Spring Data must understand which branch to choose — persist or merge. And it does not read your mind. It looks at the entity and tries to make a technically reasonable assumption.

In most projects (including our Commerce Persistence Lab), newness is determined quite predictably: if an entity has no identifier, then it is new. That is why for an entity with @GeneratedValue, the typical sign of a new object is id == null. But Spring Data has another important signal: version (@Version). If an entity is versioned, then it is quite logical to consider the entity new when version == null (if the version is an object type like Long, not a primitive long).

As a result, you get something like this “intuition table”:

Entity What is usually checked Why it works
Entity with @GeneratedValue and no version id == null → new The ID will appear after persist/flush; before that, the object is new
Entity with @Version (Long version) version == null → new The new entity has not yet participated in a versioned update
Entity with assigned id (id set manually) often does not work “by default” id != null, but the object is still transient; merge may behave unexpectedly

The third row is the source of “adventures”: if you assign ids manually (assigned id), then by id != null alone you cannot honestly say that the object is “already in the database.” It may be a new entity to which you have assigned an identifier in advance. Then Spring Data may mistakenly go through merge (which means an extra SELECT is possible, and sometimes unexpected effects as well).

In our educational project, we intentionally stick to id generation through Hibernate/the database (not assigned), so the basic newness intuition will work predictably. But even in that case, it is important to remember: save is not “INSERT/UPDATE”; it is “persist/merge” + then the flush cycle decides everything.

If a project deliberately lives on assigned ids, this heuristic can no longer be left at the default: newness has to be determined explicitly, for example through Persistable, and for us the ordinary scenario with generated ids and a predictable persist branch remains ahead.

4. save(...) and a new object

A new object in Java is not yet a row in a table. And the most common thinking mistake is to expect that save sends an INSERT immediately. In reality, save for a new object usually means: “make the entity managed.” The SQL may go out right away, or it may go out later — that depends on the id strategy, flush mode, and flush triggers (which we have already discussed).

Let us look at “creating a product” in our Commerce Persistence Lab. For simplicity, let us assume that Product has sku and name, and id is generated.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductWriteService {

    private final ProductRepository productRepository;

    public ProductWriteService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public Product create(String sku, String name) {
        // For now this is just a Java object; it has no direct relationship to the database yet
        Product product = new Product(sku, name);

        // Important: save() here primarily "introduces" the entity into the persistence flow (persist)
        // INSERT will most often happen later — on flush/commit
        return productRepository.save(product); // SQL may go out later (on flush)
    }
}

What is important to see with your eyes (and through SQL trace): after save(product), the object is usually already managed, meaning Hibernate starts tracking it. But the INSERT will most often appear on flush, that is, either when the transaction commits, or earlier if a flush trigger occurs (for example, you execute a query that needs to see these changes).

And this raises an interesting question: “Then why return Product from create() at all?” The practical answer: in a managed scenario it is convenient for the calling code to obtain the id immediately (if it has already been assigned), and to avoid losing the reference to the object. But again: the presence of an id and the fact of SQL insertion are not the same event in time.

5. save(...) for a managed entity: why it is often unnecessary

After a few weeks of working with Spring Data, many people develop a habit: “changed a field — call save.” It looks like discipline, but in terms of meaning it is often just a duplication of a mechanism that is already working. If you loaded an entity inside a transaction, it became managed. Then you change its fields, Hibernate performs dirty checking, and at flush it sends an UPDATE. The repository is no longer the main character here.

Let us compare two ways of renaming a product. The first one is “the way we are used to”:

import org.springframework.transaction.annotation.Transactional;

@Transactional
public void renameWithRitualSave(Long id, String newName) {
    // findById inside a transaction gives a managed entity
    Product product = productRepository.findById(id).orElseThrow();

    // Change the managed object's state
    product.renameTo(newName);

    // Most of the time this is unnecessary: UPDATE will happen on flush/commit anyway (dirty checking)
    productRepository.save(product); // most often redundant
}

The second one is “the way ORM actually works”:

import org.springframework.transaction.annotation.Transactional;

@Transactional
public void renameManaged(Long id, String newName) {
    // The entity became managed immediately after loading
    Product product = productRepository.findById(id).orElseThrow();

    // From here it is enough to simply change the fields:
    // Hibernate will find the changes and do UPDATE on flush/commit
    product.renameTo(newName); // dirty checking will do UPDATE on flush/commit
}

From the point of view of the final SQL, in many cases there will be no difference: UPDATE will happen anyway. But there is a difference at the level of the mental model. In the second variant you honestly admit: “Hibernate performs the update because the object is managed.” In the first variant you create the illusion that “the repository saves it.” And illusions in the persistence layer are expensive: they make it harder to debug flush, bulk side effects, stale state, and all the “why did it generate that SQL?” situations.

Sometimes save on a managed object is left as a stylistic “do it the same way everywhere” choice. In an educational project, it is better to avoid that so the code clearly reflects the runtime model.

6. save(...) and detached: merge and the copy

The detached scenario is the most treacherous, because on the surface it looks the same: you have a Product object, you changed it, you called save. It seems simple. But under the hood, save for a detached object almost always turns into merge. And merge returns a managed instance that may not be the same object you passed in.

Let us show an example of “update from the outside world” (for example, a DTO arrived, we assembled an entity, and now we are trying to save it):

import org.springframework.transaction.annotation.Transactional;

@Transactional
public Product updateDetached(Product detached) {
    // The detached object is NOT under the persistence context's watch
    detached.renameTo("New name");

    // save(detached) here usually means merge()
    // Important: a managed copy will be returned, and further work should use that one
    return productRepository.save(detached); // merge: returns a managed copy
}

If you then continue working within the same transaction with the old detached object, you can get very strange feelings: “But I changed the field, why is it not like that in the database?” or “Why are the values in the logs wrong?” The correct rule sounds boring, but it saves nerves: after save(detached), continue working with the object returned by save.

To see the trap as clearly as possible, imagine the following code:

import org.springframework.transaction.annotation.Transactional;

@Transactional
public void brokenDetachedFlow(Product detached) {
    // merge creates/returns a managed instance inside the current persistence context
    Product managed = productRepository.save(detached);

    // Change detached: these changes may never reach the database
    detached.renameTo("Oops"); // changing the WRONG object

    // Change managed: Hibernate will see this and write it at flush/commit
    managed.renameTo("Ok");    // changing managed
}

From Hibernate's point of view, managed is what matters. Changes to detached after merge may never reach the persistence context at all, because detached is just a regular object in memory, “not under observation.”

And yes, this is exactly the case where find + mutate is often simpler and safer: you first obtain a managed entity from the database, and then selectively copy changes into it. We discussed that earlier, and save here is not the “best” option — it is just one of the options, and not always the most predictable one.

7. saveAndFlush(...): early flush and SQL now

saveAndFlush sounds like “save, but with a guarantee.” In human language, you naturally want to read it as: “save reliably.” In engineering language, it reads differently: save and force a flush, meaning “send SQL to the database right now, even if the transaction is not over yet.” And this “right now” is sometimes critical, and sometimes just extra trouble.

Let us show a minimal example:

import org.springframework.transaction.annotation.Transactional;

@Transactional
public Product createAndFlush(String sku, String name) {
    // Create a new object
    Product product = new Product(sku, name);

    // saveAndFlush() = save() + forced flush right here
    // This is NOT commit: the transaction can still roll back
    return productRepository.saveAndFlush(product); // SQL goes out immediately on flush
}

Important: flush is not the same as commit. After saveAndFlush(...), the transaction can still roll back, and the changes will not be finalized. But the database will already see the SQL operation inside the transaction: constraints will be checked (for example, sku uniqueness), locks may be taken, triggers may fire (if you have any), and, most importantly, an error may arrive earlier than you expected.

It is useful to think of this as a timeline:

sequenceDiagram
    participant S as "Service (@Transactional)"
    participant EM as "Hibernate (persistence context)"
    participant DB as PostgreSQL

    S->>EM: save(entity)
    Note over EM: entity became managed SQL may be deferred
    S->>EM: saveAndFlush(entity)
    EM->>DB: INSERT/UPDATE (flush)
    Note over DB: constraints checked error may happen now
    S->>DB: COMMIT (end of transaction)

If you call save without flush, uniqueness errors often surface only closer to commit time (or during auto-flush before a query). If you call saveAndFlush, the error surfaces immediately after that call. That is sometimes convenient (fail fast), but it is not “more reliable”; it is “earlier.”

8. Where saveAndFlush(...) is appropriate

There is a temptation to use saveAndFlush as a universal remedy for anxiety: “what if it is not saved?” But in the Hibernate world, that is like checking whether the door is closed every three seconds. Yes, you feel control, but the system starts to live worse: more flushes, more SQL earlier than necessary, more time holding locks, more unexpected “in the middle of the method” errors.

A justified case is usually related to the need to know the result of the database interaction earlier. For example, you create a Product with a unique sku, and you want to fail on the unique constraint right away, before doing a lot more logic inside the same transaction. This does not replace the fact that the database must enforce uniqueness, but it helps you get the error faster and avoid wasting time on useless work.

Another valid scenario is when, within one transaction, you have a step that fundamentally needs SQL to be out already (for example, a query follows that must see these changes, and you do not want to rely on auto-flush “by the rules”; you want to make the flush moment explicit and readable in the code). This is rare, but sometimes such code really is clearer: “here is the boundary after which the database is definitely synchronized.”

In other cases, saveAndFlush often masks an architectural problem. For example, when transactions are too long, and the developer tries to “lighten” the middle of the transaction with flushes; or when the service layer does not control boundaries, and someone tries to achieve predictability “manually.” In such situations, saveAndFlush is more likely to add chaos than remove it.

To reinforce this, we can quickly compare save and saveAndFlush by meaning:

Method What it guarantees What it does NOT guarantee Main cost
save() the entity entered the persistence flow (persist/merge) that SQL has already been sent to the database cost is determined by the flush cycle
saveAndFlush() SQL is synchronized with the database at that moment commit (the transaction can still roll back) early constraints/locks + extra flushes

9. Typical mistakes when working with save and saveAndFlush

Mistakes around save almost always happen not because of ignorance of annotations, but because of the wrong mental model. Spring Data makes code shorter, and that creates the impression that “it does everything by itself.” But Hibernate still honestly follows the persistence-context rules, and if you do not understand them, you will be surprised by SQL like by a magic trick: “how did it do that?” Below are the most common pitfalls that even very capable people step into.

Mistake #1: treating save as an “immediate INSERT/UPDATE.”
Because of the method name, it is tempting to think that SQL goes out the moment it is called. But most of the time, SQL will go out on flush. As a result, the developer writes code as if the record is already in the database, and then wonders why the next query suddenly triggers a flush and fails on a constraint “in an unexpected place.”

Mistake #2: calling save after every change to a managed entity “just in case.”
In a managed flow, this is usually unnecessary: dirty checking will do its job without save. At the same time, the extra save reinforces a wrong habit and hurts readability: it becomes unclear where the real cause of the UPDATE is — in the state model or in the “repository ritual.”

Mistake #3: ignoring the return value of save in a detached flow.
If the object is detached, save often turns into merge and returns a managed copy. Continuing to modify the old object after save is like fixing a car from a photo: you are doing something, but the real system may not change at all.

Mistake #4: using saveAndFlush as a “more reliable save.”
saveAndFlush does not commit. It just forces SQL to go out earlier. This can be useful for fail-fast and for an explicit “synchronize now,” but if you apply it reflexively, you will get more early locks, more SQL in the middle of the transaction, and a more nervous persistence layer.

Mistake #5: trying to fix transaction-boundary problems with saveAndFlush.
If the transaction is designed poorly (too long, smeared across layers, or just living “however it happens”), early flushes will not make the behavior correct — they will make it just more unpredictable. The unit-of-work boundary should be at the service layer, and saveAndFlush is a tool for precise control over the moment of synchronization, not a crutch for architecture.

1
Zadanie
Hibernate deep-dive, poziom 27, lekcja 1
Niedostępne
`save()` dla nowej encji
`save()` dla nowej encji
1
Zadanie
Hibernate deep-dive, poziom 27, lekcja 1
Niedostępne
Wczesny SQL przez `saveAndFlush()`
Wczesny SQL przez `saveAndFlush()`
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION