CodeGym /Courses /Hibernate deep-dive /Checking JDBC batching in import

Checking JDBC batching in import

Hibernate deep-dive
Level 23, Lesson 4
Available

1. Introduction

hibernate.jdbc.batch_size by itself does not promise anything yet. By this point, it is already clear that batching depends on several things at once: id must not force INSERT immediately, a long loop must not bloat the persistence context, and the write flow must not break apart between a bunch of different statements.

To be honest, Hibernate has two kinds of optimizations: the ones you can see right away (for example, you removed N+1 — and the SQL log stopped being “a novel with 500 pages”), and the ones that are very easy to imagine, but hard to prove without the right optics. JDBC batching belongs to the second group. You can set hibernate.jdbc.batch_size=50, write persist() in a loop, and... get nothing but moral satisfaction.

The reason is simple: batching is a conditional mechanism. It only kicks in if the prerequisites match (identical statements, a suitable id strategy, a correct flush cycle, the driver, etc.). So the right mindset here is: “turn it on — verify it.” It is like a seat belt: it is useful, but if you did not fasten it, it will not save you; and if you fastened it “in theory” but the latch did not click, it will not help much either.

In our Commerce Persistence Lab project, SQL trace and statistics are needed exactly for such situations: they let us verify batching technically, not by the vague feeling that “it seems faster now.” That is why we will look not only at time itself (it is deceptive), but at direct signs of batching.

2. Honest comparison: without batching and with batching

To make batching verification look like an engineering experiment rather than reading tea leaves, we need to agree on comparison discipline. The idea is simple: we fix everything that is not directly related to batching, and we change only the batch parameters. Then any change in SQL logs and batch logs can be confidently tied to batching, not to the fact that “we accidentally rewrote half the scenario.”

Below is a convenient table of “what we keep unchanged” and “what we change.” This is not bureaucracy, but a way not to get lost when the result turns out to be unexpected (and it usually does).

Comparison axis What we do for fairness Why it matters
Dataset Import the same number of products with the same data structure Otherwise you are comparing different tasks
Transactional boundary In both cases, one service method = one transaction Batching lives inside one transaction and flush cycle
Id strategy Keep
SEQUENCE
(or at least do not change it between comparisons)
Otherwise the effect will be “about the id strategy,” not batching
SQL observability Enable SQL trace + bind params + batch logs Without this, you cannot prove batching
Mutable parameters Change batch_size and the flush/clear step; touch ordering separately if you are checking a mixed write flow Otherwise you will not understand what exactly produced the effect

Pay attention to a subtle point: if you change the id strategy, batch_size, and add flush/clear all at once, and then see acceleration, you will not be able to honestly answer the question “what exactly helped?” So we will think like engineers: “one hypothesis — one change, as much as possible.”

3. batch-lab profile: logs for batching

What is especially nice about this topic is that we do not need APM, profilers, or a shamanic drum. We need the right set of logs. That is what will tell us where Hibernate puts statements into a batch, when it executes the batch, and why it suddenly did not do so even though we “really asked.”

Below is an example of what the application-batch-lab.yml profile might look like. We deliberately enable the log category org.hibernate.orm.jdbc.batch, because that is exactly where the “technical proof” appears that batching is actually being executed. At the same time, we enable SQL and bind parameters to see the shape of the queries and not guess what went to the DB.

# src/main/resources/application-batch-lab.yml
spring:
  config:
    activate:
      on-profile: batch-lab
  jpa:
    properties:
      # Here we will enable batch size locally through Session#setJdbcBatchSize(...)
      # So globally we can leave 0, to make the baseline definitely "without batching".
      hibernate.jdbc.batch_size: 0

      # We already discussed these flags: they do not "enable batching",
      # but they help group operations when batching is already possible.
      hibernate.order_inserts: true
      hibernate.order_updates: true

      # Important: statistics are needed specifically for educational diagnostics (flushes, number of SQL statements, etc.)
      # In production, they are usually enabled selectively.
      hibernate.generate_statistics: true

