Bulk updates and the stale persistence context

Hibernate · lesson 32 of 32 · 6 min read

A bulk UPDATE bypasses everything the ORM knows, including entities already loaded.

Open this lesson in the learning hub

Key points

  • A JPQL bulk update or delete is translated straight to SQL. It does not load entities, does not run callbacks, and does not update the persistence context.
  • That means entities already loaded in the same context are now stale, and a subsequent flush can write the old values back over the bulk change.
  • clearAutomatically = true on @Modifying clears the context afterwards, which prevents that - at the cost of detaching everything, so unsaved changes are lost.
  • Bulk operations also skip @PreUpdate, auditing, optimistic locking and cascade. A bulk delete will not cascade to children and can leave orphans or violate a foreign key.
  • They are still the right tool for large changes: updating a million rows one entity at a time is orders of magnitude slower and will exhaust the persistence context.
  • The rule of thumb: use bulk for genuine bulk work, run it in its own transaction, and do not mix it with entity manipulation in the same unit of work.

Example

// Bypasses the ORM entirely. Fast, and dangerous in the wrong place.
interface OrderRepository extends JpaRepository<Order, Long> {

    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query("update Order o set o.status = :to where o.status = :from")
    int bulkUpdateStatus(@Param("from") OrderStatus from, @Param("to") OrderStatus to);
}

//   flushAutomatically  - pending changes are written BEFORE the bulk runs,
//                         so the bulk sees them
//   clearAutomatically  - the context is cleared AFTER, so nothing stale
//                         survives to be flushed back

// THE BUG this prevents:
@Transactional
void broken() {
    Order order = repo.findById(1L).orElseThrow();   // status = NEW, now managed
    repo.bulkUpdateStatus(NEW, PROCESSING);          // DB row -> PROCESSING

    // order.status is still NEW in memory. Dirty checking at commit sees a
    // difference and writes NEW back - silently undoing the bulk update.
    order.setNote("touched");
}

/*
 * WHAT BULK OPERATIONS SKIP:
 *
 *   @PreUpdate / @PostUpdate      not called
 *   @Version optimistic locking   not incremented -> lost update detection off
 *   cascade                       NOT applied - children are left behind
 *   second-level cache            stale unless evicted
 *   Envers auditing               no revision recorded
 *
 * So a bulk delete of parents can leave orphan children, or fail on a
 * foreign key, depending on the constraint.
 *
 * WHEN TO USE THEM ANYWAY:
 *
 *   1,000,000 rows, entity by entity   minutes, and the context explodes
 *   1,000,000 rows, one bulk UPDATE    one statement
 *
 * Do it in its own transaction, and do not touch entities in it.
 */

Bulk statements bypass callbacks, versioning and cascade, and leave loaded entities stale - isolate them in their own transaction.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Hibernate course, and every lesson in it is listed on the Hibernate contents page.