Published on
· Updated

Spring Boot JPA Batch Inserts with PostgreSQL: Hibernate, JDBC, and COPY

Authors

Calling saveAll does not by itself turn thousands of Spring Data JPA entities into one PostgreSQL bulk insert. Hibernate still has to decide when to generate SQL, the JDBC driver has to batch or rewrite statements, and the persistence context retains every managed entity until it is cleared.

For high-volume persistence, optimize the entire path:

application chunk
  -> Hibernate action queue
  -> JDBC batch
  -> pgJDBC protocol
  -> PostgreSQL constraints, indexes, and WAL

This guide shows three implementation levels:

  1. Hibernate/JPA batching when entity semantics matter;
  2. JdbcTemplate.batchUpdate when SQL control matters more;
  3. PostgreSQL COPY for dedicated ingestion pipelines.

Version note

The examples target Spring Boot 4.1, Hibernate ORM 7.1, and PostgreSQL with the current pgJDBC driver as of August 10, 2026. Benchmark with the exact versions and schema used in production.

TL;DR

  • Set hibernate.jdbc.batch_size; Hibernate batching is disabled by default.
  • Prefer sequence-based identifiers for insert batching. Hibernate disables JDBC insert batching for identity-generated IDs.
  • Call flush() and clear() at chunk boundaries to bound first-level-cache memory.
  • Enable pgJDBC reWriteBatchedInserts=true only after measuring it with your SQL and driver version.
  • Use JdbcTemplate for straightforward batch SQL and COPY for large file or stream ingestion.
  • Decide whether a chunk is only a memory boundary or also a commit boundary. flush() is not a commit.

Start with a method decision

MethodKeeps JPA lifecycle and cascadesSQL controlMemory profileBest fit
saveAll with Hibernate batchingYesLowMust flush and clearNormal entity imports
EntityManager.persist loopYesMediumExplicit chunk controlTuned entity inserts
Hibernate StatelessSessionNo first-level cache; reduced ORM semanticsMediumLowSimple high-volume row operations
JdbcTemplate.batchUpdateNoHighLowFlat inserts, updates, and upserts
pgJDBC CopyManagerNoVery highStreamingVery large PostgreSQL ingestion

Choose based on required semantics before chasing throughput. Reimplementing cascades, validation, auditing, and identifier behavior outside JPA can cost more than the saved milliseconds.

Why saveAll can still issue many inserts

Spring Data JPA determines whether each entity is new and delegates to JPA persist or merge. saveAll gives the operation one repository-level transaction, but it is not a database-specific bulk statement.

Actual batching depends on several conditions:

  • JDBC batching is enabled in Hibernate;
  • adjacent SQL statements have the same shape;
  • identifier generation does not require every row to be inserted immediately;
  • inserts and updates are ordered well enough to form batches;
  • the driver sends or rewrites the batch efficiently;
  • the application does not flush prematurely.

Turn on SQL and Hibernate statistics in a non-production test environment and inspect the number of prepared statements and executed JDBC batches. A faster wall-clock time alone does not prove that batching is working.

Configure Hibernate and pgJDBC

Start with a modest batch size and measure. Hibernate's own guide uses the range 10 to 50 as a practical starting point.

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/app?reWriteBatchedInserts=true
    username: app
    password: ${DB_PASSWORD}
  jpa:
    open-in-view: false
    properties:
      hibernate:
        jdbc:
          batch_size: 50
        order_inserts: true
        order_updates: true

What each setting does:

  • hibernate.jdbc.batch_size limits statements in a Hibernate JDBC batch. Zero, the default, disables the feature.
  • hibernate.order_inserts groups compatible inserts by entity type and identifier.
  • hibernate.order_updates groups compatible updates and can reduce some deadlock patterns, but sorting has a cost.
  • reWriteBatchedInserts=true lets pgJDBC rewrite compatible batched inserts into multi-value inserts. The driver documents this option as disabled by default.

Do not copy a batch size from a benchmark. Wider rows, indexes, triggers, generated columns, network latency, and transaction duration all change the result. Compare at least 20, 50, 100, and an unoptimized baseline.

Use a batch-friendly identifier

This mapping prevents effective Hibernate insert batching:

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

Hibernate must execute each identity insert to retrieve its generated key, and its user guide explicitly states that JDBC insert batching is disabled for identity identifier generation.

PostgreSQL sequences allow Hibernate to allocate identifiers before the insert:

@Entity
@Table(name = "measurement")
public class Measurement {

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

    @Column(nullable = false)
    private String deviceId;

