← How-To

Spring Boot JPA Batch Insert Performance: Why Your Inserts Are Slow

The illustrator delivered one thousand brushstrokes in one thousand separate envelopes. We have configured a batch size.

March 27, 2026 · 20 min read
The illustrator delivered one thousand brushstrokes in one thousand separate envelopes, each with a return receipt. We have configured a batch size and asked him to use a larger envelope.

Why Your Spring Boot Inserts Are Making 1,000 Round Trips

Good evening. Inserting 1,000 rows should take milliseconds. If it takes seconds — or longer — the problem is not PostgreSQL. It is a stack of Spring Boot and Hibernate defaults that each add latency, and they are worth understanding individually:

  1. GenerationType.IDENTITY disables JDBC batching entirely. Hibernate must execute each INSERT individually to get the returned ID.
  2. No hibernate.jdbc.batch_size configured. Without this, Hibernate sends each INSERT as a separate JDBC statement even when batching is theoretically possible.
  3. Insert order not optimized. Interleaved entity types break batch grouping — Hibernate flushes a batch every time the entity type changes.
  4. JDBC driver sends individual INSERTs. Even when Hibernate batches at the JDBC level, the PostgreSQL JDBC driver sends each INSERT as a separate SQL statement unless reWriteBatchedInserts is enabled.
  5. saveAll() may trigger SELECT-before-INSERT. If Spring Data cannot determine that an entity is new, it calls merge() instead of persist(), generating a SELECT to check for existing rows before inserting.

Each layer compounds. Fixing one alone produces modest improvement. Fixing all of them together produces something rather more satisfying: 10,000 rows go from approximately 8 seconds to under 250 milliseconds.

For the full Spring Boot PostgreSQL optimization playbook — HikariCP, N+1 queries, projections, and pagination — see the comprehensive Spring Boot guide.

The IDENTITY Trap — Why Hibernate Cannot Batch Your Inserts

How GenerationType.IDENTITY Works

Allow me to show you the entity configuration that, I'm afraid, prevents batching entirely:

The entity that prevents batching
@Entity
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private Long authorId;

    // getters and setters
}

GenerationType.IDENTITY tells Hibernate to let the database generate the primary key using PostgreSQL's SERIAL or GENERATED ALWAYS AS IDENTITY column. PostgreSQL assigns the ID during the INSERT and returns it via the RETURNING clause.

Here is where it becomes problematic: Hibernate needs each entity's ID immediately after insertion. The persistence context (first-level cache) maps entities by their primary key. Without the ID, Hibernate cannot manage the entity's lifecycle — it cannot track it, flush changes to it, or use it as a foreign key reference.

Because the ID is only available after each individual INSERT executes, Hibernate must:

  1. Execute the INSERT for entity 1
  2. Read the returned ID
  3. Assign the ID to the entity object
  4. Execute the INSERT for entity 2
  5. Read the returned ID
  6. ... repeat for every entity

This makes JDBC batching impossible. Each INSERT is a separate database round trip. 1,000 entities = 1,000 round trips. One at a time. Patiently waiting for each to return.

For a deeper explanation of why IDENTITY prevents batching and how PostgreSQL sequences solve it, see the IDENTITY vs SEQUENCE deep dive.

The Fix — Switch to GenerationType.SEQUENCE

The remedy is straightforward:

SEQUENCE generation \u2014 batching enabled
@Entity
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "book_seq")
    @SequenceGenerator(name = "book_seq", sequenceName = "book_id_seq", allocationSize = 50)
    private Long id;

    private String title;
    private Long authorId;

    // getters and setters
}

With sequence generation, Hibernate calls nextval('book_id_seq') to pre-allocate a range of IDs. With allocationSize = 50, a single nextval call reserves 50 IDs. Hibernate assigns these IDs in memory without additional database calls.

Since Hibernate already knows the IDs before executing the INSERTs, it can group them into JDBC batches.

The PostgreSQL side: The sequence increment must match the allocationSize:

PostgreSQL sequence with matching increment
CREATE SEQUENCE book_id_seq INCREMENT BY 50;

If the sequence increment does not match allocationSize, Hibernate's ID assignment may collide with other application instances or produce unexpected gaps.

About ID gaps: Sequence pre-allocation leaves gaps in IDs when the application restarts (unused pre-allocated IDs are lost). I mention this because it comes up frequently, and I would like to put the concern to rest: gaps are cosmetic. They do not waste storage, do not affect performance, and do not violate any database constraint. An allocationSize of 50 is a good default for most workloads. If you are inserting millions of rows at a time, increase it to 100 or 200 to further reduce sequence round trips.

Spring Data JPA requires no changes to the repository interface. The entity annotation is the only change.