logging:
  level:
    # Show SQL to see the shape of queries (even if the driver really sends them in batches)
    org.hibernate.SQL: debug

    # Bind parameters: useful so you do not have to guess "which values are flying around", but the log is very noisy
    org.hibernate.orm.jdbc.bind: trace

    # The main place where batching life is visible
    org.hibernate.orm.jdbc.batch: debug

    # Sometimes useful for educational logs, but do not leave it enabled forever
    org.hibernate.stat: debug

This profile gives us a fair baseline: batching is globally disabled, and the specific import flow below enables it locally on Session. Ordering here does not “enable” anything; it just stays nearby as tuning for the write flow when batching is actually allowed.

If you run the application through Gradle, a typical launch looks like this:

./gradlew bootRun --args='--spring.profiles.active=local,batch-lab'

What matters here is not the spell itself, but the meaning: we enabled a profile that makes batching “observable.” Without that, you will be looking at seconds and milliseconds, and milliseconds are tricky creatures: they like to depend on JVM warm-up, disk state, your laptop’s mood, and the phase of the Moon.

4. Deterministic dataset through draft objects

When we import products, the temptation is huge: “I will generate a List<Product> and call the service twice.” But that very quickly leads to oddities, because an entity is an object with a lifecycle. It can become managed, then detached, and then you suddenly start reusing entities in a non-typical state. And right now we are checking batching, not practicing merge().

So it is more practical to keep import input data in the form of a “draft”: a simple immutable object (for example, a record) from which the service will create a new entity every time. This gives us experimental purity: each run creates new transient objects, and we do not drag along a tail of old managed/detached states.

Here is the minimal ProductDraft:

// src/main/java/com/example/commerce/catalog/service/ProductDraft.java
package com.example.commerce.catalog.service;

import java.math.BigDecimal;

// Data draft for import:
// this is NOT an entity, it has no persistence context lifecycle,
// and it is safe to reuse/generate it for different runs.
public record ProductDraft(
        String sku,
        String name,
        BigDecimal priceAmount
) {
}

And here is a small input data generator. Note: we add runTag to the SKU so that two runs do not conflict on unique sku. Strictly speaking, for an ideal comparison it is better to delete inserted rows and import the same SKU set, but for an educational batching check it is more important to see the batching itself in the logs than to write a scientific paper on microbenchmarks.

// src/main/java/com/example/commerce/labsupport/batch/ProductDraftFactory.java
package com.example.commerce.labsupport.batch;

import com.example.commerce.catalog.service.ProductDraft;

import java.math.BigDecimal;
import java.util.List;
import java.util.stream.IntStream;

public class ProductDraftFactory {

    public List<ProductDraft> generate(int count, String runTag) {
        // Important: runTag makes SKUs unique between different runs (BASE vs BATCH),
        // so that unique constraints (if any) do not break the experiment.
        return IntStream.rangeClosed(1, count)
                .mapToObj(i -> new ProductDraft(
                        runTag + "-SKU-" + i,          // unique SKU within the run
                        "Imported Product " + i,       // the name matters only as a "similar" field
                        BigDecimal.valueOf(10.00)      // price is the same: we are checking batching, not business data
                ))
                .toList();
    }
}

Yes, all prices are the same. And that is fine. Right now we are measuring JDBC batching behavior, not business realism. Our goal is to make SQL as uniform as possible, so the “batches” are easier to see.

5. Import service: baseline and batching

Now let us move to the heart of the lecture: the service that performs the import. We will create two implementations inside one ProductImportService. The reason is simple: it is convenient to run them side by side and compare them in the same environment, without changing configs or rebuilding the application.

In the baseline variant, we explicitly set batch size to 0 (that is, “without batching”) at the Session level. In the batching variant, we set, for example, 20 and add a controlled flush/clear step so that the persistence context does not grow forever.

// src/main/java/com/example/commerce/catalog/service/ProductImportService.java
package com.example.commerce.catalog.service;