    @Column(nullable = false)
    private Instant recordedAt;

    @Column(nullable = false)
    private BigDecimal value;

    protected Measurement() {}

    public Measurement(String deviceId, Instant recordedAt, BigDecimal value) {
        this.deviceId = deviceId;
        this.recordedAt = recordedAt;
        this.value = value;
    }
}

allocationSize=50 reduces sequence round trips by reserving identifier ranges. Gaps are normal when a process stops or a transaction rolls back. If identifiers must be gapless, a database sequence is the wrong business mechanism.

Keep the ORM batch size and sequence allocation size in the same general range initially, but treat them as independent settings and benchmark both.

Bound the persistence context

A transaction that persists 500,000 entities can retain all of them in Hibernate's first-level cache. JDBC batching reduces network round trips; it does not automatically release those Java objects.

Flush and clear on each memory chunk:

@Service
public class JpaMeasurementImporter {

    private static final int CHUNK_SIZE = 50;

    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    public void importAll(Stream<MeasurementRow> rows) {
        AtomicInteger count = new AtomicInteger();

        rows.forEach(row -> {
            entityManager.persist(new Measurement(
                row.deviceId(),
                row.recordedAt(),
                row.value()
            ));

            if (count.incrementAndGet() % CHUNK_SIZE == 0) {
                entityManager.flush();
                entityManager.clear();
            }
        });

        entityManager.flush();
        entityManager.clear();
    }
}

flush() sends pending SQL so Hibernate can execute the batch. clear() detaches managed entities and releases the persistence context's references. After clear, do not expect changes to an older entity instance to be dirty-checked.

The final flush handles a partial chunk. Avoid parallel() here: one JPA EntityManager is not safe for concurrent use, and uncontrolled parallel transactions can exhaust the connection pool.

Separate memory chunks from commit chunks

The method above uses one transaction. Each flush is a memory and SQL-execution boundary, but a later failure can still roll back every earlier chunk.

That is correct when the import must be atomic. It is risky when:

  • the transaction lasts minutes;
  • locks or old row versions accumulate;
  • a retry would repeat a huge amount of work;
  • one connection is held too long;
  • partial progress is acceptable and resumable.

For independent commits, call a transactional worker bean once per chunk:

@Service
public class MeasurementChunkWriter {

    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    public void write(List<MeasurementRow> chunk) {
        for (MeasurementRow row : chunk) {
            entityManager.persist(new Measurement(
                row.deviceId(),
                row.recordedAt(),
                row.value()
            ));
        }
        entityManager.flush();
        entityManager.clear();
    }
}
@Service
public class MeasurementImportCoordinator {

    private final MeasurementChunkWriter writer;

    public MeasurementImportCoordinator(MeasurementChunkWriter writer) {
        this.writer = writer;
    }

    public void importChunks(Iterable<List<MeasurementRow>> chunks) {
        for (List<MeasurementRow> chunk : chunks) {
            writer.write(chunk);
        }
    }
}

Keeping the transaction on another bean matters because Spring's default proxy-based transaction interception does not apply to a direct self-invocation.

Once chunks commit independently, design restart semantics. A unique business key such as (device_id, recorded_at) and an import checkpoint can make a repeated chunk safe.

Use JdbcTemplate.batchUpdate for direct SQL

JPA is unnecessary when the task is a flat insert and no entity callback, cascade, or persistence-context behavior is required.

@Repository
public class JdbcMeasurementWriter {

    private static final String INSERT = """
        insert into measurement (device_id, recorded_at, value)
        values (?, ?, ?)
        on conflict (device_id, recorded_at) do update
        set value = excluded.value
        """;

    private final JdbcTemplate jdbc;

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

    @Transactional
    public int[][] write(List<MeasurementRow> rows) {
        return jdbc.batchUpdate(
            INSERT,
            rows,
            500,
            (statement, row) -> {
                statement.setString(1, row.deviceId());
                statement.setObject(2, row.recordedAt());
                statement.setBigDecimal(3, row.value());
            }
        );
    }
}

PostgreSQL guarantees an atomic insert-or-update outcome for ON CONFLICT DO UPDATE when the statement otherwise succeeds. A matching unique constraint or unique index is required:

alter table measurement
    add constraint uq_measurement_device_time
    unique (device_id, recorded_at);

Upsert changes semantics: a retried row overwrites the existing value. If a repeated business key with different data indicates corruption, use DO NOTHING plus a reconciliation query or fail the import instead.