What About GenerationType.TABLE?

GenerationType.TABLE simulates sequences using a database table with row-level locking. Each ID allocation requires a row lock, UPDATE, and commit on the ID table.

I would not recommend TABLE generation with PostgreSQL. PostgreSQL has native sequences — they are faster, lock-free, and designed for exactly this purpose. TABLE generation exists for databases that lack sequences. PostgreSQL is not one of them.

GenerationType.AUTO in Hibernate 5+ may default to TABLE on some configurations. Explicitly specify SEQUENCE to avoid this:

Always explicit, never AUTO
// Always explicit. Never AUTO.
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "book_seq")

Configuring Hibernate Batch Size

hibernate.jdbc.batch_size

Switching to SEQUENCE generation enables batching, but — and this is worth noting — Hibernate does not batch by default. You must configure the batch size explicitly.

Enable Hibernate JDBC batching
# application.properties
spring.jpa.properties.hibernate.jdbc.batch_size=50

This tells Hibernate to group up to 50 INSERT statements into a single JDBC batch execution. Instead of sending 1,000 individual INSERTs, Hibernate sends 20 batches of 50.

Without this setting, Hibernate sends each INSERT individually even with SEQUENCE generation — you solve the ID problem but still make 1,000 round trips. The configuration is as important as the annotation.

The batch is flushed when:

  • The batch reaches the configured size (50 statements)
  • The transaction commits
  • A different entity type is inserted (breaking the batch — see the next section)
  • An explicit entityManager.flush() is called

Recommended values: 25–100 for most workloads. Higher values have diminishing returns and increase memory usage for the batch buffer. A batch size of 50 is a safe starting point.

hibernate.order_inserts and hibernate.order_updates

Two more settings that deserve your attention:

Enable insert and update ordering
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true

By default, Hibernate flushes entities in the order they were persisted. If your code persists Book, Author, Book, Author, Hibernate sends interleaved INSERTs:

Without ordering \u2014 broken batches
INSERT INTO book ...;   -- batch of 1
INSERT INTO author ...; -- batch of 1
INSERT INTO book ...;   -- batch of 1
INSERT INTO author ...; -- batch of 1

Every entity type change breaks the batch — a detail that can undo your batching work if you are persisting multiple entity types in the same transaction. With order_inserts=true, Hibernate reorders the flush to group entities by type:

With ordering \u2014 grouped batches
INSERT INTO book ...;   -- batch of N books
INSERT INTO author ...; -- batch of N authors

order_updates=true does the same for UPDATE statements.

Both settings should always be enabled when using batch inserts. There is no practical downside — the reordering happens in Hibernate's flush logic before any SQL is sent to the database.

JDBC Batch Rewriting — The Driver-Level Optimization

What reWriteBatchedInserts Does

There is one more layer to attend to. Even with Hibernate batching enabled, the PostgreSQL JDBC driver (pgjdbc) sends each INSERT in the batch as a separate SQL statement by default:

Without rewriting \u2014 separate statements
-- What the driver sends without rewriting (3 separate statements):
INSERT INTO book (title, author_id) VALUES ('Book 1', 1);
INSERT INTO book (title, author_id) VALUES ('Book 2', 1);
INSERT INTO book (title, author_id) VALUES ('Book 3', 2);

With reWriteBatchedInserts=true, the driver rewrites the batch into a single multi-row INSERT:

With rewriting \u2014 single multi-row INSERT
-- What the driver sends with rewriting (1 statement):
INSERT INTO book (title, author_id) VALUES ('Book 1', 1), ('Book 2', 1), ('Book 3', 2);

One statement instead of many. PostgreSQL processes the insert more efficiently — one parse, one plan, one execution instead of 50. The improvement is substantial: typically a 50–80% reduction in insert time for large batches.

Enabling reWriteBatchedInserts

Add the parameter to the JDBC connection URL:

Enable JDBC batch rewriting
# application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb?reWriteBatchedInserts=true

This is a pgjdbc driver flag, not a Hibernate setting. It operates at the JDBC level beneath Hibernate. No code changes required — only the connection URL changes.

The rewriting is compatible with Hibernate batching: Hibernate sends a JDBC batch, and the driver rewrites it into a multi-row INSERT before sending it to PostgreSQL.

For more on pgjdbc configuration, see the JDBC prepared statement behavior guide.

Caveats

  • reWriteBatchedInserts does not work with RETURNING clauses in all cases. IDENTITY generation uses RETURNING, which is another reason to prefer SEQUENCE generation. With SEQUENCE, the IDs are known before the INSERT and no RETURNING clause is needed.
  • PostgreSQL has a maximum number of bind parameters per statement (32,767 by default). A batch of 50 inserts with 10 columns each uses 500 parameters — well within the limit. Very large batches (thousands of rows with many columns) may need to be split.
  • The rewriting is transparent to the application. Error handling, constraint violations, and transaction semantics are preserved. If any row in the multi-row INSERT violates a constraint, the entire batch fails — the same behavior as without rewriting.