import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import com.example.commerce.common.jpa.Money;
import jakarta.persistence.EntityManager;
import org.hibernate.Session;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class ProductImportService {

    private final EntityManager em;

    public ProductImportService(EntityManager em) {
        this.em = em;
    }

    @Transactional
    public void importWithoutBatch(List<ProductDraft> drafts) {
        // Explicitly disable batching for the baseline run:
        // this way we know for sure that the comparison is fair and "there is no magic from config".
        em.unwrap(Session.class).setJdbcBatchSize(0);

        for (ProductDraft draft : drafts) {
            // In the baseline, Hibernate will perform inserts "one by one" (at the JDBC level).
            em.persist(toEntity(draft));
        }
    }

    @Transactional
    public void importWithBatch(List<ProductDraft> drafts, int batchSize) {
        // Allow Hibernate to accumulate JDBC operations into batches up to batchSize.
        // This does NOT yet mean that the batch has "just now" gone to the DB.
        em.unwrap(Session.class).setJdbcBatchSize(batchSize);

        for (int i = 0; i < drafts.size(); i++) {
            em.persist(toEntity(drafts.get(i)));

            // Control persistence context size:
            // flush = send accumulated changes to the DB,
            // clear = remove managed objects from the context so it does not bloat.
            if ((i + 1) % batchSize == 0) {
                em.flush();
                em.clear();
            }
        }

        // Finish the "tail": if the list size is not divisible by batchSize,
        // the last batch must still go to the DB before commit.
        em.flush();
        em.clear();
    }

    private Product toEntity(ProductDraft draft) {
        // Convert every draft into a NEW transient entity:
        // this matters for experimental purity (no reuse of managed/detached objects).
        Product p = new Product();
        p.setSku(draft.sku());
        p.setName(draft.name());
        p.setStatus(ProductStatus.ACTIVE);
        p.setPrice(Money.usd(draft.priceAmount()));
        return p;
    }
}

There is one point that often does not quite sink in at first. setJdbcBatchSize() does not mean “execute the batch”; it means “allow Hibernate to accumulate operations into a batch up to a certain size.” The actual “batch execution” happens at flush/commit time, and that is exactly what we will look at in the logs.

Also note that the flush/clear step here is tied to batchSize. This is not a hard rule, but it is a good starting point. If you call clear() too often, batching may become less efficient (you “provoke” flush more often). If you do it too rarely, the context grows. In an educational project, predictability and clarity matter more.

6. Laboratory runner: execution

To verify batching, we need a scenario run. Doing this through controllers today is not desirable, because it will add unnecessary entities (HTTP, serialization, and so on), while in the performance module we try to stay focused on persistence. So a convenient format for the lab is an ApplicationRunner under the batch-lab profile.

Below is an example runner that performs two runs: baseline and batching. We measure time with System.nanoTime() and print it. This is not “the ultimate truth,” but it is a useful reference value. The main result will still be in the batching logs.

// src/main/java/com/example/commerce/labsupport/batch/ProductImportBatchLabRunner.java
package com.example.commerce.labsupport.batch;

import com.example.commerce.catalog.service.ProductDraft;
import com.example.commerce.catalog.service.ProductImportService;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
@Profile("batch-lab")
public class ProductImportBatchLabRunner implements ApplicationRunner {

    private final ProductImportService importService;
    private final ProductDraftFactory factory = new ProductDraftFactory();

    public ProductImportBatchLabRunner(ProductImportService importService) {
        this.importService = importService;
    }

    @Override
    public void run(ApplicationArguments args) {
        // Generate two NON-overlapping data sets,
        // so that unique constraints (for example, on sku) do not interfere with the second run.
        List<ProductDraft> baseline = factory.generate(1_000, "BASE");
        List<ProductDraft> batched = factory.generate(1_000, "BATCH");

        // Run #1: baseline (batching disabled at the Session level)
        long t1 = System.nanoTime();
        importService.importWithoutBatch(baseline);
        long t2 = System.nanoTime();
        System.out.println("baseline import = " + (t2 - t1) / 1_000_000 + " ms"); // output format example; not a benchmark

        // Run #2: batching enabled, batchSize = 20
        // The main proof is the org.hibernate.orm.jdbc.batch logs, not just these milliseconds.
        long t3 = System.nanoTime();
        importService.importWithBatch(batched, 20);
        long t4 = System.nanoTime();
        System.out.println("batched import = " + (t4 - t3) / 1_000_000 + " ms");  // output format example; not a benchmark
    }
}

