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.
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
- Nobody called save, but the UPDATE still went to the database. Explain how that happened.mediumAlso on jpa and hibernate5 min
- This endpoint logs one query per row. Why are you seeing N+1 queries, and how do you fix them?mediumAlso on jpa and hibernate5 min
- What does batching buy you when serving an LLM, and what does it do to your latency budget?hardAlso on batching4 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardSame kind of round: concept6 min
- How do goroutines leak, and how would you find a leak in a running service?mediumSame kind of round: concept5 min
- Design rejected the native control, so you are building a custom one. How do you make it accessible?hardSame kind of round: concept5 min
- A client disconnects but your server keeps working on their request. How does cancellation actually propagate in .NET?mediumSame kind of round: concept4 min
- This form marks invalid fields with red text and a red border. What has to change?mediumSame kind of round: concept4 min