Saving tens of thousands of records with saveAll() turns out to be painfully slow. Checking the logs, you see a long list of INSERT statements—one for every single record. You’ve probably run into this before.

The name saveAll() gives off a “it’ll save everything in one go” vibe, but with the default settings it actually fires off INSERTs one at a time. In this article, we’ll work through why batch INSERT doesn’t kick in and how to configure it so that it does, verifying each step against the SQL logs as we go.

Is saveAll() really doing batch INSERT?

Let’s start by questioning the status quo. saveAll() takes a collection, but internally it simply calls save() once per record—it has nothing to do with JDBC’s batching feature. Unless you’ve enabled batching, one INSERT is sent to the DB on every round trip.

The clearest way to check is to count the SQL statements being issued. During development, show-sql is enough.

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

If you save 10,000 records and see 10,000 lines of INSERT logs, batching isn’t working at all. When you want a more precise picture of “how many times did we round-trip to JDBC,” a SQL logger like p6spy or Hibernate’s statistics (generate_statistics) lets you track even the number of batch executions. This becomes your baseline for verifying the effect later on.

Setting hibernate.jdbc.batch_size

This is the first step to enabling batch INSERT. Prefix the property with spring.jpa.properties to pass it through to Hibernate.

spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true

batch_size is the upper limit on how many statements get bundled into a single batch. A reasonable range is around 20–100. Making it larger reduces network round trips, but it also means accumulating more statements in memory, so cranking it up to something like 1000 can actually backfire. The practical approach is to measure at around 50 first and tune from there.

That said, there are cases where this alone still won’t take effect. There are two reasons, so let’s look at them in order.

Why order_inserts / order_updates are needed

JDBC batching groups statements together when “the same SQL statement appears consecutively.” In other words, if INSERTs into different tables are interleaved—one User, one Order, then another User—the batch gets broken up each time.

That’s where order_inserts=true comes in: Hibernate reorders INSERTs by table at flush time. Because INSERTs into the same table end up grouped consecutively, they can be bundled as a batch. The same applies to updates via order_updates=true.

When bulk-updating versioned (@Version-annotated) entities, also enabling hibernate.jdbc.batch_versioned_data=true makes updates eligible for batching as well.

How IDENTITY generation disables batch INSERT

This is the trickiest pitfall. You’ve set batch_size and order_inserts, yet the INSERTs still come out one at a time. The cause is often the ID generation strategy.

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

GenerationType.IDENTITY delegates ID generation to the DB’s AUTO_INCREMENT. The generated value isn’t known until the INSERT is actually executed. But Hibernate needs to fix the entity’s ID at the moment it’s persisted. As a result, it fundamentally can’t accumulate INSERTs and bundle them later, so batching is disabled.

The workaround is to change the generation strategy. On PostgreSQL or Oracle, you can use SEQUENCE.

@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq")
@SequenceGenerator(name = "user_seq", sequenceName = "user_seq", allocationSize = 50)
private Long id;

Setting allocationSize to a larger value lets Hibernate reserve sequence values in bulk (pooled optimization) and assign IDs before the INSERT. This allows the INSERTs to be bundled into batches.

On DBs without sequences, such as MySQL, the TABLE strategy is another option, but it tends to be slow due to lock contention on the dedicated table. If you want fast bulk INSERTs while keeping AUTO_INCREMENT, switching to JdbcTemplate (described later) is often the more straightforward choice.

On PostgreSQL, use reWriteBatchedInserts as well

Even if Hibernate bundles the batch on its side, the PostgreSQL JDBC driver by default sends multiple INSERT statements one after another. Enabling the driver’s rewrite feature here lets it rewrite multiple INSERTs into a single multi-VALUES INSERT.

spring.datasource.url=jdbc:postgresql://localhost:5432/app?reWriteBatchedInserts=true

With this enabled, statements get combined into the form INSERT INTO users (...) VALUES (...), (...), (...), greatly reducing network round trips and parsing overhead. The key point is that you get the maximum benefit only when it’s paired with Hibernate’s batch_size setting. Either one alone is only half-effective, so on PostgreSQL you should include both.

Comparison with JdbcTemplate.batchUpdate

If you don’t need persistence context management in the first place—pure bulk INSERT—then rather than sticking with JPA, using JdbcTemplate.batchUpdate is fast and simple.

jdbcTemplate.batchUpdate(
    "INSERT INTO users (name, email) VALUES (?, ?)",
    new BatchPreparedStatementSetter() {
        public void setValues(PreparedStatement ps, int i) throws SQLException {
            ps.setString(1, users.get(i).getName());
            ps.setString(2, users.get(i).getEmail());
        }
        public int getBatchSize() {
            return users.size();
        }
    });

When you don’t need JPA features like entity change tracking, cascading, or @Version optimistic locking, this is the clearest approach and doesn’t consume extra memory. Conversely, if you want to update entities right after saving, or cascade-save including associations, keeping JPA is more natural. Decide between them based on the use case, speed, and code complexity. The basics of JdbcTemplate are also covered in Using JdbcTemplate in Spring Boot.

Measuring and verifying before and after

Finally, always confirm with your own eyes that it’s working. There are two things to look at: the number of SQL statements issued and the elapsed time. Before configuration, 10,000 records mean 10,000 round trips; after configuration, success looks like the batch count dropping to a few hundred. Use the statistics logs mentioned earlier or p6spy to check how many times batches were executed.

One more thing: in large loops, the persistence context keeps accumulating entities, so insert flush() and clear() every certain number of records to free memory.

@Transactional
public void bulkInsert(List<User> users) {
    for (int i = 0; i < users.size(); i++) {
        entityManager.persist(users.get(i));
        if (i % 50 == 0) {
            entityManager.flush();
            entityManager.clear();
        }
    }
}

Flushing at the same interval as batch_size pairs well together. Also, since heavy writes tend to clog the connection pool as well, it’s reassuring to take a look at HikariCP Connection Pool Tuning alongside this. If you’re concerned about N+1 on the SELECT side, Spring Data JPA Performance Optimization is also worth a look.

Summary

When saveAll() is slow, the basic flow is to set hibernate.jdbc.batch_size and order_inserts, then check whether the ID uses IDENTITY generation. On PostgreSQL, don’t forget reWriteBatchedInserts=true as well. For pure bulk INSERT, switching to JdbcTemplate.batchUpdate is sometimes simpler. Whichever approach you take, getting into the habit of comparing the number of SQL statements issued before and after—to verify “is batching really working?”—will let you diagnose the problem yourself the next time you get stuck.