This runner is not for a fair micro-benchmark, but to see two modes in one launch and compare them with logs. The second run already happens on a more warmed-up JVM and DB, so the milliseconds here are only an auxiliary label.

To understand the process, it is useful to imagine our verification as a diagram:

flowchart TD
    A["Launch the application with the batch-lab profile"] --> B["Run 1: importWithoutBatch"]
    B --> C["Look at SQL + org.hibernate.orm.jdbc.batch"]
    C --> D["Run 2: importWithBatch + flush/clear"]
    D --> E["Look at SQL + batch logs again"]
    E --> F["Compare: are there batches, how often do they execute, what about flush"]

If you have ever tried to “speed up Hibernate” without logs, then you understand why this diagram matters: it protects you from self-deception.

7. Signs of batching in logs

When you run the baseline import without batching, the SQL log will look roughly the same: lots of separate insert into ... values .... And that is normal. But here is the key question: how do you distinguish “many inserts” from “many inserts sent in batches”?

The naive answer is: “if batching works, SQL becomes shorter.” Unfortunately, often no. The SQL log (org.hibernate.SQL) may continue to print every operation even if the driver really sends them in batches. Therefore, the main proof of batching is the log category: org.hibernate.orm.jdbc.batch

When batching really works, you start seeing messages about batch formation and execution. The exact text depends on the Hibernate version and logging level, but the general meaning will be roughly this: Hibernate accumulated N operations and sends a batch to JDBC.

For orientation, you can keep these signs in mind:

[org.hibernate.orm.jdbc.batch] ... Executing batch size: 20
[org.hibernate.orm.jdbc.batch] ... Added to batch: ...

If in baseline mode you do not see anything from org.hibernate.orm.jdbc.batch at all, and in batched mode you see regular batch messages, congratulations: you have just proved batching without any shamanism.

The second useful signal is the number of flushes. We deliberately do flush() every batchSize records, so from statistics (or indirect signs in logs) you can see that flush happened more often. This is not proof of batching, but it confirms that our scenario is controlled and that we are not just “hoping commit will sort everything out.”

And finally, an important warning: execution time by itself is not proof of batching. Sometimes baseline may not be much worse simply because the dataset is small, the database is local, and everything is dominated by JVM warm-up. Sometimes batched can even be slower because of ordering overhead or because the batch size is too small. So we always look at org.hibernate.orm.jdbc.batch with our eyes, not just at a stopwatch.

8. Interpreting the result: success criteria

First, an important note: in this runner, baseline always goes first, and batched goes second. By the second run, the JVM, the driver, and the database itself are already a bit warmed up, so the milliseconds are only useful as a reference. The proof of batching still lives in org.hibernate.orm.jdbc.batch.

In an educational project, we want quick wins: “turned on batching — it became faster.” That is pleasant, like closing a ticket in five minutes. But adult engineering thinking is a little more boring: we try to understand what exactly changed in the system’s behavior. JDBC batching is about reducing the number of round-trips at the JDBC level. If your database is on the same computer and the delays are small, the gain may not be cosmic. But in a more “real” environment (even just a separate DB container, a slightly slower disk), batches will show themselves more clearly.

What can be considered a good outcome of the experiment:

You see in org.hibernate.orm.jdbc.batch that Hibernate really executes batches of about the expected size. If batch size is 20, the log usually shows batches of about 20 operations, except for the last batch, which may be smaller. That means batching is not just “allowed,” it is actually being applied.

SQL becomes more “uniform” over a long import section, and there is less “ragged” alternation between different tables in the logs. Especially if hibernate.order_inserts is enabled. In our import, we write only Product, so it should look as clean as possible.

