- Published on
- · Updated
Spring Boot JPA Batch Inserts with PostgreSQL: Hibernate, JDBC, and COPY
- Authors

- Name
- Maria
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:
- Hibernate/JPA batching when entity semantics matter;
JdbcTemplate.batchUpdatewhen SQL control matters more;- PostgreSQL
COPYfor 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()andclear()at chunk boundaries to bound first-level-cache memory.- Enable pgJDBC
reWriteBatchedInserts=trueonly after measuring it with your SQL and driver version.- Use
JdbcTemplatefor straightforward batch SQL andCOPYfor 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
| Method | Keeps JPA lifecycle and cascades | SQL control | Memory profile | Best fit |
|---|---|---|---|---|
saveAll with Hibernate batching | Yes | Low | Must flush and clear | Normal entity imports |
EntityManager.persist loop | Yes | Medium | Explicit chunk control | Tuned entity inserts |
Hibernate StatelessSession | No first-level cache; reduced ORM semantics | Medium | Low | Simple high-volume row operations |
JdbcTemplate.batchUpdate | No | High | Low | Flat inserts, updates, and upserts |
pgJDBC CopyManager | No | Very high | Streaming | Very 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_sizelimits statements in a Hibernate JDBC batch. Zero, the default, disables the feature.hibernate.order_insertsgroups compatible inserts by entity type and identifier.hibernate.order_updatesgroups compatible updates and can reduce some deadlock patterns, but sorting has a cost.reWriteBatchedInserts=truelets 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:
COPYinto a table with animport_id.- Validate row counts, required fields, ranges, and duplicates.
- Merge valid rows into the target with
INSERT ... ON CONFLICT. - 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
COPYmake 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.