Comparison of 6 batch insert strategies using Spring Data JPA and PostgreSQL.
| Strategy | Description |
|---|---|
| JDBC Batch | JdbcTemplate.batchUpdate() with native SQL |
| Hibernate Batch | saveAllAndFlush() with hibernate.jdbc.batch_size and reWriteBatchedInserts |
| UNNEST | INSERT ... SELECT FROM UNNEST(...): single SQL statement using PostgreSQL arrays |
| COPY | COPY ... FROM STDIN: PostgreSQL binary protocol via CopyManager |
| Multi-row VALUES | INSERT INTO ... VALUES (...), (...), ...: single statement with all rows as parameter placeholders |
| JSON | INSERT ... SELECT FROM jsonb_to_recordset(...): single JSON parameter, no bind-param limit |
spring-data-jpa-batch-dml/
├── batch-core/ # Models, repositories, services, utilities (JAR)
├── web/ # Spring Boot app, controllers, records (bootJar)
├── benchmark/ # JMH benchmarks
└── docker-compose.yml # PostgreSQL 17
- batch-core: shared business logic (JPA entities, repositories, 6
@Serviceclasses for batch strategies) - web: Spring Boot application with REST API (
/customers,/stores), Springdoc - benchmark: JMH benchmarks comparing the 6 batch insert strategies at 100, 1,000, and 10,000 rows
- Java 25+
- Docker (for PostgreSQL)
# Start PostgreSQL
docker compose up -d
# Build
./gradlew clean build -x test
# Run the application
./gradlew :web:bootRunThe application starts on port 8081.
# Batch insert (modes: jdbc, hibernate, unnest, copy, multirow, json)
curl -X POST "http://localhost:8081/customers/save-all?number=1000&mode=json"
# Get all customers
curl http://localhost:8081/customers/get-all
# Get all with store and orders (eager fetch)
curl http://localhost:8081/customers/get-all-with-store-orders
# Delete all
curl -X DELETE http://localhost:8081/customers/delete-all# Unit tests (H2)
./gradlew test
# Integration tests (requires PostgreSQL)
./gradlew :batch-core:integrationTest# Run benchmarks (requires PostgreSQL)
./gradlew :benchmark:jmhResults are written to benchmark/build/reports/jmh/results.json.
Environment: GraalVM 25.0.3 (JDK 25), PostgreSQL 17 (Docker),
batch_size=100,reWriteBatchedInserts=trueJMH config: fork=1, warmup=1×3s, measurement=3×5s, mode=AverageTime
| Strategy | 100 rows (ms) | 1,000 rows (ms) | 10,000 rows (ms) |
|---|---|---|---|
| JDBC Batch | 1.609 ± 0.525 | 5.874 ± 0.321 | 45.163 ± 10.520 |
| COPY | 2.568 ± 1.044 | 6.989 ± 1.286 | 39.170 ± 14.839 |
| UNNEST | 1.538 ± 1.129 | 5.277 ± 0.702 | 41.715 ± 3.730 |
| Multi-row VALUES | 1.568 ± 1.161 | 5.725 ± 0.661 | 46.895 ± 9.378 |
| JSON | 1.702 ± 1.251 | 6.005 ± 3.417 | 48.295 ± 4.480 |
| Hibernate Batch | 3.061 ± 2.220 | 24.160 ± 81.483 | 211.531 ± 225.190 |
Note: High error margins at 10,000 rows are typical for JMH with Spring application context startup included (each fork starts a fresh context). Focus on the score column for relative comparisons. The multi-row VALUES and JSON batch sizes are capped at 4,000 rows per statement: multi-row to stay below PostgreSQL's 32,767 bind parameter limit, JSON for practical JSON array size management.
JDBC Batch, UNNEST, and multi-row VALUES are the fastest for small batches (100 rows). JSON is competitive across all scales: performance is within 5-15% of the leading strategies.
At 10,000 rows, COPY and UNNEST lead. JSON and multi-row VALUES are mid-pack with nearly identical throughput.
Hibernate Batch is slower due to persistence context overhead (dirty checking, cascading).
- Spring Boot 4.1.0
- Spring Data JPA / Hibernate 7.4
- PostgreSQL 17
- Lombok
- JMH 1.37
- Gradle 9.4.1 (multi-module)