The saveAll() Pitfall

What saveAll() Actually Does

I should address a common misconception: repository.saveAll(entities) does not generate a single batch INSERT. It iterates through the list and calls save() on each entity individually.

save() checks whether the entity is new (should be inserted) or existing (should be updated). The check depends on the entity's ID:

  • ID is null → entity is new → entityManager.persist() → INSERT
  • ID is non-null → entity might exist → entityManager.merge() → SELECT to check, then INSERT or UPDATE

With SEQUENCE generation, entities have their IDs assigned before save() is called (Hibernate pre-allocates IDs from the sequence). The ID is non-null. Spring Data calls merge(), which triggers a SELECT for each entity to check whether it already exists in the database.

The result: 1,000 new entities = 1,000 SELECTs + 1,000 INSERTs = 2,000 queries. Twice the work, for rows that do not yet exist.

Making saveAll() Efficient

Option 1 — Let the ID be null before save:

With SEQUENCE generation, Hibernate assigns the ID at persist time, not at entity creation time. If you create the entity without setting the ID:

Let Hibernate assign the ID at persist time
Book book = new Book();
book.setTitle("My Book");
book.setAuthorId(1L);
// book.getId() is null at this point
repository.save(book);  // Spring Data sees null ID → calls persist() → no SELECT

This works when you create entities in your application code and let JPA manage the ID lifecycle.

Option 2 — Implement Persistable:

For entities with assigned IDs (non-generated), implement Persistable to tell Spring Data explicitly whether the entity is new:

Implementing Persistable
@Entity
public class Book implements Persistable<Long> {
    @Id
    private Long id;

    private String title;

    @Transient
    private boolean isNew = true;

    @Override
    public Long getId() {
        return id;
    }

    @Override
    public boolean isNew() {
        return isNew;
    }

    @PostPersist
    @PostLoad
    void markNotNew() {
        this.isNew = false;
    }
}

Option 3 — Use @Version:

Entities with a null @Version field are treated as new by Spring Data:

@Version for new-entity detection
@Entity
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "book_seq")
    @SequenceGenerator(name = "book_seq", sequenceName = "book_id_seq", allocationSize = 50)
    private Long id;

    @Version
    private Long version;

    private String title;
}

The best approach for most applications: SEQUENCE-generated IDs with no manually assigned ID. This fixes both the batching problem (IDENTITY → SEQUENCE) and the saveAll merge problem (null ID → persist, not merge) simultaneously.

When to Bypass saveAll() Entirely

For bulk inserts where you do not need the persistence context — no subsequent reads of the inserted entities in the same transaction, no entity lifecycle callbacks needed — there are more direct paths available:

JdbcTemplate.batchUpdate() — direct JDBC, no entity overhead, maximum control:

JdbcTemplate \u2014 direct JDBC batching
@Repository
public class BookBatchRepository {
    private final JdbcTemplate jdbc;

    public BookBatchRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public void batchInsert(List<Book> books) {
        jdbc.batchUpdate(
            "INSERT INTO book (title, author_id) VALUES (?, ?)",
            new BatchPreparedStatementSetter() {
                @Override
                public void setValues(PreparedStatement ps, int i) throws SQLException {
                    ps.setString(1, books.get(i).getTitle());
                    ps.setLong(2, books.get(i).getAuthorId());
                }

                @Override
                public int getBatchSize() {
                    return books.size();
                }
            }
        );
    }
}

NamedParameterJdbcTemplate for a cleaner API:

NamedParameterJdbcTemplate \u2014 cleaner API
public void batchInsertNamed(List<Book> books) {
    SqlParameterSource[] params = books.stream()
        .map(book -> new MapSqlParameterSource()
            .addValue("title", book.getTitle())
            .addValue("authorId", book.getAuthorId()))
        .toArray(SqlParameterSource[]::new);

    namedJdbc.batchUpdate(
        "INSERT INTO book (title, author_id) VALUES (:title, :authorId)",
        params
    );
}

PostgreSQL COPY protocol — the fastest possible insert path for large datasets. This bypasses the INSERT pathway entirely and streams data directly into the table. For COPY with Spring Boot, see the COPY performance guide.

The trade-off with JdbcTemplate is worth understanding: you lose entity lifecycle callbacks (@PrePersist, @PostPersist), the first-level cache, dirty checking, and automatic relationship management. For pure bulk data loading, these are rarely needed — and the performance improvement is well worth the simplicity.

The Complete Configuration

Allow me to present all settings in one place:

The complete batch insert configuration
# Entity configuration:
# @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "book_seq")
# @SequenceGenerator(name = "book_seq", sequenceName = "book_id_seq", allocationSize = 50)

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

# JDBC driver rewriting
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb?reWriteBatchedInserts=true

Every line is required. Missing any one leaves performance on the table:

  • Without SEQUENCE: batching is impossible (IDENTITY forces one-at-a-time inserts)
  • Without batch_size: Hibernate sends individual statements even with SEQUENCE
  • Without order_inserts: interleaved entity types break batch grouping
  • Without reWriteBatchedInserts: the JDBC driver sends individual SQL statements even within a batch

Benchmarks — Measuring the Improvement

Setup: 10,000 Book entities inserted in a single transaction. PostgreSQL 16, Spring Boot 3.3, HikariCP default pool, local database (same machine). Timings are averages over 10 runs after warm-up.

ConfigurationTimeQueriesRound Trips
Baseline (IDENTITY, no batching)~8,200ms10,000 INSERTs10,000
SEQUENCE (allocationSize=50)~7,800ms10,000 INSERTs + 200 nextval10,200
+ batch_size=50~1,400ms200 batched INSERTs + 200 nextval400
+ order_inserts=true~1,300ms200 batched INSERTs + 200 nextval400
+ reWriteBatchedInserts=true~220ms200 multi-row INSERTs + 200 nextval400
JdbcTemplate.batchUpdate() + rewrite~150ms200 multi-row INSERTs200

From 8.2 seconds to 220ms — a 37x improvement — from configuration changes alone. No code rewrite. The JPA entities, repositories, and service layer remain unchanged. Four configuration lines and an annotation.

The JdbcTemplate path provides an additional 30% improvement by eliminating entity management overhead. Use it when the persistence context is not needed for the inserted rows.

Network latency amplifies the difference. These numbers are for a local database. With a remote database (1–5ms network round trip), the 10,000-round-trip baseline takes proportionally longer, making the improvement even more pronounced. This is, if you will permit the observation, not a hardware problem or a budget problem. It is a configuration problem — and those are the very best kind, because they are solved by knowledge, not by spending.

Verifying That Batching Is Working

Trust, but verify. Here is how to confirm the configuration is doing what you expect.

Hibernate Statistics

Enable Hibernate's built-in statistics to confirm batching is active:

Enable Hibernate statistics
spring.jpa.properties.hibernate.generate_statistics=true

The log output includes batch information:

Hibernate statistics output
HHH000117: HQL: [insert], time: 220ms, rows: 10000
...
Session Metrics {
    10000 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    15234000 nanoseconds spent preparing 200 JDBC statements;
    185432000 nanoseconds spent executing 200 JDBC statements;
    0 nanoseconds spent executing 0 JDBC batches;
}

If you see statement counts equal to your entity count (e.g., “preparing 10000 JDBC statements”), batching is not active. The first thing to check: your ID generation strategy.

PostgreSQL Logging

For development, enable full statement logging in PostgreSQL:

Enable PostgreSQL statement logging
# postgresql.conf
log_statement = 'all'

With batching and rewriting active, you should see multi-row INSERT statements in the log. Without rewriting, you see individual INSERTs arriving in rapid succession — still batched at the JDBC level, but each sent as a separate SQL statement.

pg_stat_statements

pg_stat_statements provides production-safe verification:

Verify batch behavior with pg_stat_statements
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
WHERE query LIKE 'INSERT INTO book%'
ORDER BY calls DESC;

With batching: the INSERT query should show approximately 200 calls (10,000 rows / batch size 50) instead of 10,000 calls. The mean_exec_time per call will be higher (each call inserts 50 rows), but total_exec_time will be dramatically lower.

For the comprehensive EXPLAIN ANALYZE guide to understanding query plans behind your insert statements, see the EXPLAIN ANALYZE guide. For connection pool behavior during batch operations, see the HikariCP pool sizing guide and the Open-in-View connection holding guide. For comprehensive PostgreSQL tuning, see the performance tuning guide.

Where Gold Lapel Fits

Gold Lapel's proxy sees the insert pattern at the database level — thousands of identical INSERT statements arriving in rapid succession. The proxy optimizes connection handling for batch workloads, but the configuration fixes in this guide address the root cause, and that is where your attention should go first.

The best performance comes from fixing the application layer — SEQUENCE generation, batch_size, insert ordering, and JDBC rewriting — and letting Gold Lapel attend to the remaining query optimization across your broader workload. You should leave this guide equipped to make the changes that matter most, whether or not you use Gold Lapel.

Frequently asked questions