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=trueto 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().
Table of contents
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: 10000Set 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 StatelessSession –
statelessSession.insertMultiple(), bypasses the first-level cache - JPA saveAll() – Spring Data JPA repository with
batch_sizeconfigured - EntityManager loop –
entityManager.persist()in a loop,batch_sizeconfigured
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)
| Approach | Min (ms) | Max (ms) | Avg (ms) | Median (ms) | % vs fastest |
|---|---|---|---|---|---|
| JDBC template | 81 | 191 | 97.40 | 88.00 | 0% |
| StatelessSession.insertMultiple() | 404 | 635 | 441.70 | 423.00 | 381% |
| JPA saveAll() | 424 | 628 | 464.30 | 436.50 | 396% |
| EntityManager.persist() loop | 421 | 491 | 442.40 | 437.50 | 397% |
After Optimization (batch_size: 10000)
| Approach | Min (ms) | Max (ms) | Avg (ms) | Median (ms) | % vs fastest |
|---|---|---|---|---|---|
| JDBC template | 54 | 91 | 59.50 | 55.50 | 0% |
| StatelessSession.insertMultiple() | 62 | 163 | 74.60 | 65.00 | 17% |
| EntityManager.persist() loop | 73 | 115 | 81.60 | 75.50 | 36% |
| JPA saveAll() | 73 | 214 | 94.60 | 77.00 | 39% |
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
| Situation | Recommended Approach |
|---|---|
| Standard JPA, simplest code | saveAll() + batch_size |
| Composite key entities | saveAll() + Persistable<> implementation |
| Bypass first-level cache overhead | StatelessSession.insertMultiple() |
| Maximum throughput, no ORM overhead | JDBC 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
