Every guide to Hibernate batching says the same thing: use persist(), set hibernate.jdbc.batch_size, and avoid GenerationType.IDENTITY because it defeats batching. All of that is correct, and none of it tells you whether batching is actually happening in your application right now. Setting the property is not the same as verifying the behavior — the only way to know which one you’re actually getting is to look at what Hibernate itself reports it did.
This piece opens with that exact discovery — two nearly identical entities, one setting, wildly different results — then runs two further sweeps to find out what happens when the knobs involved are set to different values instead of matching ones. Every number below came from hibernate.generate_statistics=true against Hibernate 7.4.1.Final, not from a said-to-happen description. The companion repository linked in the callout below has the full JUnit test suite behind every table.
Versions used in this article. Hibernate ORM 7.4.1.Final (GA 2026-06-09) on Spring Boot 4.1.0 (GA 2026-06-10). Spring Boot 4.1.0’s own dependency management resolveshibernate.versionto7.4.1.Finalexactly, so no POM override is needed to get this pin. The runnable project, the full JUnit test suite, and every captured transcript live in the hibernate-demo companion repository.
IDENTITY silently disables batching — the discovery that opens this article
Two entities, WidgetIdentity and WidgetSequence, differ in exactly one line — the @GeneratedValue strategy — and are otherwise inserted with identical settings: hibernate.jdbc.batch_size=25, hibernate.order_inserts=true. 30 rows each, same transaction shape, hibernate.generate_statistics=true reading the real counts.
Results, up front
| entityInsertCount | prepareStatementCount | Batching actually happening? | |
WidgetIdentity (IDENTITY) | 30 | 30 | No — one round trip per row |
WidgetSequence (SEQUENCE) | 30 | 4 | Yes |
batch_size=25 is configured identically for both entities. It does nothing at all for IDENTITY — prepareStatementCount equals entityInsertCount exactly, meaning every insert is its own round trip. The reason: with IDENTITY, the database generates the primary key during the INSERT, and Hibernate can’t know what id a row got without that insert actually executing — so there’s nothing left to batch. SEQUENCE inverts this: Hibernate gets the id from the sequence before building the insert, so it can queue several and hand them to the JDBC driver as one executeBatch() call.
The naive prediction for SEQUENCE is “30 rows at batch_size=25 is two insert batches (25+5), so prepareStatementCount should be 2.” Measured, it’s 4 — because prepareStatementCount also counts calls to pull the next block of ids from the sequence, governed by a second, independent setting: the generator’s allocationSize. WidgetSequence sets allocationSize=25 to match batch_size, so the first 25 ids come from one sequence call and the remaining 5 force a second — 2 insert batches + 2 sequence calls = 4.
allocationSize and batch_size are separate knobs governing separate things, and “separate knobs” doesn’t tell you what happens when they’re set to different values — that has to be run. Two sweeps, below.
Sweep 1 — allocationSize, batch_size fixed at 25
Four otherwise-identical entities, each with its own dedicated sequence, 30 rows each, batch_size=25 fixed:
| allocationSize | prepareStatementCount | Naive prediction | Matched? |
| 1 | 31 | 32 (2 batches + 30 sequence calls) | No |
| 10 | 5 | 5 (2 batches + 3 sequence calls) | Yes |
| 25 | 4 | 4 (2 batches + 2 sequence calls) | Yes |
| 50 | 3 | 3 (2 batches + 1 sequence call) | Yes |
Three of four match paper arithmetic exactly. allocationSize=1 doesn’t — the naive “one sequence call per row” count predicts 32; the measured number is 31. This off-by-one reproduced identically across two separate full-suite test runs, so it isn’t a fluke of one execution. It’s published here rather than smoothed over, because “run the numbers rather than predicting them” only means something if a number that doesn’t match the prediction gets published anyway.
Sweep 2 — batch_size, allocationSize fixed at 50
Four fully isolated entities (each allocationSize=50), 30 rows each, as four separate Spring Boot test configurations so each genuinely boots its own Hibernate configuration rather than one mutated at runtime:
| batch_size | prepareStatementCount |
| 1 | 32 |
| 10 | 2 |
| 25 | 2 |
| 50 | 2 |
batch_size=1 is effectively no batching — close to one prepared statement per row, plus sequence traffic. The moment batching is enabled at all, the insert-side contribution to prepareStatementCount collapses to the same small constant regardless of the exact value — 10, 25, and 50 all measured identically. That does not mean batch_size stops mattering: it still governs how many rows go into each executeBatch() call at the JDBC driver level, which is real and documented — it just isn’t a distinction this particular Hibernate statistic can see once batching is switched on at all. Reading prepareStatementCount answers “is batching happening,” not “how big are the batches.”
A disclosed caveat: running this sweep in isolation (one test class, not the full suite) measured 3 for batch_size=10/25/50, not the 2 shown above — the table reflects the full mvn test run, the literal reproduction command given in this repo, treated as canonical. The two runs disagree by exactly one prepared statement, reproducibly, most likely from a one-time cost on the first Hibernate SessionFactory bootstrapped in a JVM process — but that mechanism wasn’t traced into Hibernate’s source to confirm, so it’s disclosed rather than asserted as fact.
A mandatory correction to this article’s own code sample
An earlier version of the flush/clear loop below shipped with an off-by-one:
// WRONG -- off by one
if (i > 0 && i % 50 == 0) {
session.flush();
session.clear();
}
i is the 0-based loop index, and persist() for row i has already run by the time this check executes. i > 0 && i % 50 == 0 is true at i = 50, 100, 150, ... — but by the time i reaches 50, rows at index 0 through 50 have already been persisted, which is 51 rows, not 50. Every batch boundary holds one extra row in memory beyond the intended checkpoint. The fix:
public void bulkInsertUsers(List<User> users) {
try (Session session = sessionFactory.openSession()) {
Transaction tx = session.beginTransaction();
for (int i = 0; i < users.size(); i++) {
session.persist(users.get(i));
if ((i + 1) % 50 == 0) { // CORRECT -- 1-based checkpoint, no drift
session.flush();
session.clear();
}
}
tx.commit();
}
}
(i + 1) counts rows processed so far (1-based) rather than the 0-based loop index, so the flush fires after exactly the 50th, 100th, 150th row every time, with no drift regardless of where the loop starts counting. This repo’s own InsertIdentityRunner / InsertSequenceRunner don’t contain this loop at all — they persist a small enough batch in one transaction without needing a periodic clear — so the bug lived only in this article’s illustrative sample and has been fixed here, not there.
What batching actually sends over the wire
Batching does not send “multiple SQL statements in a single network packet” — that conflates two different things. What actually happens: JDBC’s PreparedStatement.addBatch() / executeBatch() groups several parameter sets for the same prepared statement and sends them in one client-to-server exchange, avoiding a full round trip per row. Whether that exchange spans one TCP packet or several is a driver- and network-layer detail with no fixed answer — it depends on row size, driver buffering, and the network path, none of which the numbers above speak to. The correct claim is about round trips avoided, not packet counts.
Session vs. StatelessSession
Not run in this repo — a StatelessSession scenario needs a dedicated build to demonstrate its actual failure modes (cascades that silently don’t fire, no dirty checking) rather than a profile bolted onto this one. The table below is sourced from Hibernate’s own documented contract, not a captured run — treat it as contract, not observation:
| Session | StatelessSession | |
| First-level cache | Yes | No |
| Dirty checking | Yes | No — every change needs an explicit update() |
| Cascading | Yes, per CascadeType | No — persist each entity yourself |
| Lifecycle callbacks | Yes | No |
| Batching behavior | Governed by hibernate.jdbc.batch_size, as measured above | Its own insert()/insertMultiple() path, generally lower per-row overhead |
| Best fit | Ordinary application code | ETL, bulk import/migration, seed scripts |
The tradeoff is overhead for correctness: StatelessSession skips the machinery that makes ordinary Hibernate usage convenient, which is exactly why it’s faster for a one-shot bulk load and exactly why it’s the wrong tool for ordinary request-scoped persistence code.
Two constraint exceptions with the same short name
jakarta.validation.ConstraintViolationException (Bean Validation) is thrown before any SQL runs, when an entity fails an annotation like @NotNull or @Size during Hibernate Validator’s pre-flush pass. No database round trip happens.
org.hibernate.exception.ConstraintViolationException (Hibernate’s own, wrapped inside a jakarta.persistence.PersistenceException) is thrown after SQL runs and the database itself rejects the statement — a unique index, a foreign key, or a check constraint failing at the database level.
Same short name, different packages, different failure points. Code that catches one by short name without checking the fully-qualified type will silently fail to catch the other. Catch jakarta.persistence.PersistenceException and inspect getCause() for the database-level case; a Bean Validation failure needs its own catch block for jakarta.validation.ConstraintViolationException before that.
What surprised me building this
The identity-vs-sequence result itself wasn’t the surprise — IDENTITY disabling batching is documented, if you know to look. The surprise was in the sweeps: allocationSize=1 landing at 31 instead of the paper-arithmetic 32, and the batch_size sweep collapsing to the same constant the moment batching is enabled at all, in a way that changed depending on whether the test ran alone or as part of the full suite. None of those three things would have made it into this article from reasoning about the configuration in the abstract — they only show up by running the sweep and being willing to publish a number that didn’t match the prediction.
Full transcript and source: docs/03-inserting-objects.md. Reproduce it yourself:
$ git clone https://ankurm.com/git.app/asmhatre/hibernate-demo.git
$ cd hibernate-demo
$ ./mvnw -Dtest=IdentityBatchTest,SequenceBatchTest test
$ ./mvnw -Dtest=AllocationSizeSweepTest test
$ ./mvnw -Dtest=BatchSizeSweepTest test
Frequently Asked Questions
What’s the difference between persist() and the removed save()?
persist() is JPA-standard, returns void, and doesn’t force an id to be generated immediately when using SEQUENCE — which is exactly what makes it batch-friendly. save() was Hibernate’s own pre-JPA method, returned the generated id immediately, and was removed in Hibernate 7. There’s no scenario for this in the repo because the observable difference is in the method signature and portability, not in captured runtime behavior.
How do I tell which constraint exception I’m looking at?
Check the fully-qualified class name in the stack trace, not just the short name — see the section above. If it’s wrapped in jakarta.persistence.PersistenceException, it came from the database; if it’s jakarta.validation.ConstraintViolationException directly, it never reached the database at all.
Further Reading & Cross-References
- 📘 Batch Processing with Hibernate 7 — StatelessSession and high-volume data pipelines in depth
- 📘 Hibernate 7 merge() vs refresh() — when to reach for merge() instead of persist()
- 📘 JPA Cascade Types in Hibernate 7 — CascadeType.PERSIST for parent-child inserts
- 🔗 Official Hibernate 7 User Guide — Batch Processing
- 📘 hibernate-demo: batch inserts companion repo — IdentityBatchTest, SequenceBatchTest, AllocationSizeSweepTest, BatchSizeSweepTest, and the full transcript behind every table above
No Comments yet!