@RestController
@RequestMapping("/api/customers")
public class CsvImportController {
private final CsvImportService importService;
public CsvImportController(CsvImportService importService) {
this.importService = importService;
}
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ImportResult> importCsv(@RequestParam("file") MultipartFile file) throws IOException {
if (file.isEmpty()) {
return ResponseEntity.badRequest()
.body(new ImportResult(0, 0, "Uploaded file is empty"));
}
if (!"text/csv".equals(file.getContentType())) {
return ResponseEntity.unprocessableEntity()
.body(new ImportResult(0, 0, "Expected a text/csv file"));
}
try (InputStream in = file.getInputStream()) {
ImportResult result = importService.importCustomers(in);
return ResponseEntity.ok(result);
}
}
}
@Service
public class CsvImportService {
private static final int BATCH_SIZE = 500;
private final CustomerBatchRepository repository;
public CsvImportService(CustomerBatchRepository repository) {
this.repository = repository;
}
@Transactional
public ImportResult importCustomers(InputStream in) throws IOException {
int inserted = 0;
int skipped = 0;
List<Customer> buffer = new ArrayList<>(BATCH_SIZE);
try (Reader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
CSVFormat format = CSVFormat.DEFAULT.builder()
.setHeader("email", "first_name", "last_name", "country")
.setSkipHeaderRecord(true)
.setTrim(true)
.build();
for (CSVRecord record : format.parse(reader)) {
Customer customer = mapRecord(record);
if (!customer.isValid()) {
skipped++;
continue;
}
buffer.add(customer);
if (buffer.size() >= BATCH_SIZE) {
repository.saveBatch(buffer);
inserted += buffer.size();
buffer.clear();
}
}
if (!buffer.isEmpty()) {
repository.saveBatch(buffer);
inserted += buffer.size();
}
}
return new ImportResult(inserted, skipped, "Import completed");
}
private Customer mapRecord(CSVRecord record) {
return new Customer(
record.get("email"),
record.get("first_name"),
record.get("last_name"),
record.get("country"));
}
}
@Repository
public class CustomerBatchRepository {
private static final String INSERT_SQL =
"INSERT INTO customers (email, first_name, last_name, country) " +
"VALUES (?, ?, ?, ?) ON CONFLICT (email) DO NOTHING";
private final JdbcTemplate jdbcTemplate;
public CustomerBatchRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void saveBatch(final List<Customer> customers) {
jdbcTemplate.batchUpdate(INSERT_SQL, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
Customer c = customers.get(i);
ps.setString(1, c.getEmail());
ps.setString(2, c.getFirstName());
ps.setString(3, c.getLastName());
ps.setString(4, c.getCountry());
}
@Override
public int getBatchSize() {
return customers.size();
}
});
}
}
public class Customer {
private final String email;
private final String firstName;
private final String lastName;
private final String country;
public Customer(String email, String firstName, String lastName, String country) {
this.email = email;
this.firstName = firstName;
this.lastName = lastName;
this.country = country;
}
public boolean isValid() {
return email != null && email.contains("@") && !email.isBlank();
}
public String getEmail() {
return email;
}
public String getFirstName() {
return firstName;
}
public String getLastName() {
return lastName;
}
public String getCountry() {
return country;
}
}
This snippet shows how a CSV upload is handled end to end in Spring Boot: an HTTP endpoint accepts the file, a service streams it row by row, and rows are flushed to the database in fixed-size batches rather than one INSERT per line. The batching is the whole point — with a large file, a naive per-row insert incurs one network round trip per record, so import time grows linearly with row count. Batching amortizes that cost by sending hundreds of rows per statement.
In CsvImportController, the endpoint is declared consumes = MULTIPART_FORM_DATA_VALUE and binds the file to a MultipartFile parameter named file. It rejects empty uploads early with a 400, then obtains the raw InputStream and hands it to the service. Nothing is buffered fully into memory here — the controller stays thin and delegates parsing and persistence.
CsvImportService opens a BufferedReader over the stream and uses apache-commons-csv (CSVFormat.DEFAULT with headers) to iterate records lazily. Each parsed line becomes a Customer and is appended to a List buffer. When the buffer reaches BATCH_SIZE, flush is invoked and the list is cleared; a final flush after the loop drains the remainder. The whole method is annotated @Transactional, so either the entire file imports or nothing does — a partial import from a malformed row halfway through is avoided by rolling everything back. The ImportResult returns counts of inserted and skipped rows so the caller gets feedback.
The actual database work lives in CustomerBatchRepository, which wraps a JdbcTemplate.batchUpdate call with a BatchPreparedStatementSetter. That interface exposes getBatchSize and setValues(ps, i), letting the JDBC driver group the parameter sets into one round trip. Using plain JdbcTemplate here, rather than JPA, sidesteps the persistence-context bloat and dirty-checking overhead that make ORMs slow for bulk loads.
A few pitfalls are worth noting: rewriteBatchedStatements=true must be set on the JDBC URL for MySQL to truly collapse the inserts, tuning BATCH_SIZE trades memory against round trips, and per-row validation (the isValid check) lets bad lines be skipped and counted instead of aborting the run. This pattern is the standard reach for importing tens of thousands of rows without exhausting heap or connection time.
Related snips
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
Share this code
Here's the card — post it anywhere.