CodeGym /Courses /Spring Data JPA /Bulk requests and stale entities

Bulk requests and stale entities

Spring Data JPA
Level 14, Lesson 2
Available

1. “Bulk updated it, but the object does not know”: the problem

Imagine you are the admin of a small shop: you clicked the “Deactivate all products in the category” button, the database honestly updated everything, and your code suddenly prints in the logs that the product is still ACTIVE. A little panic starts, along with searches for “where is the transaction” and suspicions directed at Hibernate (and maybe the roommates).

Important point: this kind of “lying” usually does not show up as an SQL-level error. The SQL query runs fine. The problem shows up in Java code, which keeps holding a reference to an object read before the bulk operation and, by inertia, considers it current. This can break if branches, logs, response building, and even the next business operation that relies on the product status.

Let’s look at a minimal example — for now without transaction details, just the logic:

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;

// 1) Load the entity: within the current persistence context it becomes managed
Product product = productRepository.findById(10L).orElseThrow();
System.out.println(product.getStatus()); // ACTIVE

// 2) Execute a bulk update: database rows change, but NOT the fields of the already loaded object
productRepository.updateStatusById(10L, ProductStatus.INACTIVE);

// 3) Read the field from the same Java object: it does not have to "update itself"
System.out.println(product.getStatus()); // ACTIVE (this object still has the old value)

This code is very easy for a beginner to “feel”: “I updated the status — why is the old value still in the object?” And this is where it is important to catch the right idea: a bulk operation changes rows in the database, but it is not required to update already existing Java objects. They are not “subscribed to PostgreSQL news”.

2. Persistence context and first-level cache

To understand why the object stays old, you need to spend literally a couple of minutes “peeking behind the scenes” of JPA. No deep dive and no desire to rewrite Hibernate by hand — that is a dangerous dream, like “I’m going to build my own Spring now.” We only need one mental model: EntityManager has a persistence context, and it is also the first-level cache.

When you call findById(), Hibernate does not just read a row from a table and return an object. It also remembers the mapping “(entity type, id) → specific Java object”. This is needed for two reasons. First, it provides object identity inside a unit of work: if you read the same Product twice by the same id, you get the exact same object, not two copies. Second, it saves database reads: repeated accesses can be served from memory.

Hibernate describes it like this: as soon as an entity becomes managed, the object is placed into the internal cache of the current persistence context. In other words, the persistence context is like the ORM’s “working memory”: for the duration of the work it keeps your entities within reach.

Now let’s recall the familiar entity states: transient, managed, detached, removed. When an object is managed, it is “under the control” of the persistence context. When it is detached, it lives its own life, and Hibernate no longer considers it a “live part of the current unit of work”.

And here is the key point: bulk operations work directly with the database, not with the objects in this “working memory”. So memory and database can drift apart.

3. Bulk query and desynchronization

Now let’s put the picture together into a cause-and-effect chain. A bulk update/delete is a query of the form “update/delete all rows matching the condition.” It does not imply that Hibernate first loads all of these rows as objects, carefully loops through them, and calls setters. It sends a query to the database layer and gets back a response like “N rows affected”.

The Spring Data JPA official documentation even explicitly warns: after a modifying query, the EntityManager may contain stale entities, and therefore it is not cleared automatically. Why is it not cleared automatically? Because clearing, in essence EntityManager.clear(), ejects all entities from the context, and that can “drop” changes that exist only in memory and have not yet been synchronized with the database.

And that brings us to the “beginner paradox”:

1. The database has already been updated.
2. Your object in the product variable is still old.
3. If the persistence context is still alive (for example, within a single shared operation), even a repeated findById(10) may return the exact same object, not a fresh copy.

This is not magic and not an error. It is simply two different worlds: the world of SQL rows and the world of Java objects. ORM tries to synchronize them, but bulk operations are a deliberate “shortcut” where synchronization is not free.

To make it easier to “see it with your eyes”, here is a diagram of what happens:

flowchart TD
    A["findById(10)"] --> B["Product{id=10, status=ACTIVE} in persistence context"]
    B --> C["bulk update status=INACTIVE where id=10"]
    C --> D["DB: status = INACTIVE"]
    C --> E["In memory: object still status = ACTIVE"]
    E --> F["Then the code reads product.getStatus() and believes it"]

