1. Introduction
When you write ordinary CRUD, one INSERT or one UPDATE rarely scares anyone. But as soon as a scenario like “import 10 000 products” appears, Hibernate suddenly turns into a very honest friend: it does exactly what you asked for… it just does it 10 000 times. And each such operation is a separate conversation with the database, with all the associated overhead.
Until now, we were mostly interested in Hibernate behaving correctly: where the transaction boundary is, when flush happens, why an object is managed or detached. In the performance block, the question does not change radically, but it becomes much more down-to-earth: the same entity-oriented write loop must be made to hit the database not one request at a time.
Simply put, the problem with bulk writes is almost never that PostgreSQL “cannot insert quickly”, but rather that the application does too many separate steps: prepares a statement, sends it over the network, waits for a response, repeats. Even if each step takes milliseconds, 10 000 steps become noticeable time. JDBC batching is an attempt to reduce the number of such “separate trips” by grouping same-type operations into batches.
Important point: batching is not about business logic and not about “the right annotations”. It is an engineering optimization at the level of how exactly Hibernate sends SQL to JDBC.
2. What JDBC batching is
JDBC batching sounds almost like “turning on turbo mode”. In practice, it is much more mundane: Hibernate tries to use the standard JDBC mechanism where the same SQL statement is executed many times with different parameters, but sent to the driver as a batch. In essence, it is “collect several identical operations and send them together” in order to reduce communication overhead.
It is useful to get rid of a few false expectations right away. JDBC batching does not turn your 10 000 inserts into one big “atomic” super-insert. It also does not turn Hibernate into a bulk tool at the level of “one command updated a million rows” — that is a different class of task. And most importantly: batching does not appear out of thin air. It requires Hibernate to be able to form many identical SQL operations that can be grouped.
Batching is not one big INSERT
When a developer hears “batch insert”, they often imagine: “Aha, so there will be one SQL statement like INSERT INTO ... VALUES (...), (...), (...)”. Sometimes drivers really can rewrite batched inserts into a multi-row INSERT (for example, the PostgreSQL driver can do this with certain settings), but that is a secondary optimization on the driver side. Hibernate primarily thinks like this: “I have one SQL template and many parameter sets; let’s send them as a batch via addBatch() / executeBatch().”
So batching is still a series of separate inserts/updates, just grouped at the JDBC level. The database still has to process each row, check constraints, indexes, and foreign keys. So batching is about reducing communication overhead, not about canceling the laws of physics.
JDBC batching and @BatchSize are different things
In our course we already had a module about fetching, where @BatchSize and the idea of “loading lazy associations in batches” came up. That is also called batching, but it is read batching (SELECT), while today’s topic is write batching (INSERT/UPDATE/DELETE) at the JDBC level.
To avoid confusion, keep a simple mental boundary. @BatchSize tries to make fewer SELECTs during lazy loading of associations. JDBC batching tries to make fewer separate database calls during bulk writes. These mechanisms live in different places, solve different problems, and are configured differently. If you mix them up, you get a wonderful combination: “nothing got faster, but it became harder to understand.”
3. Where batching sits in the Hibernate lifecycle
The most common reason batching “does not work” is that the developer is looking for it in the wrong place. They look at the code, see a loop with persist(), and think: “Well, that is bulk write.” And Hibernate is quietly smiling at that moment, because in the loop you are doing… in-memory changes, not SQL execution.
Now we will build the chain of where batching actually appears. And yes, it will sound a little more “mechanical” than ordinary CRUD explanations — but that is exactly what makes a deep dive a deep dive.
flowchart TD %% This is not "how your for-loop is written", but "where SQL execution actually happens". %% Batching appears at flush time, when Hibernate reaches JDBC operations. A["Java code: em.persist(entity)"] --> B["Entity becomes managed"] B --> C["Hibernate puts the operation into a queue (ActionQueue)"] C --> D["Flush (before commit or earlier)"] D --> E["SQL generation and PreparedStatement preparation"] E --> F["statement.addBatch() for same-type operations"] F --> G["statement.executeBatch()"] G --> H["PostgreSQL executes inserts/updates"]
persist() is not SQL yet
When you call em.persist(product), Hibernate, roughly speaking, records for itself: “Okay, I will need to insert this entity.” But until the flush phase happens, not a single INSERT may reach the database.
In the training project Commerce Persistence Lab, this is especially convenient to observe in an import scenario: you may have a list of Product, and you “persist” them in a loop. From the outside it looks like you are “inserting”, but in fact you are only accumulating the intention to insert.
A mini example in the style of our project (the code is short and as direct as possible):
package com.example.commerce.catalog.service;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class ProductImportService {
@Transactional
public void importProducts(List<Product> products, EntityManager em) {
// Important: persist() does NOT send SQL immediately.
// Here we only register new entities in the persistence context.
for (Product product : products) {
em.persist(product);
}
// Real INSERTs will go to the DB on flush/commit (if flush did not happen earlier).
}
}
This code creates the right context for batching (many same-type operations in one transaction), but it still does not prove that batching will actually happen. Why? Because batching starts only where Hibernate actually sends SQL.
Flush is the “moment of truth” for batching
Batching is always tied to how Hibernate executes SQL during flush. A simple formula is enough to remember: batching lives inside one flush cycle. If flush happens, Hibernate starts executing accumulated INSERT/UPDATE, and that is where it tries to group same-type operations into a batch.
Therefore, if your transaction is very short and flush happens often (or you call flush() yourself for every little thing), the batch may simply not have time to build up. But if flush happens once at the end of a large transaction, Hibernate gets a chance to collect a decent batch.
For now, it is enough to fix the idea: batching is an optimization of the SQL sending phase, not the object-changing phase.
JDBC level: addBatch() and executeBatch()
At the lowest level, Hibernate communicates with the database through JDBC. For inserts and updates it usually uses PreparedStatement, because that is safer and faster than constantly building an SQL string. And batching works only when Hibernate can repeatedly execute the same PreparedStatement, changing only the parameters.
If Hibernate ends up with different SQL templates (different tables, different column sets, different order of actions), it physically cannot “collect them into one batch”. That is why batching does not like chaos. It likes repeatability. Just like all of us on Monday morning: give us the same tasks — and we are faster.
4. Conditions for batching
Now the most practical part: what must line up for batching to even have a chance. It is important to see this not as a “checklist of magic boxes”, but as a cause-and-effect model. Hibernate is not required to do batching; it can merely try, if it has suitable material.
Below is a compact table that helps keep things straight. After it, we will go through the key points in text form.
| Condition | What it means in plain English | Why batching will not happen without it |
|---|---|---|
| hibernate.jdbc.batch_size > 0 | batching is enabled and there is a batch limit | otherwise Hibernate will not use executeBatch() at all |
| Many same-type operations in one Session/transaction | there is “raw material” for a batch | there is nothing to assemble a batch from |
| The SQL shape is identical | one SQL template repeated many times | different statements cannot be grouped |
| Flush happens in “chunks”, not after every operation | a batch has time to accumulate | the batch breaks into crumbs |
| ID generation does not force an immediate INSERT | Hibernate can defer inserts | if insert is needed right now, batching breaks |
| The driver and dialect support batching properly | JDBC will actually execute the batch | sometimes the driver “supports it” only on paper |
This list makes it obvious why hibernate.jdbc.batch_size alone is not enough. Further down the chain, three things can slow us down: an id strategy that forces an immediate insert; an overgrown persistence context in a long loop; and a broken SQL stream where identical statements do not have time to gather into a proper batch.
Identical SQL operations and a “steady flow”
Imagine that you are importing only Product. Hibernate generates one SQL template, insert into product (...) values (...), and repeats it many times. That is the ideal situation for batching.
Now imagine that in one loop you alternate between Product and Category. From Java’s point of view, everything is beautiful: one loop, two operations. But from batching’s point of view, you get a stream like this: INSERT product, INSERT category, INSERT product, INSERT category… and Hibernate simply cannot collect 20 identical INSERT product operations in a row, because INSERT category keeps getting in between them.
Here is an example that externally looks like “bulk write”, but batching has a harder time with it:
import jakarta.persistence.EntityManager;
import org.hibernate.Session;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Transactional
public void saveCatalog(List<Product> products, List<Category> categories, EntityManager em) {
// Set the batch size locally (handy for experiments in the lab).
em.unwrap(Session.class).setJdbcBatchSize(20);
for (int i = 0; i < products.size(); i++) {
// The action stream is "mixed": INSERT Product, then INSERT Category.
// This reduces the ability to collect a long series of identical statements.
em.persist(products.get(i));
em.persist(categories.get(i));
}
// INSERTs will still happen on flush/commit, but batching may become "ragged".
}
Yes, there is setJdbcBatchSize(20) here. Yes, there is one transaction. But the action stream is “mixed”, and batching becomes less predictable. It may happen partially, in small batches, or may produce almost no effect — it depends on how Hibernate orders the actions in the specific flush phase.
Transaction and flush boundaries
Batching almost always implies that you have one “unit of work” inside which you perform many inserts or updates. It is the transaction that gives Hibernate room to manoeuvre: it can accumulate changes and send them at the end (or in batches).
If there is no transaction, or it is split into many microtransactions, batching will be minimal. It is like trying to build a truck out of boxes, but every 30 seconds someone says: “Drive off immediately, even if there is only one box.” The truck leaves, batching does not emerge.
For our labs, this means a simple discipline: bulk scenarios must be implemented as one transactional service method. Not for the beauty of the annotation, but because batching without it is like coffee without caffeine: it looks similar, but why bother.
The driver is involved too
In our training stack we work with PostgreSQL, and the PostgreSQL JDBC driver generally supports batching. But in real life it sometimes turns out that the “setting is enabled”, while the effect is almost nonexistent, because the driver processes batches inefficiently or the server does not receive the expected optimization.
Here it is useful to keep a calm engineering attitude. Hibernate batching is a strategy to “reduce overhead”. If you enabled batching and saw no difference, that is not a reason to argue with reality. It is a reason to measure, inspect logs, make sure grouping is actually happening, and only then draw conclusions.
5. Enabling batching in Spring Boot + Hibernate
In practice, batching is enabled through hibernate.jdbc.batch_size. You can set it globally in Hibernate properties or locally on a Session via setJdbcBatchSize(...), if you want to check a specific import or temporarily override behavior for a particular write flow.
Here it is important to fix only two ideas. batch_size is the upper batch limit, not a promise of “exactly 20”. And the mere existence of the setting proves nothing: if IDENTITY, frequent flush(), or a ragged SQL stream break grouping, Hibernate is not obliged to show a beautiful batch just because you wrote a number in the config.
6. Scenarios in Commerce Persistence Lab
So that today’s topic does not hang in the air, let us tie it to our domain. We have a Product entity, and importing products is a natural insert-heavy scenario. It is the “ideal testbed” for batching, because the inserts are same-type and repeat many times.
Let us start with the cleanest variant — importing only products. It is good because even a weak SQL log quickly shows the repetition.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Transactional
public void importOnlyProducts(List<Product> products, EntityManager em) {
// Maximum "steady" flow: only Product -> repeatable INSERT.
for (Product p : products) {
em.persist(p);
}
// This is the best candidate for efficient batching (with the setting enabled).
}
Now for a contrasting example — importing products together with something else (categories, assignment relationships, etc.). Even if the code still looks “bulk-like”, the SQL flow becomes ragged, and batching may group less effectively.
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Transactional
public void importProductsAndCategories(List<Product> products,
List<Category> categories,
EntityManager em) {
for (int i = 0; i < products.size(); i++) {
// Mixing different entity types usually means different INSERTs.
em.persist(products.get(i));
em.persist(categories.get(i));
}
// At flush time Hibernate will find it harder to collect large batches of same-type operations.
}
The second example is not “bad” by itself. It simply illustrates the main idea of the lecture: batching does not depend on how many for loops you have. It depends on how homogeneous an SQL stream Hibernate can assemble at flush time.
7. Verifying batching through logs
You should confirm batching through logs, not by the feeling that “it seems faster”. The SQL log (org.hibernate.SQL) shows the shape of the queries, but not always the fact of batch sending itself: you may see the same insert into product ..., even if the driver executed them in batches.
The direct indicator is org.hibernate.orm.jdbc.batch. That is where you can see whether Hibernate reached addBatch() / executeBatch() and actually started executing batches. Execution time is a useful secondary metric, but not proof by itself.
8. Typical mistakes in JDBC batching
Mistake #1: assuming batching starts just because there is a loop.
A loop is only a shape of Java code. Hibernate cares not about the loop, but about which SQL operations it can collect during the flush phase. The same for can produce a “clean” stream of same-type INSERTs, or it can produce a mixed set of operations that barely batch at all.
Mistake #2: confusing write batching with read batching (@BatchSize).
This is a very common confusion because the word is the same, but the meaning is different. @BatchSize helps with lazy SELECTs and reduces the number of queries when loading associations. JDBC batching helps send INSERT/UPDATE/DELETE operations in batches. If you enable one and expect the effect of the other, you will get only disappointment and a mild hatred of abstractions.
Mistake #3: setting an enormous hibernate.jdbc.batch_size without understanding that it is a limit, not a guarantee.
If you set 500 or 1000 “just in case”, you are not obligated to get batches of exactly 1000. But you very well may get unexpected memory usage and strange behavior in long transactions. For a start, 20–50 and log-based observation is much more reasonable.
Mistake #4: “proving” batching only by execution time.
Time is a useful indicator, but too noisy. One run being faster than another is not always because of batching. Sometimes it is JIT, sometimes disk cache, sometimes database warmup, sometimes just a lucky day. If you have not confirmed batching in org.hibernate.orm.jdbc.batch logs, you are still believing, not knowing.
Mistake #5: failing to separate the “accumulating changes” phase from the “sending SQL” phase.
If you expect batching “at the moment of persist()”, you will look for the effect in the wrong place. persist() registers the entity; batching appears in the flush phase. Until this becomes your internal model, any experiments with bulk writes will feel like magic: “sometimes it works, sometimes it does not.”
GO TO FULL VERSION