Bulk Operations and Batching
|
This section documents Hibernate ORM 7.4.x (User Guide, Introduction, Query Language Guide, Data Repositories Guide), Jakarta Persistence 3.2, Hibernate Search 8.4.x, and the Hibernate Validator / Hibernate Reactive references — which are the references these pages are written and verified against. This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Three older reference books were consulted as bibliography only while preparing these pages and are not the
primary or main source for any page. All three predate Jakarta Persistence 3.2 and Hibernate ORM 6/7 (the
This section’s bibliography lists the reference material consulted while preparing these pages. |
Loading, mutating, and flushing entities one at a time through the persistence context does not scale to imports/exports touching thousands or millions of rows. This page covers the mechanisms built for that case.
JDBC batching
hibernate.jdbc.batch_size groups consecutive, same-shape INSERT/UPDATE/DELETE statements into a single
JDBC batch round-trip instead of one round-trip per statement:
hibernate.jdbc.batch_size: 50
hibernate.order_inserts: true # groups same-table inserts together even if interleaved with other entities
hibernate.order_updates: true # same, for updates
Two conditions silently disable batching for an entity even with batch_size set: GenerationType.IDENTITY
(the id must come back from the database before the statement is considered "done", one at a time — see
Entities and Identifiers) and a version-checked
optimistic-locking UPDATE/DELETE interleaved with statements Hibernate cannot verify were batched
correctly, on drivers that do not return per-statement batch update counts. order_inserts/order_updates
matter because Hibernate can only batch consecutive same-table statements — interleaving inserts to two
different tables (one Book, one Author, one Book, …) defeats batching unless reordered first.
StatelessSession for ETL-style work
Architecture introduces StatelessSession: no persistence context,
no first-level cache, no automatic dirty checking, no cascading. Every operation is an immediate, individual
statement, and entities it returns are already detached:
try (StatelessSession session = sessionFactory.openStatelessSession()) {
Transaction tx = session.beginTransaction();
try (ScrollableResults<Book> results = session.createQuery("FROM Book", Book.class)
.scroll(ScrollMode.FORWARD_ONLY)) {
while (results.next()) {
Book book = results.get();
book.setOutOfPrint(true);
session.update(book); // explicit -- no dirty checking to rely on
}
}
tx.commit();
}
StatelessSession trades away every convenience a normal Session provides in exchange for predictable,
constant-memory behavior regardless of how many rows are processed — exactly the profile a large batch job
wants, and the reason it, rather than a regular Session with periodic clear() calls, is often the first
choice for genuinely large (multi-million-row) jobs.
Bulk HQL UPDATE/DELETE/INSERT … SELECT
For a single mass mutation, a bulk HQL statement is far cheaper than loading every row into the persistence context to mutate it there:
int updated = em.createQuery("UPDATE Book b SET b.outOfPrint = true WHERE b.publishedYear < :year")
.setParameter("year", 1990)
.executeUpdate();
int deleted = em.createQuery("DELETE FROM Book b WHERE b.outOfPrint = true AND b.stock = 0")
.executeUpdate();
int inserted = em.createQuery("""
INSERT INTO ArchivedBook (id, title)
SELECT b.id, b.title FROM Book b WHERE b.outOfPrint = true
""").executeUpdate();
Why bulk HQL bypasses the persistence context and second-level cache
A bulk UPDATE/DELETE is translated directly into one SQL statement and executed against the database — it never loads, touches, or updates any already-managed entity instance’s in-memory state, and it never
consults or invalidates the second-level cache for the affected rows unless
hibernate.query.startup_check/cache-region eviction is configured to handle it. Two consequences that
surprise people:
-
If an entity matching the bulk statement’s
WHEREclause is already managed in the current persistence context, that in-memory instance now silently disagrees with the database — its fields still hold the pre-bulk-update values until something re-reads it (refresh(), or a freshfind()afterclear()). -
If second-level caching is enabled for the affected entity (see Second-Level Cache), cached entries for the mutated rows can go stale unless evicted — Hibernate does invalidate the relevant cache regions automatically for a bulk HQL statement it can analyze, but a native-SQL bulk mutation bypasses this entirely and needs a manual
SessionFactory.getCache().evict*call.
For this reason, run a bulk statement at the start of a unit of work (before anything relevant has been
loaded) or immediately clear() the persistence context afterward if related entities might already be
managed.
The flush()/clear() loop pattern
For a large import processed through a normal, stateful Session (rather than StatelessSession), the
standard pattern periodically flushes and clears to bound memory and keep dirty-checking cost from growing
without limit as the persistence context accumulates entities:
int batchSize = 50;
for (int i = 0; i < records.size(); i++) {
Book book = mapToEntity(records.get(i));
em.persist(book);
if (i % batchSize == 0 && i > 0) {
em.flush(); // send pending INSERTs to the database
em.clear(); // detach everything -- release memory, reset dirty-checking cost to zero
}
}
Without periodic clear(), the persistence context grows by one entity per iteration for the whole import,
and every later flush has to dirty-check the entire, ever-growing set of managed entities — a quadratic cost
in the number of records processed that StatelessSession sidesteps entirely by never accumulating state in
the first place.