The context does not bloat because we do clear(). You will feel this even without a profiler: the application will not suddenly slow down on large volumes, and in the worst case it will not hit an OutOfMemoryError. This is also part of “checking batching,” because batching almost always goes together with “managing the size of the unit of work.”

If you see batching logs but no acceleration, that is not a reason to “cancel batching.” It is a reason to honestly say: “batching is being executed, but in my conditions the bottleneck is not JDBC round-trips.” And that is, surprisingly, a very good conclusion. Because it frees you from legends like “Hibernate is slow because one setting was not enabled.”

And if even after that the entity loop remains too expensive, then the problem is no longer that batching is “badly enabled.” That means the approach with managed entities loses on cost, and then you have to move to bulk operations or a lower-level write flow.

9. Mini batching checklist

Instead of long lists, it is more useful to keep a short map. Below is the minimal version that is convenient to run through your head before saying “batching works.”

Step What you do Where you confirm it
1 Run an insert-heavy scenario in one transaction Service method @Transactional, clear persist() loop
2 Make sure the id strategy does not break batching The code/mapping uses
SEQUENCE
, not
IDENTITY
3 Enable batch logs logging.level.org.hibernate.orm.jdbc.batch=DEBUG
4 Set a batch size in the batched variant session.setJdbcBatchSize(20) or hibernate.jdbc.batch_size=20
5 Control the context size flush() / clear() in the loop
6 Prove batching The logs contain org.hibernate.orm.jdbc.batch messages about batch execution

This checklist will not make you a “performance engineer in one evening,” but it does make you a person who does not write in a PR, “turned on batching, it got faster, probably.”

10. Common mistakes when checking batching in Hibernate

Mistake #1: comparing two runs while changing everything at once.
It is very easy to fall into the trap of “now I will enable batch_size, change the id strategy, add flush/clear, enable ordering, rewrite the loop, and it will get faster.” Sometimes it really will. But you will not be able to explain why. In production, this turns into a situation where a month later someone changes one setting back — and everything falls apart, because nobody understood the cause-and-effect relationship.

Mistake #2: treating execution time reduction as the only proof of batching.
Execution time can improve because of JVM warm-up, disk cache, or because the second run accidentally got a more favorable DB state. If you did not look at org.hibernate.orm.jdbc.batch, you do not know whether batching was present or not. And conversely: batching may be present, but time may barely change if your bottleneck is not JDBC round-trips at all.

Mistake #3: enabling hibernate.jdbc.batch_size but forgetting about IDENTITY.
This is a classic. You enable batch size, and Hibernate silently does not batch inserts because it needs to get the id immediately after each INSERT. The batching logs stay empty, but you may not notice if org.hibernate.orm.jdbc.batch is not enabled. So id strategy is not a “DBA topic,” but part of runtime behavior.

Mistake #4: not managing persistence context size in a large loop.
Sometimes batching does not give the expected effect simply because the application starts spending time on context growth, snapshots, and dirty checking infrastructure, rather than on sending SQL. As a result, you enabled batching, and the gain was eaten by memory and GC. This is especially “fun” on 50_000 rows, when it suddenly turns out that the database is not slow — you are just holding too many managed objects.

Mistake #5: calling flush() on every iteration.
This can accidentally hammer batching to death. If you force Hibernate to synchronize after every persist(), you turn the unit of work into a sequence of micro-commits inside one transaction (not literally, but in effect). The database will receive very frequent small portions of work, and batching will not have time to build up a batch. In the end, you enabled batching — and then you did not give it a chance to show itself.

1
Task
Hibernate deep-dive, level 23, lesson 4
Locked
Profile `batch-lab` for comparing baseline and batched imports
Profile `batch-lab` for comparing baseline and batched imports
1
Task
Hibernate deep-dive, level 23, lesson 4
Locked
Profile `stats-batch-lab` and Hibernate Statistics output
Profile `stats-batch-lab` and Hibernate Statistics output
1
Survey/quiz
Batch requests, level 23, lesson 4
Unavailable
Batch requests
Optimizing bulk data writes
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION