Spring Boot: Import Excel File into Database (Apache POI)

Upload an .xlsx class roster to a Spring Boot endpoint, read it with Apache POI, validate every row with Bean Validation and save the valid rows in JDBC batches. Includes a JSON error report, duplicate checks, a SAX reader for large files with measured heap, upload limits and MockMvc tests.

To import an Excel file into a database in Spring Boot, we accept the .xlsx file in a multipart POST request, read its rows with Apache POI, check every row with Bean Validation, and save the valid rows with Spring Data JPA. A school office that uploads a class roster with exam marks is a typical case: most rows are fine, a few have a wrong email or a mark of 105, and the office needs to know exactly which cells to fix. Below we build that endpoint with cell reading through DataFormatter, header mapping by name, a JSON report of row errors, duplicate checks, batch inserts, a streaming reader for large files and upload size limits, and we test it with MockMvc.

DataFormatter formatter = new DataFormatter();
String roll = formatter.formatCellValue(row.getCell(0));     // "101" (numeric cell)
String date = formatter.formatCellValue(row.getCell(5));     // "6/15/24" (date cell)

var violations = validator.validate(studentRow);             // email "chloe.school.com" -> "must be a well-formed email address"
repository.saveAll(students);                                // inserts in JDBC batches of 50
curl -F "file=@samples/students.xlsx" http://localhost:8080/students/import
# {"totalRows":8,"imported":4,"failed":4,"errors":[{"row":5,"column":"Email","message":"must be a well-formed email address"}, ...]}

1. How Does an Excel Import Work in Spring Boot?

An .xlsx file is a zip archive of XML files (the Office Open XML format): one XML file per sheet, a shared table for repeated strings, and a styles file that says how numbers and dates are displayed. Apache POI is the Java library that reads and writes these files. An import endpoint passes the file through four steps. The first two can reject the whole file, the last two reject single rows:

Flow diagram of POST /students/import: 1. Upload (MultipartFile, max-file-size 5MB, .xlsx name check), 2. Parse (XSSFWorkbook or SAX, DataFormatter text, skip blank rows, map headers by name), 3. Validate (text to Integer and date, Bean Validation, duplicate in file), 4. Save in chunks (500 rows per chunk, roll number in database check, saveAll with flush and clear, batches of 50 inserts); steps 1 and 2 can reject the whole file with 413 or 400, steps 3 and 4 reject single rows as RowError and the import continues; everything ends in a 200 OK ImportReport JSON
A problem with the file stops the import with a 4xx status. A problem in one row becomes an entry in the report, and the other rows are still saved.

POI offers two ways to read a sheet. The usual one, XSSFWorkbook, builds an object for every row and cell in memory. The XSSF event API parses the sheet XML with SAX (a parser that reports one XML element at a time) and keeps only the current row:

XSSFWorkbook (user model)XSSF event API (SAX)
MemoryWhole workbook as objectsCurrent row plus the shared strings
CodeLoop over Sheet, Row, CellCallback per cell (SheetContentsHandler)
Random accessYes, sheet.getRow(500)No, rows arrive in file order
FormulasCan evaluate themReads the cached result
Good forSheets with a few thousand rowsLarge files, small heap

We start with XSSFWorkbook because its code is easier to follow, and switch to the event API in section 2.9. Both readers produce the same text for each cell, so validation and saving stay the same.

2. Importing an Excel File Into a Database With Spring Boot

The complete project uses Spring Boot 4.1.1, Java 25, Apache POI 5.5.1, Hibernate 7.4.5 and an H2 in-memory database. Its 12 tests check every response, log line and database row shown below.

2.1. Maven Dependencies

Spring Boot does not manage the POI version, so we set it ourselves. poi-ooxml is the module for .xlsx files and pulls in the core poi module:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
  <groupId>org.apache.poi</groupId>
  <artifactId>poi-ooxml</artifactId>
  <version>5.5.1</version>
</dependency>
<dependency>
  <groupId>com.h2database</groupId>
  <artifactId>h2</artifactId>
  <scope>runtime</scope>
</dependency>

In Spring Boot 4, the web starter is called spring-boot-starter-webmvc; the old spring-boot-starter-web name still works but is deprecated.

2.2. The Student Entity and the Sheet Layout

The class teacher fills one row per student: roll number, name, class, email, marks and admission date. The import does not care about the column order, because it finds each column by its header text:

Mapping diagram: sheet column A Roll No with the number 101 becomes the text "101" and the String field rollNumber checked with NotBlank and digits; B Name becomes name; C Class becomes className; D Email becomes email checked with Email; E Marks 85 becomes the text "85" and the Integer field marks checked with Min 0 and Max 100; F Admission Date shown as 6/15/24 becomes "2024-06-15" and the LocalDate field admissionDate checked with PastOrPresent
Every cell is read as text first. The text is then converted to the field type and checked, and each failure is reported with the column header the teacher sees.

The entity is a plain JPA class. rollNumber has a unique constraint, and the id comes from a sequence, which matters for batch inserts in section 2.8:

@Entity
public class Student {

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

  @Column(unique = true, nullable = false)
  private String rollNumber;

  private String name;
  private String email;
  private String className;
  private Integer marks;
  private LocalDate admissionDate;
}

We do not validate the entity directly. A StudentRow record holds one converted row together with its Excel row number, and carries the Bean Validation constraints:

public record StudentRow(
    int rowNumber,
    @NotBlank @Pattern(regexp = "\\d{1,6}", message = "must be a number with up to 6 digits") String rollNumber,
    @NotBlank @Size(max = 100) String name,
    @NotBlank @Email String email,
    @NotBlank String className,
    @NotNull @Min(0) @Max(100) Integer marks,
    @NotNull @PastOrPresent LocalDate admissionDate) {
}

An enum connects each header to a field name. The validator reports errors by field name (email), and the enum turns that back into the header (Email) for the report:

public enum StudentColumn {
  ROLL_NUMBER("Roll No", "rollNumber"),
  NAME("Name", "name"),
  EMAIL("Email", "email"),
  CLASS_NAME("Class", "className"),
  MARKS("Marks", "marks"),
  ADMISSION_DATE("Admission Date", "admissionDate");
  // constructor, header(), field() and headerOf(field) omitted
}

2.3. Uploading the File With MultipartFile

A MultipartFile is Spring’s view of one file in a multipart/form-data request. We check the file name, copy the upload to a temporary file and pass that file to the service:

@PostMapping(value = "/students/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ImportReport importStudents(@RequestParam("file") MultipartFile file,
    @RequestParam(defaultValue = "false") boolean streaming,
    @RequestParam(defaultValue = "false") boolean allOrNothing) {

  String fileName = file.getOriginalFilename();
  if (file.isEmpty() || fileName == null || !fileName.toLowerCase().endsWith(".xlsx")) {
    throw new InvalidFileException("Upload a non-empty .xlsx file");
  }
  SheetReader reader = streaming ? streamingReader : workbookReader;
  Path upload = null;
  try {
    upload = Files.createTempFile("students-", ".xlsx");
    file.transferTo(upload);                     // POI reads a file part by part
    return importService.importStudents(upload, reader, allOrNothing);
  } catch (IOException | UnsupportedFileFormatException e) {
    throw new InvalidFileException("Not a readable .xlsx file");
  } finally {
    deleteQuietly(upload);
  }
}

We give POI a file, not file.getInputStream(). The POI quick guide says that an InputStream has to buffer the whole file, and section 2.9 shows how much heap that costs. A file with a .xlsx name that is not a zip archive makes POI throw NotOfficeXmlFileException, a subclass of UnsupportedFileFormatException, and the controller turns it into a 400 response.

An @RestControllerAdvice maps our exception to a ProblemDetail, the standard JSON error body from RFC 9457:

@ExceptionHandler(InvalidFileException.class)
ProblemDetail invalidFile(InvalidFileException e) {
  return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage());
}

2.4. Reading Cells With DataFormatter

Every cell in Excel has a type: string, numeric, boolean, formula or blank. Dates are numeric cells with a date format. Reading a cell with the wrong getter throws an exception, and that is the most common bug in Excel imports. The roll number 101 is a numeric cell:

roll.getStringCellValue();                 // IllegalStateException: Cannot get a STRING value from a NUMERIC cell
roll.getNumericCellValue();                // 101.0

DataFormatter formatter = new DataFormatter();
formatter.formatCellValue(roll);           // "101"
formatter.formatCellValue(marks);          // "85.5"
formatter.formatCellValue(date);           // "6/15/24" (built-in format m/d/yy)
formatter.formatCellValue(formula);        // "B1*2" (the formula, not the result)
formatter.formatCellValue(formula, evaluator);   // "171"
formatter.formatCellValue(row.getCell(9)); // "" (missing cell)

DataFormatter returns the text Excel shows for a cell, whatever its type, so we read every cell as a String and convert it ourselves. Missing cells come back as an empty string instead of null.

Dates need one more step. The displayed text depends on the format the teacher picked (“6/15/24”, “15-Jun-2024” and so on), so we extend DataFormatter to return ISO dates such as “2024-06-15”. The first method is used by XSSFWorkbook, the second by the event API in section 2.9:

public class IsoDateFormatter extends DataFormatter {

  @Override
  public String formatCellValue(Cell cell) {
    if (cell != null && cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
      return cell.getLocalDateTimeCellValue().toLocalDate().toString();   // "2024-06-15"
    }
    return super.formatCellValue(cell).trim();
  }

  @Override
  public String formatRawCellContents(double value, int formatIndex, String formatString,
      boolean use1904Windowing) {
    if (DateUtil.isADateFormat(formatIndex, formatString) && DateUtil.isValidExcelDate(value)) {
      return DateUtil.getLocalDateTime(value, use1904Windowing).toLocalDate().toString();
    }
    return super.formatRawCellContents(value, formatIndex, formatString, use1904Windowing);
  }
}

A date typed as text, for example “2024-06-15”, stays as it is and parses the same way. A text date in another format, such as “15/06/2024”, fails the parsing and is reported as a row error.

2.5. Reading Rows and Mapping Headers by Name

sheet.getRow(r) returns null for a row that was never written, and a row whose cells were cleared still exists but holds only empty strings. We skip both, and number the rows the way Excel does, starting at 1:

XSSFWorkbook workbook = new XSSFWorkbook(pkg);       // pkg = OPCPackage.open(file, PackageAccess.READ)
Sheet sheet = workbook.getSheetAt(0);
for (int r = sheet.getFirstRowNum(); r <= sheet.getLastRowNum(); r++) {
  Row row = sheet.getRow(r);
  if (row == null) {
    continue;                                        // row never written
  }
  List<String> values = new ArrayList<>();
  for (int c = 0; c < row.getLastCellNum(); c++) {
    values.add(formatter.formatCellValue(row.getCell(c)));   // "" for a missing cell
  }
  if (!SheetReader.isBlank(values)) {
    rows.accept(new SheetRow(r + 1, values));        // Excel row number
  }
}

The first non-blank row is the header. We store the position of each header, ignoring case and extra spaces, and reject the file when a required column is missing:

Map<String, Integer> positions = new HashMap<>();
for (int i = 0; i < row.values().size(); i++) {
  positions.put(row.value(i).trim().toLowerCase(), i);       // "roll no" -> 0, "class" -> 2
}
for (StudentColumn column : StudentColumn.values()) {
  Integer index = positions.get(column.header().toLowerCase());
  if (index == null) {
    missing.add(column.header());
  } else {
    columns.put(column, index);
  }
}
if (!missing.isEmpty()) {
  throw new InvalidFileException("Missing columns: " + String.join(", ", missing));
}

A sheet without the Email column gets this response:

{"detail":"Missing columns: Email","instance":"/students/import","status":400,"title":"Bad Request"}

Mapping by header name keeps the import working when someone inserts or moves a column, which happens often with sheets that people edit by hand. Mapping by index would silently store emails in the class field.

2.6. Validating Each Row and Reporting Errors

Each row goes through two checks. First, the text of Marks and Admission Date is converted to Integer and LocalDate; a failure is recorded with the column header. Then the validator checks the constraints on StudentRow, and we skip a violation for a column that already failed conversion, so the teacher sees one message per cell:

try {
  marks = Integer.valueOf(marksText);                  // "absent" -> NumberFormatException
} catch (NumberFormatException e) {
  badColumns.add(addError(row, StudentColumn.MARKS, "must be a whole number"));
}

for (ConstraintViolation<StudentRow> violation : validator.validate(student)) {
  String column = StudentColumn.headerOf(violation.getPropertyPath().toString());   // "email" -> "Email"
  if (!badColumns.contains(column)) {
    errors.add(new RowError(row.rowNumber(), column, violation.getMessage()));
  }
}

The report is a record with the counts and the list of RowError(row, column, message) entries, sorted by row. Spring MVC writes it as JSON. Our sample roster has 8 student rows and one blank row:

RowRoll NoNameClassEmailMarksAdmission Date
2101Anna7Aanna@school.com856/15/24
3102Ben7Aben@school.com926/15/24
4
5103Chloe7Achloe.school.com786/17/24
6104David7Adavid@school.com10515/06/2024 (text)
7102Emma7Aemma@school.com886/18/24
81057Afarah@school.comabsent6/18/24
9106Grace7Bgrace@school.com956/14/24
10107Hugo7Bhugo@school.com676/20/24

Uploading it to the running application saves four students and reports the other four rows:

curl -F "file=@samples/students.xlsx" http://localhost:8080/students/import
{"totalRows":8,"imported":4,"failed":4,"errors":[
  {"row":5,"column":"Email","message":"must be a well-formed email address"},
  {"row":6,"column":"Marks","message":"must be less than or equal to 100"},
  {"row":6,"column":"Admission Date","message":"must be a date such as 2024-06-15"},
  {"row":7,"column":"Roll No","message":"duplicate of row 3"},
  {"row":8,"column":"Name","message":"must not be blank"},
  {"row":8,"column":"Marks","message":"must be a whole number"}]}

The blank row 4 does not count in totalRows. Row 6 has two errors, and failed counts rows, not errors. GET /students returns the four saved students, for example Anna with “marks”:85 and “admissionDate”:”2024-06-15″.

2.7. Handling Duplicate Roll Numbers

A roll number can appear twice in the same file, or it can already be in the database from an earlier upload. We check both before the insert. Inside the file, a map from roll number to its first row finds the repeat:

Integer firstRow = seenRollNumbers.putIfAbsent(student.rollNumber(), row.rowNumber());
if (firstRow != null) {                               // row 7: "102" was seen in row 3
  errors.add(new RowError(row.rowNumber(), "Roll No", "duplicate of row " + firstRow));
}

For the database, one query per chunk of 500 rows returns the roll numbers that already exist:

@Query("select s.rollNumber from Student s where s.rollNumber in :rollNumbers")
List<String> findExistingRollNumbers(Collection<String> rollNumbers);

Uploading the same roster a second time saves nothing. The four valid rows now fail with already exists, next to the six errors we saw before:

{"totalRows":8,"imported":0,"failed":8,"errors":[
  {"row":2,"column":"Roll No","message":"already exists"},
  {"row":3,"column":"Roll No","message":"already exists"},
  {"row":5,"column":"Email","message":"must be a well-formed email address"},
  ...
  {"row":10,"column":"Roll No","message":"already exists"}]}

The unique constraint on rollNumber stays as the last guard. If two uploads run at the same time, the second insert fails with a constraint violation instead of storing a duplicate. Updating existing students instead of rejecting them is the other common policy; it needs a findByRollNumber() and changes to the loaded entity, which our example does not do.

2.8. Saving Rows in Batches

saveAll() calls persist() for each new entity, and Hibernate sends one INSERT per student. With JDBC batching, Hibernate groups up to 50 inserts into one database call. Two properties turn it on, and the SEQUENCE id with allocationSize = 50 keeps it working, because an IDENTITY id disables insert batching:

spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true

The service saves valid rows in chunks of 500. After each chunk, flush() sends the pending inserts and clear() detaches the saved entities, so memory does not grow with the file:

repository.saveAll(students);   // persist() per student, sent in JDBC batches of 50
entityManager.flush();          // run the batched inserts now
entityManager.clear();          // free the saved entities before the next chunk

The import method is @Transactional, so all chunks belong to one transaction. To see the batches, we turned on Hibernate statistics (spring.jpa.properties.hibernate.generate_statistics=true) and the org.hibernate.session.metrics logger at DEBUG. Importing 1,200 valid rows printed:

DEBUG org.hibernate.session.metrics : HHH000401: Logging session metrics:
  5891555 ns preparing 30 JDBC statements
  17971287 ns executing 27 JDBC statements
  156534667 ns executing 24 JDBC batches
  432371392 ns executing 4 flushes (flushing a total of 1200 entities and 0 collections)

1,200 inserts went to the database in 24 calls instead of 1,200. The plain statements are the 3 roll number checks (chunks of 500, 500 and 200) and the calls to student_seq, one per 50 ids. The log has more lines (connections, cache); we trimmed them.

2.9. Reading Large Files With the SAX Event API

XSSFWorkbook is fine for a class roster, but a district office may upload a sheet with every student in the region, and XSSFWorkbook keeps an object for every cell of it. The XSSF event API reads the sheet XML as a stream: XSSFSheetXMLHandler turns the XML into calls to our SheetContentsHandler, one per cell, already formatted by our IsoDateFormatter:

XSSFReader reader = new XSSFReader(pkg);
ReadOnlySharedStringsTable strings = new ReadOnlySharedStringsTable(pkg);
try (InputStream sheet = reader.getSheetIterator().next()) {        // first sheet
  XMLReader parser = XMLHelper.newXMLReader();
  parser.setContentHandler(new XSSFSheetXMLHandler(
      reader.getStylesTable(), strings, new RowCollector(rows), new IsoDateFormatter(), false));
  parser.parse(new InputSource(sheet));
}
public void startRow(int rowNum) {
  values = new ArrayList<>();
}

public void cell(String cellReference, String formattedValue, XSSFComment comment) {
  int column = new CellReference(cellReference).getCol();   // "C5" -> 2
  while (values.size() < column) {
    values.add("");                                         // empty cells are not reported
  }
  values.add(formattedValue == null ? "" : formattedValue.trim());
}

public void endRow(int rowNum) {
  if (!SheetReader.isBlank(values)) {
    rows.accept(new SheetRow(rowNum + 1, values));
  }
}

The streaming reader produces the same SheetRow objects, so the service does not change. The endpoint uses it with ?streaming=true, and the test suite runs the roster through both readers and expects the same report.

To compare memory, we generated a sheet with 100,000 students (3,007 KB on disk) and read it in a fresh JVM with a growing -Xmx until the read succeeded:

ReaderPackage opened fromSmallest -Xmx that worked
XSSF event API (SAX)File16 MB (the smallest we tried)
XSSF event API (SAX)InputStream96 MB
XSSFWorkbookFile768 MB (512 MB failed)
XSSFWorkbookInputStream768 MB (512 MB failed)
Bar chart of the smallest heap that read a 100,000-row, 3 MB .xlsx file: SAX event API from a file 16 MB, SAX event API from an InputStream 96 MB, XSSFWorkbook from a file 768 MB, XSSFWorkbook from an InputStream 768 MB
For a 3 MB file, XSSFWorkbook needed 768 MB of heap. The event API, opened from a file, read it with 16 MB.

Each try ran in a fresh JVM with -XX:+UseSerialGC on a small, shared sandbox machine with 2 CPUs, and the steps were 16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512 and 768 MB. The exact numbers will differ on other machines, but the gap will not. Two facts stood out:

  • The SAX reader only saves most of its memory when the package is opened from a file. With OPCPackage.open(InputStream), POI unzips every part into byte arrays first; the OutOfMemoryError in those runs came from that step, not from parsing. XSSFWorkbook needed the same heap either way, because it builds every cell as an object.
  • ReadOnlySharedStringsTable keeps all distinct strings of the file in memory, so a sheet full of unique names and emails needs more heap than one with repeated values.

With max-file-size=5MB, a user can upload a sheet with 100,000 students. If the application runs with a small heap, make the event API the default reader instead of an option.

FastExcel is another streaming option for reading large files; we stayed with POI so that both readers share the same DataFormatter logic.

2.10. Limiting the Upload Size

Spring Boot rejects uploads over 1MB per file and 10MB per request by default, according to the Spring Boot multipart how-to. A sheet with 100,000 students is about 3 MB, so we raise both limits:

spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=6MB

A larger file makes Spring throw MaxUploadSizeExceededException before our controller runs. A second handler in the same advice turns it into a 413 response:

@ExceptionHandler(MaxUploadSizeExceededException.class)
ProblemDetail tooLarge(MaxUploadSizeExceededException e) {
  return ProblemDetail.forStatusAndDetail(HttpStatus.CONTENT_TOO_LARGE,
      "The file is larger than the 5MB upload limit");
}
curl -F "file=@big.xlsx" http://localhost:8080/students/import
# HTTP 413
# {"detail":"The file is larger than the 5MB upload limit","instance":"/students/import","status":413,"title":"Content Too Large"}

A 50 MB file got the same 413 response in our run. max-request-size is a little larger than max-file-size because the request also carries the multipart headers and the other form fields.

MockMvc does not apply these limits, because it never parses a real multipart request. The project checks the 413 with @SpringBootTest(webEnvironment = RANDOM_PORT) and a real HTTP request to the embedded Tomcat.

2.11. Testing the Import With MockMvc

The test builds the roster in memory with POI, so no binary file has to live in src/test/resources. A small helper writes each value of a row with the matching cell type, the way a teacher’s sheet would store it:

rows.add(new Object[]{101, "Anna", "7A", "anna@school.com", 85, LocalDate.of(2024, 6, 15)});

switch (values) {
  case Number n -> cell.setCellValue(n.doubleValue());   // numeric cell
  case LocalDate d -> {
    cell.setCellValue(d);
    cell.setCellStyle(dateStyle);                         // data format 14, m/d/yy
  }
  default -> cell.setCellValue(values.toString());    // text cell
}

ByteArrayOutputStream out = new ByteArrayOutputStream();
workbook.write(out);
return out.toByteArray();                                 // the .xlsx bytes for the upload

MockMvc sends the bytes as a multipart request with multipart(). The test runs once per reader, compares the whole JSON report and then reads the saved rows back from the repository:

@ParameterizedTest
@ValueSource(booleans = {false, true})
void importsValidRowsAndReportsRowErrors(boolean streaming) throws Exception {
  byte[] roster = TestWorkbooks.xlsx(TestWorkbooks.roster());

  mockMvc.perform(multipart("/students/import")
          .file(new MockMultipartFile("file", "class-7.xlsx", XLSX, roster))   // file(roster) in the project
          .param("streaming", String.valueOf(streaming)))
      .andExpect(status().isOk())
      .andExpect(content().json(ROSTER_REPORT, JsonCompareMode.STRICT));

  List<Student> saved = repository.findAllByOrderByRollNumber();
  assertThat(saved).extracting(Student::getRollNumber).containsExactly("101", "102", "106", "107");
  Student anna = saved.getFirst();
  assertThat(anna.getMarks()).isEqualTo(85);
  assertThat(anna.getAdmissionDate()).isEqualTo(LocalDate.of(2024, 6, 15));
}

In Spring Boot 4, @AutoConfigureMockMvc lives in the spring-boot-webmvc-test module, which the spring-boot-starter-webmvc-test starter brings in. Other tests in the class cover the second upload, a missing column, a text file renamed to .xlsx, a 6 MB upload that MockMvc lets through, the all-or-nothing mode and the 24 batches for 1,200 rows.

3. Excel Import FAQs

3.1. Should We Accept CSV or Excel Files?

CSV is plain text with one line per row, so it is smaller, faster to parse and needs no POI. Excel is what office staff already use, and it keeps types and formats that CSV loses:

CSVExcel .xlsx
FormatPlain text, comma separatedZip of XML files
LibraryNone, or OpenCSVApache POI
Dates and numbersText only, format depends on who saved itTyped cells with display formats
Several sheetsNoYes
Leading zeros, such as roll “007”Lost when the file is opened and saved in ExcelKept in a text cell
Memory for large filesLine by lineNeeds the event API

When the people uploading the file work in Excel, we accept .xlsx. When another system produces the file, CSV is usually the simpler choice. Both can share the same SheetRow and validation code; only the reader changes.

3.2. How Do We Read a Numeric Cell as a String?

getStringCellValue() on a numeric cell throws IllegalStateException: Cannot get a STRING value from a NUMERIC cell. getNumericCellValue() works but returns 101.0. DataFormatter.formatCellValue(cell) returns “101”, the text Excel displays:

new DataFormatter().formatCellValue(roll);   // "101"

Calling cell.setCellType(CellType.STRING) before reading, as older tutorials show, is deprecated in POI 5.

3.3. How Do We Make the Import All or Nothing?

Some offices prefer to fix the file and upload it again rather than import half of it. With ?allOrNothing=true, the service still reads and checks every row, then marks the transaction for rollback when the report has errors:

if (allOrNothing && !run.errors.isEmpty()) {
  TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
  imported = 0;                               // the roster: imported 0, failed 4, no rows saved
}

The chunks that were already flushed are rolled back with the rest, and the teacher still gets the full error list.

3.4. Should We Use Spring Batch for Excel Imports?

For an upload that a user waits for, a controller and a service like ours are enough. Spring Batch fits scheduled imports of very large files, where restart after a failure, skip limits and job history matter more than an immediate HTTP response.

4. Conclusion

An Excel import in Spring Boot reads each cell as text with DataFormatter, maps columns by their header names, converts and validates every row, and saves the valid rows in JDBC batches while the invalid ones go into a JSON report with row, column and message. XSSFWorkbook is enough for sheets with a few thousand rows; for larger files, the XSSF event API opened from a file keeps the heap small, and spring.servlet.multipart.max-file-size sets the upper limit for what users can send. The reverse direction, exporting data to Excel from a REST API, uses the same POI classes.

5. References

Happy Learning !!

Source Code on Github

Leave a Comment

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.