Skip to content
Preptima
hardConceptCodingScenarioMidSeniorStaff

You pass a thousand new entities to saveAll and the database sees a thousand separate inserts. How do you get them batched?

Batching needs a batch size configured, statements for the same table kept adjacent, and an identifier strategy that does not force a round trip per row. IDENTITY generation silently prevents insert batching, because Hibernate has to execute each insert to learn the key.

5 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive, Product Startup
Practice answering out loud

What the interviewer is scoring

  • Does the candidate check the identifier generation strategy before touching the batch size
  • Whether they know the SQL log looks identical whether or not statements were batched
  • That ordering inserts is connected to batches being per-statement and broken by interleaving
  • Can they say what saveAll does per element and why it may issue a select first
  • Whether the persistence context is flushed and cleared to bound memory during a large load

Answer

Why the identifier strategy decides this before anything else

The first thing to check is not the batch size, it is @GeneratedValue. With GenerationType.IDENTITY, the key is produced by the database as a side effect of the insert, and JPA requires a persisted entity to have its identifier available, because the persistence context is keyed by it. Hibernate therefore has to execute each insert immediately on persist in order to read the generated key back, and a statement that has already been sent cannot be added to a batch. Every other setting you tune is irrelevant while this is in place, which is why "I set the batch size and nothing happened" is such a common report.

GenerationType.SEQUENCE inverts the dependency. Hibernate asks the sequence for a value first, assigns it, and can then defer the insert until flush, at which point many inserts are pending together and can go out as one batch. Better still, a pooled allocation strategy lets one sequence call reserve a block of values, so a thousand rows cost a handful of sequence round trips rather than a thousand. GenerationType.AUTO is not an answer to the question, because what it maps to depends on the dialect and the Hibernate version, so state the strategy explicitly rather than inheriting one.

The trade-off is that identifiers allocated in blocks are no longer dense: a restart abandons the unused remainder of a block, leaving gaps. If some part of the business treats the primary key as a countable, gapless sequence, that is a conversation to have before switching, and the correct outcome is usually that the business gets its own numbering column rather than that you keep IDENTITY.

The settings that have to be in place together

Three properties matter and they are not independent. A batch size turns batching on at all. Ordering inserts and updates matters because the JDBC driver batches consecutive executions of the same statement: ten inserts into one table followed by ten into another give you two batches, whereas the same twenty interleaved give you twenty single statements. Hibernate will sort the pending actions by entity type for you when asked to.

# Nothing batches at all without this.
spring.jpa.properties.hibernate.jdbc.batch_size=50
# Group statements by table so a batch is not broken by an interleaved insert.
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true

Below that there is a driver layer, and it is easy to forget that a JDBC batch is not automatically one network round trip. Some drivers send each statement in the batch separately; PostgreSQL's driver, for instance, needs reWriteBatchedInserts on the connection to rewrite a batch of inserts into a single multi-row statement. So it is entirely possible to have Hibernate batching correctly and still see a thousand round trips, which is the second half of this diagnosis and the reason to measure at the connection rather than at the ORM.

Updates to entities carrying a @Version column are excluded from batching unless you opt in, because a batched update returns only aggregate row counts and Hibernate needs per-row counts to detect a stale version. Turning that on is safe with drivers that report per-statement counts correctly, and it is the sort of claim worth verifying against your own driver rather than assuming.

The SQL log cannot tell you whether it worked

This is the detail that decides whether a candidate has done this or read about it. Hibernate's statement logging prints the SQL it prepares, once per entity, regardless of whether those executions were later grouped into a JDBC batch. So the log looks the same before and after you fix the configuration, people conclude the change did nothing, and the investigation stops.

Measure at the JDBC layer instead. A datasource proxy that counts executeBatch calls, the database's own statement statistics, or a packet count against the database host will all distinguish one round trip from fifty. Hibernate's own statistics can be enabled to report the counts it is tracking. In a test, the strongest assertion is a proxied datasource counting round trips, because it fails when someone later adds an IDENTITY column to the entity and quietly turns batching off again.

saveAll is not a bulk operation

saveAll iterates and calls save on each element, and save is not persist. It decides whether the instance is new and calls persist if so and merge if not, and merge on an instance that is not in the persistence context issues a select to load the current state first. So a loop of "new" objects that already carry an identifier — because you assigned it yourself, or because you are re-importing rows — produces a select per row before the insert per row, doubling the round trips you were trying to reduce. Implementing Persistable to declare newness explicitly, or having the entity carry a version field that is null until first flush, removes that guess.

The other cost of a large saveAll is the persistence context itself, which retains every entity you have persisted so it can dirty-check them at flush. Ten thousand entities means ten thousand retained objects plus their load-time snapshots, and the dirty-check cost at each flush grows with the number retained, so a long import gets slower as it proceeds and can exhaust the heap outright. The remedy is to flush and clear at the batch boundary, in chunks matching the configured batch size, so that the context never holds more than one batch. Note that clearing detaches everything, so anything you still need afterwards must be re-read.

When to leave JPA out of it

For a genuine bulk load, the honest answer is often that none of this is worth configuring. A JdbcTemplate batch update, or the database's own bulk-copy path, writes rows without constructing entities, without a persistence context, and without dirty checking, and it is both faster and simpler to reason about. What you give up is the mapping, the lifecycle callbacks, cascading, and the second-level cache staying coherent, so it suits importing rows nobody's in-memory model is holding, and it suits it well.

Choose deliberately between the two rather than pushing JPA towards a workload it was not designed for. The middle ground — a correctly configured sequence generator, ordered inserts, a batch size, and a flush-and-clear loop — is the right answer when the write is part of ordinary application logic, and the ORM-free path is the right answer when the write is a data movement job wearing an application's clothes.

Likely follow-ups

  • What does a pooled sequence optimiser do to the sequence of identifiers, and does that matter?
  • When would you bypass JPA entirely for a bulk load, and what do you give up?
  • Why does batching an update to a versioned entity need its own setting?
  • How would you prove in an automated test that a save path batches?

Related questions

Further reading

jpahibernatebatchingidentifier-generationpersistence-context