The worst part about this problem is that it often does not fail with an exception. It is silent. It simply makes the wrong decisions in code.

4. clearAutomatically: clearing the context

This is where our hero of the day comes on stage: @Modifying(clearAutomatically = true). The @Modifying annotation has an attribute that is literally described as “clear the persistence context after executing the modifying query”.

An important careful formulation: clearAutomatically does not “update the fields of your object.” It does something else: it clears the persistence context, that is, it “forgets” all managed entities that were cached there. It is like saying: “We made a mass data change — now let’s agree that everything loaded into memory may be stale, and let’s not trust it.”

What does this give you in practice? After clearing:

— already read entities become detached (essentially: ORM stops considering them current within the current context);
— the next findById() (or any other query) will be forced to read the data again for real, because there is no cached version left “in memory”.

And now a very subtle point that absolutely must be said: if you have a variable product, then after clear() (automatic or manual) it does not disappear anywhere. It is still the same object in the Java heap. It is still ACTIVE in the status field. Hibernate just no longer treats it as “truth” and will no longer hand it out as the result of new queries. So the correct reaction after a bulk operation sounds like this: “If I need a current entity, I read it again.”

And once again: why is this flag not enabled by default? Spring Data JPA says it directly: if you automatically clear the EntityManager, you can discard changes that are still “alive” only in memory and have not been sent to the database. So clearAutomatically is not something you “set everywhere to be safe.” It is a tool you enable deliberately and selectively.

5. Demo of stale state in shop-data-jpa

Now we will carefully plug the problem into our end-to-end project shop-data-jpa so that it stops being an “abstract internet pain.” The scenario is as real as it gets: we have products (Product) and a status (ProductStatus). An admin wants to deactivate products in bulk, for example by category or by creation date.

Let’s start with the repository. We will make two methods: one “ordinary bulk update” and a second one — “bulk update + auto clear.” That makes the difference easier to see.

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // bulk update: Hibernate sends UPDATE directly to the database (without loading the entity into memory)
    // the result is the number of affected rows
    @Modifying
    @Query("update Product p set p.status = :status where p.id = :id")
    int updateStatusById(@Param("id") Long id, @Param("status") ProductStatus status);
}

And to the same interface we can add a variant with auto clearing:

import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

// Add a second educational method to the same ProductRepository
@Modifying(clearAutomatically = true)
@Query("update Product p set p.status = :status where p.id = :id")
int updateStatusByIdAndClear(@Param("id") Long id, @Param("status") ProductStatus status);

Yes, in real code you rarely keep both methods side by side — this is a teaching contrast. And here the simple rule works again: a modifying query runs inside a transaction, otherwise no write to the database starts.

Now for the demo service scenario. I am intentionally making it transaction-bound: this shows that findById() and the bulk update live in the same persistence context. And yes, it is slightly “weird” — it is not about business logic, but about diagnostics.

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import com.example.shopdatajpa.catalog.repository.ProductRepository;
import org.springframework.transaction.annotation.Transactional;

public class ProductAdminDebugService {

    private final ProductRepository productRepository;

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

    @Transactional
    public void demoStaleObject(Long id) {
        // 1) Load the entity within the current persistence context
        Product p1 = productRepository.findById(id).orElseThrow();
        System.out.println("Before = " + p1.getStatus()); // Before = ACTIVE

        // 2) Bulk update changes the DB, but DOES NOT change the fields of p1 in memory
        productRepository.updateStatusById(id, ProductStatus.INACTIVE);

        // 3) This variable still holds the old value — that is where the "magic" comes from
        System.out.println("After  = " + p1.getStatus()); // After  = ACTIVE (stale)
    }
}

A key psychological trap happens here: a person sees updateStatusById(...), then sees p1.getStatus() and thinks the update “did not work.” But the update did work — it is just that p1 was never updated. It is an ordinary Java object.

Now let’s add a check: “what is really in the database?” The simplest way is to read the entity again, and this is, by the way, often the safest solution for a beginner:

// Read it again — and get the state that is really stored in the database
Product fresh = productRepository.findById(id).orElseThrow();
System.out.println("Fresh  = " + fresh.getStatus()); // Fresh  = INACTIVE

And now let’s show why clearAutomatically may be needed. The auto-clear effect is especially noticeable inside a single shared transactional service operation, where both the bulk update and the repeated read live in the same persistence context.

// Suppose everything happens inside one transactional service operation.
Product p1 = productRepository.findById(id).orElseThrow();

// bulk update + automatic persistence context clear
productRepository.updateStatusByIdAndClear(id, ProductStatus.INACTIVE);

// p2 will be loaded from the database again, because the context has already been cleared
Product p2 = productRepository.findById(id).orElseThrow();
System.out.println(p2.getStatus()); // INACTIVE

The key thing: p1 is still the old object. But p2 is fresh, because after the modifying query the context was cleared, and Hibernate could not “cheat” with the cache.

6. Strategies after bulk operations

With bulk operations, you need to make one promise to yourself: they are almost never about “getting updated objects.” They are about “changing data quickly.” So the healthiest style is one where, after a bulk update, you do not continue to live by relying on previously read entities as if nothing happened.

If we formalize it, you have three “human-friendly” strategies. The first is to not keep entities at all, and work from the criterion and the changedRows result. The second is to reread what you need after the bulk operation and not try to save on one extra query where correctness matters more. The third is to enable clearAutomatically when you understand that the current context may already have accumulated a cache of managed entities and you risk seeing stale data.

To feel the difference, it is useful to look at a small table — not as dogma, but as a brain cheat sheet:

What you do after the bulk operation What can go wrong without clearAutomatically What is usually safer
Immediately finish the method and return int changed Almost nothing — you do not use old entities clearAutomatically is often not needed
Keep working with old objects They will remain with the old state, business logic may go off track Do not use old objects, reread
Do repeated reads in the same context You can get a cached old version instead of a fresh one clearAutomatically = true or manual rereading after clear

Now about when clearAutomatically is better not to set “automatically”. If in your scenario you already made some changes to other entities before the bulk operation and have not yet synchronized them with the database, clearing the context can throw those changes out of managed state. That is exactly why Spring Data JPA does not clear the EntityManager automatically for every modifying query.

Simply put: you can accidentally lose part of your work if the bulk operation happens “in the middle” of a large operation where object changes have already been made. So a good junior-friendly style for bulk methods is to keep them as “clean” and isolated in meaning as possible. The fewer “live” entities around the bulk operation, the lower the risk of stale effects and the lower the need for magic flags. And yes, that sounds boring. But boring code usually breaks less often — this is that rare case where boredom is useful.

7. Typical mistakes with bulk operations

Mistake #1: expecting a bulk query to update an already existing Java object.
After update, the rows in the database changed, but not the fields in your object. A Java variable is not a “smart pointer to a table row”; it is an ordinary reference to an object in memory. If you need current state, you need to fetch it again.

Mistake #2: building business logic on top of an “old” object immediately after a bulk operation.
The unpleasant thing here is that the code often does not crash. It just goes down the wrong if branch, prints the wrong log, or builds the wrong response. If the bulk operation was part of a scenario, reconsider where you take the “truth” for the next step.

Mistake #3: setting clearAutomatically = true as a universal charm for every modifying method.
Clearing the persistence context is a strong action. It is useful when you really want to “reset the ORM memory” after a mass change. But it can be harmful if you already made changes in managed entities before the bulk operation and expected ORM to carry them to the database.

Mistake #4: trying to keep using a previously loaded list of entities after a bulk operation as if it were “updated.”
This is a special case of the first mistake, only more expensive: you are not just holding one Product, you are holding a List<Product>, and now you have a whole collection of objects in memory with the “before bulk” state. If this list then goes into calculations, sorting, filtering, or response building — you are giving yourself Groundhog Day.

Mistake #5: not noticing that the problem is not in the database, but in the application’s persistence context / memory.
A beginner often checks: “the status in the database is already INACTIVE.” Therefore, “Hibernate is broken.” No, Hibernate is actually behaving predictably: it will not telepathically update your objects after a bulk query. You need to learn to see two layers: data changes in the database and object freshness in memory.

1
Task
Spring Data JPA, level 14, lesson 2
Locked
Stale state demonstration after bulk update
Stale state demonstration after bulk update
1
Task
Spring Data JPA, level 14, lesson 2
Locked
Re-read after `clearAutomatically = true`
Re-read after `clearAutomatically = true`
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION