Spring Data JPA: Batch Insert Performance Tuning

Two configuration settings improve a slow Hibernate batch insert in Spring Boot Data JPA. Without them, saveAll() sends one INSERT per row – even across 10,000 rows. With them, median insertion time drops from 437 ms to 77 ms on the same dataset.

I learned this the hard way. A service I maintained was slow under load, and the root cause was a forgotten Hibernate detail. After fixing it, I shared the finding on LinkedIn – the post reached 102,000 impressions, 514 reactions, and 394 saves. Clearly, this problem affects many developers.


TL;DR – Add hibernate.jdbc.batch_size: 10000 (tune for your load) to your Hibernate properties and ?reWriteBatchedInserts=true to your datasource URL. These two flags enable Hibernate batch flushing and rewrite individual INSERTs into a single multi-row INSERT. Result: 6x faster bulk inserts compared to untuned JPA saveAll().

Why saveAll() is Slow Without Configuration

By default, Hibernate’s saveAll() is a plain loop. For each entity, it calls PreparedStatement.execute() once. Each INSERT is its own round-trip to the database. There is no batching.

Setting hibernate.jdbc.batch_size changes this. Hibernate calls PreparedStatement.addBatch() for each entity, then calls executeBatch() once the batch size is reached or at flush time. Instead of N round-trips for N rows, you get ceil(N / batch_size) round-trips.

reWriteBatchedInserts=true on the PostgreSQL JDBC driver takes this further. It rewrites the batched statements into a single multi-row INSERT:

INSERT INTO items VALUES (...), (...), (...)

This reduces protocol overhead. It also explains why the JDBC baseline improved from 88 ms to 56 ms between test runs, even though batch_size does not affect JDBC directly.

Hibernate Batch Size Configuration

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/postgres?reWriteBatchedInserts=true
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 10000

Set batch_size to at least the number of rows you expect in a single transaction flush. This benchmark uses 10,000 to cover all rows in one flush without splitting.

I love Spring Data JPA for the way it helps you maintain a project and deliver features quickly. But there is a lot of magic under the hood – and hibernate batch size configuration is one of those things you need to know about.

For testing this and other batch configurations, I compared four approaches:

  • JDBC batchUpdate – raw SQL with jdbcTemplate.batchUpdate(), the performance baseline
  • Hibernate StatelessSessionstatelessSession.insertMultiple(), bypasses the first-level cache
  • JPA saveAll() – Spring Data JPA repository with batch_size configured
  • EntityManager loopentityManager.persist() in a loop, batch_size configured

JDBC batchUpdate (baseline)

Classic JDBC using jdbcTemplate.batchUpdate(). The driver collects all rows and sends them in one round-trip. No Hibernate is involved, so batch_size has no effect – batching is always on.

@Transactional
public Order createOrder(CreateOrderCommand command) {
    UUID orderId = UUID.randomUUID();
    Instant now = Instant.now();
    Timestamp nowTs = Timestamp.from(now);
    jdbcTemplate.update(INSERT_ORDER_SQL, orderId, command.userId(), nowTs, nowTs);
    List<CreateOrderCommand.ItemEntry> entries = command.items();
    jdbcTemplate.batchUpdate(INSERT_ITEM_SQL, entries, entries.size(), (ps, entry) -> {
        ps.setObject(1, entry.id().getItemId());
        ps.setObject(2, entry.id().getStoreId());
        ps.setObject(3, orderId);
        ps.setString(4, entry.name());
        ps.setInt(5, entry.quantity());
        ps.setBigDecimal(6, entry.cost());
    });
    return new Order(orderId, command.userId(), now, now);
}

Hibernate StatelessSession

StatelessSession skips the first-level cache, dirty-checking, and lifecycle callbacks. The insertMultiple() API hands all entities to Hibernate’s batch machinery in one call.

Key finding: without batch_size, StatelessSession is just as slow as JPA. The batching still relies on that configuration. The API is not the lever – the config is.

@Transactional
public Order createOrder(CreateOrderCommand command) {
    Order order = new Order();
    order.setUserId(command.userId());
    Session session = entityManager.unwrap(Session.class);
    try (StatelessSession statelessSession = session.statelessWithOptions().connection().open()) {
        statelessSession.insert(order);
        List<Item> items = new ArrayList<>(command.items().size());
        for (CreateOrderCommand.ItemEntry item : command.items()) {
            items.add(new Item(item.id(), order, item.name(), item.quantity(), item.cost()));
        }
        statelessSession.insertMultiple(items);
    }
    return order;
}

JPA saveAll()

Standard Spring Data JPA. Without batch_size, saveAll() sends one INSERT per row. With it, Hibernate accumulates statements and flushes them in batches.

One important detail: the entity implements Persistable<ItemId> and tracks isNew manually. Without this, Hibernate issues a SELECT before each INSERT to check whether the entity exists. It cannot determine new vs. existing from a composite ID alone – so it queries first. That turns a bulk insert into an N+1 SELECT problem.

@Transactional
public Order createOrder(CreateOrderCommand command) {
    Order order = new Order();
    order.setUserId(command.userId());
    Order savedOrder = orderRepository.save(order);
    List<Item> items = command
            .items()
            .stream()
            .map(item -> new Item(item.id(), savedOrder, item.name(), item.quantity(), item.cost()))
            .toList();
    itemRepository.saveAll(items);
    return savedOrder;
}
@Immutable
@Entity
@Table(name = "items")
public class Item implements Persistable<ItemId> {
    @EmbeddedId
    private ItemId id;
    @Transient
    private boolean isNew = true;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;
    @Column(nullable = false)
    private String name;
    @Column(nullable = false)
    private int quantity;
    @Column(nullable = false, precision = 19, scale = 2)
    private BigDecimal cost;
    // ... constructor, getters
    @Override
    public boolean isNew() {
        return isNew;
    }
    @PostPersist
    @PostLoad
    private void markNotNew() {
        this.isNew = false;
    }
}

EntityManager.persist() loop

Direct JPA EntityManager. Hibernate accumulates persist() calls and flushes them in batches because batch_size is set.

Unlike StatelessSession, the persistence context (first-level cache) is active. All 10,000 entities are tracked until the transaction commits, adding a fixed overhead at flush time.

@Transactional
public Order createOrder(CreateOrderCommand command) {
    Order order = new Order();
    order.setUserId(command.userId());
    entityManager.persist(order);
    for (CreateOrderCommand.ItemEntry item : command.items()) {
        entityManager.persist(new Item(item.id(), order, item.name(), item.quantity(), item.cost()));
    }
    return order;
}

Testing performance batch inserting with w/wo tuning

10 runs per approach (2 warmup runs excluded from measurements), inserting 10,000 entries per run. The database was cleaned between each run. Hardware: Apple M4 Pro 24 GB, JDK 21, PostgreSQL 17.

Before Optimization (no batch_size)

ApproachMin (ms)Max (ms)Avg (ms)Median (ms)% vs fastest
JDBC template8119197.4088.000%
StatelessSession.insertMultiple()404635441.70423.00381%
JPA saveAll()424628464.30436.50396%
EntityManager.persist() loop421491442.40437.50397%

After Optimization (batch_size: 10000)

ApproachMin (ms)Max (ms)Avg (ms)Median (ms)% vs fastest
JDBC template549159.5055.500%
StatelessSession.insertMultiple()6216374.6065.0017%
EntityManager.persist() loop7311581.6075.5036%
JPA saveAll()7321494.6077.0039%

With the optimization, the median for JPA saveAll() drops to 77 ms vs. 55 ms for raw JDBC. The extra 22 ms is JPA overhead – the cost of working at a higher level of abstraction. For most use cases, it is an acceptable trade-off.

One more finding worth noting: StatelessSession.insertMultiple() dropped from 423 ms to 65 ms with the same batch_size config. Without it, even the “bypass everything” Hibernate API sends one INSERT per row. The configuration is the lever, not which API you choose

When to Use Each Approach

SituationRecommended Approach
Standard JPA, simplest codesaveAll() + batch_size
Composite key entitiessaveAll() + Persistable<> implementation
Bypass first-level cache overheadStatelessSession.insertMultiple()
Maximum throughput, no ORM overheadJDBC batchUpdate()

At the end

If you like this post, follow me on LinkedIn to get to know about new articles and quality posts on software development.

If you’re interested in Spring Data JPA content, I have more. Just take the time and open Spring Data JPA posts. I highly recommend my latest article: Spring Data JPA with Kotlin: Best Practices for Spring Boot

Avatar photo
Dmitry Protsenko

Senior Software Engineer
Specialized on Java / Kotlin and CyberSecurity
Author of this blog

Articles: 34