Direct JDBC also makes it easier to separate one batch per transaction and to capture batch update counts. It bypasses JPA callbacks and application-level entity validation, so keep critical constraints in PostgreSQL.

Use PostgreSQL COPY for ingestion

For very large CSV or generated streams, PostgreSQL COPY FROM STDIN avoids issuing an insert statement for every row. pgJDBC exposes the protocol through CopyManager.

@Repository
public class CopyMeasurementWriter {

    private final DataSource dataSource;

    public CopyMeasurementWriter(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public long copy(Reader csv) throws SQLException, IOException {
        try (Connection connection = dataSource.getConnection()) {
            PGConnection postgres = connection.unwrap(PGConnection.class);
            CopyManager copy = postgres.getCopyAPI();

            return copy.copyIn("""
                copy measurement_stage (device_id, recorded_at, value)
                from stdin with (format csv)
                """, csv);
        }
    }
}

For untrusted or retryable imports, load into a staging table first:

  1. COPY into a table with an import_id.
  2. Validate row counts, required fields, ranges, and duplicates.
  3. Merge valid rows into the target with INSERT ... ON CONFLICT.
  4. Record the import result and delete or archive staging rows.

COPY is PostgreSQL-specific and bypasses Hibernate's entity model. It is the right tradeoff for an ingestion boundary, not a universal replacement for normal application writes.

Do not interpolate table or column names from user input. COPY identifiers are part of the SQL string and cannot be bound as normal parameters.

Updates need their own strategy

Loading a collection of entities, mutating them, and calling saveAll can work with JDBC update batching, but it also reads and manages every row. For a set-based change, one SQL statement is usually clearer:

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
    update Measurement m
       set m.archived = true
     where m.recordedAt < :cutoff
       and m.archived = false
    """)
int archiveBefore(Instant cutoff);

Bulk JPQL updates bypass normal entity state synchronization. Clear the persistence context or ensure no stale instances are used afterward. For different values per row, use JDBC batching, a staging table plus UPDATE ... FROM, or another set-based PostgreSQL operation.

Measure the database, not only the Java method

Record a baseline and compare each change under production-like conditions:

  • rows per second and end-to-end duration;
  • p50, p95, and p99 chunk latency;
  • number of SQL statements and JDBC batches;
  • connection-pool acquisition time and active connections;
  • heap usage and garbage collection;
  • PostgreSQL CPU, WAL bytes, lock waits, and checkpoint activity;
  • index and trigger cost;
  • rejected, invalid, and duplicate rows.

Warm-up runs and local databases can hide network round trips. Test with realistic latency, indexes, constraints, triggers, row width, and competing traffic.

A larger batch can improve throughput while making lock time, memory, replication lag, or recovery worse. Select the smallest batch that reaches the desired throughput without damaging service-level objectives.

Common reasons batching “does not work”

Identity-generated IDs

Hibernate inserts each row to obtain the identifier. Use a sequence when the data model permits it.

Mixed entity types

Interleaved SQL shapes break batches. hibernate.order_inserts and hibernate.order_updates can help, but measure the sorting cost.

No explicit batch size

Hibernate's hibernate.jdbc.batch_size default is zero. saveAll does not override it.

An ever-growing persistence context

The application batches SQL but retains hundreds of thousands of managed entities. Flush and clear.

Premature queries or flushes

A query executed in the middle of the loop can trigger an automatic flush and split a batch. Keep read queries out of the write loop where possible.

Too much parallelism

Parallel writers compete for connections, indexes, locks, CPU, and WAL bandwidth. Start with one measured pipeline, then increase concurrency gradually.

Treating flush as a checkpoint

A flush does not commit. If restartable progress matters, define explicit transaction and checkpoint boundaries.

Production checklist

  • Is JPA behavior actually required for this path?
  • Is Hibernate JDBC batching enabled and verified from statistics or SQL traces?
  • Does identifier generation allow batching?
  • Are inserts and updates ordered only when measurement shows a benefit?
  • Is the persistence context cleared at a bounded interval?
  • Are transaction size and chunk size chosen independently?
  • Can a committed chunk be retried without corrupting data?
  • Are unique constraints and validation enforced in PostgreSQL?
  • Would JDBC or COPY make the operation simpler and more observable?
  • Have WAL, replicas, indexes, triggers, and concurrent traffic been included in the load test?

For ordinary entity imports, sequence IDs plus Hibernate batching and regular flush/clear calls are a strong default. Move to direct JDBC or COPY when measurements show the ORM layer is the limiting cost and the simpler persistence semantics are acceptable.

Official references