Importing Data from Excel

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Baseline date: 2026-10-09. This page was written against Apache POI 5.5.1 (the latest release on that date; 6.0 is announced but has not been released, so what it says about 6.0 is expected, not shipped), Java 21 and later, Spring Boot 4.1.1 on Spring Framework 7, Jakarta EE 11 (Servlet 6.1, Validation 3.1), Hibernate Validator 9.1 and Quarkus 3.40. Every Java class below was compiled against those versions, the Spring Boot application was started and called with curl, and the outputs marked as observed come from those runs. The Quarkus resource and the Servlet were compiled but not deployed.

Importing a spreadsheet is not "read the cells". A user uploads books-import.xlsx, a person edited it by hand, and the server has to decide, row by row, what is data, what is a mistake and what to tell the person about the mistakes. This page builds one complete import for the Bookshelf scenario and keeps every decision visible:

  • an upload endpoint that stores the bytes safely and applies size limits;

  • a reader that finds columns by their header name, so reordering columns does not break the import;

  • a coercion layer that turns any cell (a date typed as text, a number stored as text, a formula, a merged cell) into one canonical text, and then into a typed value;

  • Jakarta Bean Validation per row, with Excel row numbers in the messages;

  • duplicate detection inside the file and against the database, and idempotency for a file that is uploaded twice;

  • all-or-nothing or partial import, in one transaction with batched inserts;

  • an error report the sender can act on, as JSON and as an annotated copy of the workbook.

The page reads the file, but it deliberately does not repeat what other pages of the section already document. The usermodel API is in Spreadsheet Usermodel Basics, the date and number rules in Dates, Numbers & DataFormatter, the event API in Streaming Reads: Event & SAX APIs, and everything about hostile files (zip bombs, XXE, magic numbers, macros, timeouts) in Security & Untrusted Files. This page assumes that upload validation and links to it at the point where it belongs. The matching import of Word forms, and the batch-job version of this import, are covered by other pages of the section.

The import pipeline

Every import has the same stages, whatever the framework. The figure below is the one the rest of the page follows, and each stage has a section.

flowchart LR U[Upload multipart request] --> S[Store in a private temp file with a size cap] S --> D{Detect type by content} D -- not an xlsx --> R1[Reject 400 or 415 or 422, nothing parsed] D -- xlsx --> P[Parse usermodel or SAX] P -- unreadable --> R2[Reject 422] P --> V[Map and validate every row] V --> C[Detect duplicates in the file and in the database] C --> M{Mode and errors} M -- all-or-nothing and any error --> R3[Reject, return the error report] M -- no errors, or partial --> T[Persist valid rows in one transaction, in batches] T --> OK[Return the report with the batch id] R3 --> E[Report as JSON or annotated workbook] OK --> E

Three rules shape the design, and each one is a lesson the code below makes visible:

  • Parse first, write later. Nothing touches the database until the whole sheet has been read and validated, so a bad file never leaves half a catalogue behind, and the sender gets every problem in one answer instead of one problem per attempt.

  • The row number is the user interface. The sender knows "row 17" because Excel shows it. Every message carries the 1-based row number as Excel displays it, never a zero-based index.

  • A file is hostile until proven otherwise, and so is a cell. The page stores the upload in a private temporary file, never uses the client file name, never returns the parser’s own exception text, and treats every cell value as untrusted input to validation and to the SQL layer (parameters only).

The running example

The Bookshelf catalogue upload is books-import.xlsx: one sheet, a header row, then one book per row. The columns are found by name, so the order in the file does not matter, and extra columns are ignored.

Header Type Bean property Rules

ISBN

text

isbn

Required, exactly 13 digits. Unique in the catalogue.

Title

text

title

Required, at most 200 characters.

Author

text

author

Required, at most 120 characters. Often a merged cell when one author wrote several consecutive books.

Published on

date

publishedOn

Required, a real Excel date or an ISO yyyy-MM-dd text, not in the future.

Price

decimal

price

Required, zero or more, at most 6 integer digits and 2 decimals. May be a formula.

Copies

whole number

copies

Required, between 0 and 10000.

The file used for every output on this page is the one below: nine rows under the header, written to exercise each case. Row 3 has its author in a merged cell shared with row 2, row 6 has a formula for the price, row 8 is blank, row 9 has an ISBN stored as a number and row 10 has a formula that evaluates to #DIV/0!.

Row ISBN Title Author Published on Price Copies

2

9780000000011

The Quiet Library

Ana Moreno (merged with row 3)

05/03/2019 (a date cell)

24.90

12

3

9780000000028

Maps of Salt

(merged cell)

20/11/2021 (a date cell)

18.50

7

4

97800000000

(empty)

Luis Vega

01/01/2031

-3.00

2.5

5

9780000000042

Small Engines

Mara Quint

last spring (text)

abc (text)

4

6

9780000000059

Late Trains

Mara Quint

01/06/2020

formula, 25.00

3

7

9780000000011

The Quiet Library (2nd copy)

Ana Moreno

05/03/2019

24.90

1

8

(blank row)

9

9780000000096 (a number cell)

Paper Rivers

Ana Moreno

14/09/2018

15.00

9

10

9780000000102

Broken Sums

Luis Vega

02/02/2017

formula, #DIV/0!

1

Rows 2, 3, 6 and 9 are valid. Row 4 has five problems, row 5 has two, row 7 repeats the ISBN of row 2, row 10 has an error value, and row 8 must be skipped without comment.

The dependencies are POI, the Jakarta Validation API with an implementation, and (for the JDBC part) Spring’s JDBC support. In a Spring Boot application the starters bring the rest.

<dependencies>
    <dependency>
        <groupId>org.apache.poi</groupId>
        <artifactId>poi-ooxml</artifactId>
        <version>5.5.1</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
</dependencies>
dependencies {
    implementation("org.apache.poi:poi-ooxml:5.5.1")
    implementation("org.springframework.boot:spring-boot-starter-webmvc")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-jdbc")
}

Source: POI component overview (poi-ooxml is the artifact for .xlsx) and the Spring Boot application properties for the starters. The setup details (lite jars, logging bridge) are on Setup & Dependencies.

Outside Spring Boot, add jakarta.validation:jakarta.validation-api, an implementation such as org.hibernate.validator:hibernate-validator and an Expression Language implementation for message interpolation (org.glassfish.expressly:expressly). Spring Boot’s validation starter brings both.

Receiving the upload

All three frameworks do the same thing before your code runs: they parse the multipart/form-data body, enforce size limits, and give you a handle on the uploaded part. What differs is the handle and where the limits live. In all three cases the example copies the part to a private temporary file and hands the Path to the import code. That is a deliberate choice rather than a convenience:

  • POI opens a .xlsx more safely and with less heap from a File than from an InputStream (the stream variant buffers the whole file first);

  • the file can be read twice, once to hash it and once to parse it, and once more to write the annotated copy;

  • the temporary file is deleted in a finally block, whatever happens.

The client-supplied file name and Content-Type are metadata to log, never to trust. Detecting the real type, the extension allow-list and the zip checks are in the upload validation section of the security page, whose UploadValidator the examples below call at the commented line. The example controllers leave that call out only to keep the code on this page about the import.

Spring MVC: MultipartFile and @RequestPart

Spring binds a multipart part to a MultipartFile with either @RequestPart("file") or @RequestParam("file"). They behave the same for a file; @RequestPart is the one to use when another part carries JSON that must go through an HttpMessageConverter (for example a part named options bound to a record), because only @RequestPart applies message converters to the part. The controller below uses @RequestPart for the file and plain @RequestParam for two small options, and it maps each outcome to a status code.

package bookshelf.imports;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/books")
public class BookImportController {

    static final MediaType XLSX =
            MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");

    private final BookImportService service;
    private final ErrorWorkbookWriter errorWriter = new ErrorWorkbookWriter();
    private final Path uploadDir;

    public BookImportController(BookImportService service, Path uploadDir) {
        this.service = service;
        this.uploadDir = uploadDir; // a private directory, outside the web root
    }

    @PostMapping(path = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<?> importBooks(@RequestPart("file") MultipartFile file,
                                         @RequestParam(defaultValue = "ALL_OR_NOTHING") ImportMode mode,
                                         @RequestParam(defaultValue = "json") String format) throws IOException {
        if (file.isEmpty()) {
            return ResponseEntity.badRequest().body(ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "No file."));
        }
        Path stored = Files.createTempFile(uploadDir, "upload-", ".xlsx"); // never the client's file name
        try {
            file.transferTo(stored);
            // validate type and size here: see "Security & Untrusted Files" (UploadValidator)
            ImportReport report = service.importFile(stored, file.getOriginalFilename(), mode);

            HttpStatus status = switch (report.status()) {
                case "IMPORTED", "PARTIALLY_IMPORTED" -> HttpStatus.OK;
                default -> HttpStatus.UNPROCESSABLE_ENTITY;
            };
            // a sheet-level error (rowsRead 0) answers JSON: the sheet may be over the row cap, so do not open it again
            if ("xlsx".equals(format) && !report.errors().isEmpty() && report.rowsRead() > 0) {
                ByteArrayOutputStream out = new ByteArrayOutputStream();
                errorWriter.write(stored, report.errors(), out);
                return ResponseEntity.status(status)
                        .contentType(XLSX)
                        .header(HttpHeaders.CONTENT_DISPOSITION,
                                ContentDisposition.attachment().filename("books-import-errors.xlsx").build().toString())
                        .header("X-Import-Status", report.status())
                        .body(out.toByteArray());
            }
            return ResponseEntity.status(status).body(report);
        } catch (InvalidFormatException e) {
            return ResponseEntity.unprocessableEntity()
                    .body(ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, "Not a valid .xlsx file."));
        } finally {
            Files.deleteIfExists(stored);
        }
    }

    @ExceptionHandler(UnreadableWorkbookException.class)
    public ProblemDetail unreadable(UnreadableWorkbookException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage());
    }

    @ExceptionHandler(AlreadyImportedException.class)
    public ProblemDetail alreadyImported(AlreadyImportedException e) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
        problem.setProperty("batchId", e.batchId());
        return problem;
    }
}

A few details of this controller are worth reading slowly:

  • file.transferTo(stored) moves or copies the part to the path. When the container has already spooled the part to disk it can be a cheap move; when the part was in memory it writes it. Either way the controller owns stored and deletes it.

  • Files.createTempFile(uploadDir, "upload-", ".xlsx") chooses the name. The directory must be private to the service and outside any web root; see the temp-file rules on the security page.

  • report.status() decides the HTTP status: IMPORTED and PARTIALLY_IMPORTED answer 200, everything else 422. A duplicate upload is a 409 through the exception handler, and an unreadable workbook is a 422.

  • The error workbook is built into a ByteArrayOutputStream because it is bounded: it is written only when rows were read, and the reader reads no rows from a sheet over 20,000 rows. A sheet-level error (over the cap, a missing column) leaves rowsRead at 0, so the controller answers the JSON report even with format=xlsx and never opens that upload a second time. For a larger bound, stream it with StreamingResponseBody, as the export pages of this section do.

The wiring is a handful of beans. The only one that is not obvious is the Path for the upload directory, which comes from a property so that tests and production can differ.

package app;

import bookshelf.imports.*;
import jakarta.validation.Validator;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.support.TransactionTemplate;

@SpringBootApplication
@Import(BookImportController.class)
public class BookshelfApplication {

    public static void main(String[] args) {
        SpringApplication.run(BookshelfApplication.class, args);
    }

    @Bean
    BookRowMapper bookRowMapper(Validator validator) {
        return new BookRowMapper(validator);
    }

    @Bean
    BookSheetReader bookSheetReader(BookRowMapper mapper) {
        return new BookSheetReader(mapper);
    }

    @Bean
    BookRepository bookRepository(JdbcTemplate jdbc) {
        return new BookRepository(jdbc);
    }

    @Bean
    BookImportService bookImportService(BookSheetReader reader, BookRepository repository, TransactionTemplate tx) {
        return new BookImportService(reader, repository, tx);
    }

    @Bean
    Path uploadDir(@Value("${bookshelf.upload-dir}") Path dir) throws IOException {
        return Files.createDirectories(dir);
    }
}

Source: Spring Boot application properties and the Spring programmatic transaction management reference (Spring Boot auto-configures the TransactionTemplate bean used above). The @Import is there only because the controller lives in the bookshelf.imports package, outside the application class’s package.

The size limits are Spring Boot properties of the spring.servlet.multipart group:

spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=12MB
spring.servlet.multipart.file-size-threshold=1MB
bookshelf.upload-dir=${java.io.tmpdir}/bookshelf-uploads
Property Default Effect

spring.servlet.multipart.max-file-size

1MB

The largest single part. A larger one fails the request with MaxUploadSizeExceededException, which Spring MVC resolves to 413 Content Too Large. A common first surprise: a 2 MB workbook fails with the default.

spring.servlet.multipart.max-request-size

10MB

The largest whole request, all parts and form fields together. Keep it a little above max-file-size.

spring.servlet.multipart.file-size-threshold

0

Parts larger than this are written to disk by the container instead of being held in memory. 0 means every part goes to disk.

spring.servlet.multipart.location

the servlet container’s temp directory

Where the container spools parts. Point it at the same private directory as the service’s uploads.

spring.servlet.multipart.resolve-lazily

false

When true, the request is parsed only when the controller asks for a part. Useful when a filter must reject the request before any bytes are written.

Observed with these settings (the 12,000,000-byte file is rejected before the controller runs):

$ head -c 12000000 /dev/zero > big.xlsx
$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' -F "file=@big.xlsx" http://localhost:8765/api/books/import
HTTP 413

Source: Spring Boot application properties (the spring.servlet.multipart group). The proxy in front of the application has limits of its own (client_max_body_size in nginx, for example); the smallest limit in the chain wins, and the client sees that proxy’s error page rather than the JSON of this application.

Servlet: Part and @MultipartConfig

Without Spring, the Servlet API gives you the same thing through HttpServletRequest.getPart(name). A servlet that calls getPart must be marked with @MultipartConfig (or declare the same settings in web.xml), otherwise the container refuses to parse the body. The limits are attributes of the annotation.

package bookshelf.imports;

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.MultipartConfig;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.Part;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

@WebServlet("/servlet/books/import")
@MultipartConfig(
        fileSizeThreshold = 1024 * 1024,      // above 1 MiB the container spools the part to disk
        maxFileSize = 10L * 1024 * 1024,      // one part, 10 MiB
        maxRequestSize = 12L * 1024 * 1024)   // the whole request, 12 MiB
public class BookImportServlet extends HttpServlet {

    private BookImportService service;
    private Path uploadDir;

    @Override
    public void init() {
        // the container calls the no-arg constructor, so take the collaborators from the context
        service = (BookImportService) getServletContext().getAttribute(BookImportService.class.getName());
        uploadDir = (Path) getServletContext().getAttribute("bookshelf.uploadDir");
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        Part part = request.getPart("file");          // IllegalStateException when a size limit is exceeded
        if (part == null || part.getSize() == 0) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "No file.");
            return;
        }
        Path stored = Files.createTempFile(uploadDir, "upload-", ".xlsx");
        try (InputStream in = part.getInputStream()) {
            Files.copy(in, stored, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
            ImportReport report = service.importFile(stored, part.getSubmittedFileName(), ImportMode.ALL_OR_NOTHING);
            response.setStatus(report.status().equals("REJECTED") ? 422 : 200);
            response.setContentType("application/json");
            response.setCharacterEncoding(StandardCharsets.UTF_8.name());
            response.getWriter().write(ReportJson.toJson(report));
        } catch (AlreadyImportedException e) {
            response.sendError(HttpServletResponse.SC_CONFLICT, e.getMessage());
        } finally {
            Files.deleteIfExists(stored);
            part.delete(); // release the container's own temporary copy
        }
    }
}

Source: Jakarta Servlet 6.1 specification (section on multipart configuration, @MultipartConfig, Part). A servlet declared with @WebServlet is instantiated by the container through its public no-arg constructor, so it cannot take its collaborators as constructor arguments: the example reads them in init() from context attributes that a ServletContextListener sets at startup (with CDI, @Inject fields do the same). To pass them to a constructor instead, drop @WebServlet and register the instance with ServletContext.addServlet, then call setMultipartConfig(new MultipartConfigElement(...)) on the returned registration. ReportJson is a ten-line JSON writer included in the compiled project; in a real application use JSON-B (jakarta.json.bind) or Jackson.

@MultipartConfig attribute Default Effect

fileSizeThreshold

0

Above this size the container writes the part to a temporary file, below it keeps it in memory.

maxFileSize

-1 (unlimited)

The largest single file. The servlet throws IllegalStateException from getPart or getParts when it is exceeded, so catch it and answer 413 yourself.

maxRequestSize

-1 (unlimited)

The largest request. The same exception when exceeded.

location

"" (container temp directory)

The directory where the container writes parts. Absolute path; set it to a private directory.

The defaults are unlimited, which is the opposite of Spring Boot’s defaults. A servlet without explicit limits accepts any size the connector allows, so always set both. Two more points: Part.getSubmittedFileName() is the client’s name and the same warning applies; and Part.delete() removes the container’s own temporary copy, which the example calls so that a part spooled to location does not stay on disk until the container’s cleanup.

Quarkus: @RestForm and FileUpload

Quarkus REST (the Quarkus implementation of Jakarta REST, formerly RESTEasy Reactive) binds a multipart part to a parameter annotated with @RestForm. A file arrives as org.jboss.resteasy.reactive.multipart.FileUpload: Quarkus has already written it to the uploads directory, and uploadedFile() is the java.nio.file.Path of that file.

package bookshelf.imports;

import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;

@Path("/api/books/import")
public class BookImportResource {

    @Inject
    BookImportService service;

    @POST
    @Consumes(MediaType.MULTIPART_FORM_DATA)
    @Produces(MediaType.APPLICATION_JSON)
    public Response upload(@RestForm("file") FileUpload file,
                           @QueryParam("mode") @DefaultValue("ALL_OR_NOTHING") ImportMode mode) throws IOException {
        // Quarkus has already spooled the part to quarkus.http.body.uploads-directory and deletes it after the request
        ImportReport report = service.importFile(file.uploadedFile(), file.fileName(), mode);
        int status = report.status().equals("REJECTED") ? 422 : 200;
        return Response.status(status).entity(report).build();
    }
}

The BookImportService is injected, so it needs a CDI producer, for example a @Produces method in an @ApplicationScoped class that builds the reader from the injected jakarta.validation.Validator, the repository from a JdbcTemplate-like adapter, and so on. The service class is plain Java and does not depend on Spring beyond the transaction and JDBC helper used here; with Quarkus the same logic is usually written with Hibernate ORM with Panache or a jakarta.transaction.UserTransaction (see the Quarkus pages of this site) and the import classes are unchanged.

The limits and the location are configuration properties:

quarkus.http.limits.max-body-size=12M
quarkus.http.body.uploads-directory=/var/lib/bookshelf/uploads
quarkus.http.body.delete-uploaded-files-on-end=true
Property Default Effect

quarkus.http.limits.max-body-size

10240K

The largest request body. There is no per-file limit: with one file part, this is also the file limit. A larger request is answered with 413 by the HTTP layer.

quarkus.http.body.uploads-directory

${java.io.tmpdir}/uploads

Where Quarkus stores the parts of multipart/form-data requests.

quarkus.http.body.delete-uploaded-files-on-end

true

Quarkus removes the uploaded files when the response is sent. With false they stay in the directory forever, so only disable it when something else cleans the directory.

Source: Quarkus configuration reference (quarkus.http.limits.*, quarkus.http.body.*) and Quarkus HTTP reference. Because Quarkus deletes the file when the response ends, a handler that wants to keep the upload (to process it in a background job, for example) must copy it somewhere else before returning. See the Quarkus pages of this site on REST services and validation.

The limits side by side

Question Spring Boot Servlet Quarkus

Largest file

spring.servlet.multipart.max-file-size, default 1 MB

@MultipartConfig(maxFileSize), default unlimited

quarkus.http.limits.max-body-size, default 10240K (no per-file limit)

Largest request

spring.servlet.multipart.max-request-size, default 10 MB

@MultipartConfig(maxRequestSize), default unlimited

the same property

Where parts are spooled

spring.servlet.multipart.location

@MultipartConfig(location)

quarkus.http.body.uploads-directory

Memory threshold

file-size-threshold

fileSizeThreshold

(parts always go to disk)

Failure signal

MaxUploadSizeExceededException, 413

IllegalStateException from getPart

413 from the HTTP layer

Set the limit from what your files really look like, as the security page recommends. A workbook of 20,000 rows by 6 columns is typically well under a few megabytes, so a 10 MB limit is already generous for this catalogue. The byte limit and the zip limits of the security page (ZipSecureFile) are what bound the memory of the parse: the row cap of the reader (below) is checked with getLastRowNum() only after WorkbookFactory.create has loaded the sheet, so it bounds the work that follows (mapping, validation, the database, the error list), not the parse itself. To refuse a long sheet before loading it, read the sheet’s <dimension> element first, or use the SAX reader, whose heap does not grow with the sheet.

Choosing the reader: usermodel or SAX

POI offers two ways to read an .xlsx, and the choice is about memory, not about convenience.

Question Usermodel (WorkbookFactory, XSSFWorkbook) Event/SAX (XSSFReader + XSSFSheetXMLHandler)

Memory

Loads the whole sheet as an object model; heap grows with the number of cells, many times the file size.

Streams the XML; heap stays flat, the shared strings table aside.

Random access

Yes: any cell, any row, merged regions, getLastRowNum, rows read twice.

No: rows arrive once, in order. Merged regions and cell comments have to be handled by hand.

Cell types

Typed access: CellType, getNumericCellValue, DateUtil.isCellDateFormatted.

Text only: the handler gives each cell as already formatted text.

Formula cells

Cached result or FormulaEvaluator.

The cached result (or the formula text, by option).

Code size

Small, readable.

A handler class, a few dozen lines.

When

Files you cap at some thousands of rows, forms and catalogues edited by people.

Files of tens or hundreds of thousands of rows: bulk loads, exports from another system.

Decide with two numbers: the row cap you are willing to accept, and the heap you can give to one import. Measure both on your real files, because the cost per cell depends on the strings and styles in them (see Streaming Reads: Event & SAX APIs for how to measure). The Bookshelf catalogue upload is edited by hand, so the usermodel reader is the main path of this page, with a hard cap of 20,000 rows (BookSheetReader.MAX_ROWS). The SAX reader that feeds the same mapper and validator is shown later, for the cases where the cap has to be lifted. Both produce identical results, which the page demonstrates.

Opening the workbook is WorkbookFactory.create(File, String, boolean): the file, a password (null for none) and a readOnly flag. Read-only opening opens the package with read access, so closing the workbook never writes back into the file. For an upload that must be .xlsx only, new XSSFWorkbook(file) is also an option, but it is the wrong choice for reading: see the pitfall about write-back near the end of this page. WorkbookFactory also accepts .xls; to reject it, check the type first, as the security page does with FileMagic. The writer of the annotated copy needs an .xlsx, so the pipeline rejects .xls at the detect stage.

Modelling a row

The domain is a Java record with Jakarta Bean Validation constraints on its components. A record is a good fit: it is immutable, the constructor takes every field, and the constraints document the rules next to the data.

package bookshelf.imports;

import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PastOrPresent;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;

/** One data row of books-import.xlsx, after type coercion and before persistence. */
public record BookRow(
        @NotBlank @Pattern(regexp = "\\d{13}", message = "must be exactly 13 digits") String isbn,
        @NotBlank @Size(max = 200) String title,
        @NotBlank @Size(max = 120) String author,
        @NotNull @PastOrPresent LocalDate publishedOn,
        @NotNull @DecimalMin("0.00") @Digits(integer = 6, fraction = 2) BigDecimal price,
        @NotNull @Min(0) @Max(10000) Integer copies) {
}

copies is an Integer rather than an int on purpose: a missing cell must be representable as null so that @NotNull can report it. With a primitive, a missing value would silently become 0. The same reasoning applies to every other field. The message of @Pattern is explicit because the default message would show the regular expression to the person who uploaded the file.

The mapping between header text, column and bean property is one enum. Matching is tolerant of the things people do to headers: case, extra spaces and the non-breaking space that Word and web pages leave in pasted text.

package bookshelf.imports;

import java.util.Locale;
import java.util.Optional;

/** The columns of books-import.xlsx: header text, bean property and whether the column must exist. */
public enum BookColumn {
    ISBN("ISBN", "isbn"),
    TITLE("Title", "title"),
    AUTHOR("Author", "author"),
    PUBLISHED_ON("Published on", "publishedOn"),
    PRICE("Price", "price"),
    COPIES("Copies", "copies");

    private final String header;
    private final String property;

    BookColumn(String header, String property) {
        this.header = header;
        this.property = property;
    }

    public String header() {
        return header;
    }

    public String property() {
        return property;
    }

    /** Case-, space- and non-breaking-space-insensitive match of a header cell. */
    public static Optional<BookColumn> forHeader(String text) {
        String wanted = normalise(text);
        for (BookColumn column : values()) {
            if (normalise(column.header).equals(wanted)) {
                return Optional.of(column);
            }
        }
        return Optional.empty();
    }

    public static Optional<BookColumn> forProperty(String property) {
        for (BookColumn column : values()) {
            if (column.property.equals(property)) {
                return Optional.of(column);
            }
        }
        return Optional.empty();
    }

    private static String normalise(String text) {
        return text == null ? "" : text.replace(' ', ' ').strip().replaceAll("\\s+", " ").toLowerCase(Locale.ROOT);
    }
}

Source: Jakarta Validation 3.1 specification for the constraint annotations used (@NotBlank, @NotNull, @Pattern, @Size, @Min, @Max, @DecimalMin, @Digits, @PastOrPresent), and the Hibernate Validator reference.

An error is a record with the row, the column (or null for a message about the whole row or the whole sheet) and the message. Row numbers are Excel’s, 1-based.

package bookshelf.imports;

/** A problem in one cell (column set) or in a whole row (column null). Row numbers are Excel's, 1-based. */
public record RowError(int row, BookColumn column, String message) {

    public static RowError ofRow(int row, String message) {
        return new RowError(row, null, message);
    }
}

Reading the header row

The reader looks at the first row of the first sheet, matches each header cell to a BookColumn, and builds a map from column to cell index. Everything after that works with the map, so a file with Copies before Price, or with an extra Notes column, imports the same way.

package bookshelf.imports;

import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;

/** Usermodel reader: header row -> column indexes by name, then one Mapped per data row. */
public final class BookSheetReader {

    public static final int MAX_ROWS = 20_000;

    private final BookRowMapper mapper;
    private final CellText text = new CellText();

    public BookSheetReader(BookRowMapper mapper) {
        this.mapper = mapper;
    }

    public ParsedSheet read(Path file) throws IOException {
        try (Workbook workbook = WorkbookFactory.create(file.toFile(), null, true)) {
            if (workbook.getNumberOfSheets() == 0) {
                return new ParsedSheet(List.of(), List.of(RowError.ofRow(1, "The workbook has no sheets.")), 0);
            }
            return read(workbook.getSheetAt(0));
        }
    }

    ParsedSheet read(Sheet sheet) {
        List<RowError> sheetErrors = new ArrayList<>();
        Row header = sheet.getRow(sheet.getFirstRowNum());
        if (header == null) {
            return new ParsedSheet(List.of(), List.of(RowError.ofRow(1, "The first sheet is empty.")), 0);
        }
        Map<BookColumn, Integer> columns = headerColumns(header, sheetErrors);
        if (!sheetErrors.isEmpty()) {
            return new ParsedSheet(List.of(), sheetErrors, 0);
        }
        int dataRows = sheet.getLastRowNum() - header.getRowNum();
        if (dataRows > MAX_ROWS) {
            sheetErrors.add(RowError.ofRow(1, "The sheet has " + dataRows + " rows; the limit is " + MAX_ROWS + "."));
            return new ParsedSheet(List.of(), sheetErrors, 0);
        }

        List<BookRowMapper.Mapped> mapped = new ArrayList<>();
        int blank = 0;
        for (int r = header.getRowNum() + 1; r <= sheet.getLastRowNum(); r++) {
            Row row = sheet.getRow(r);
            if (Sheets.isBlank(row, text)) {
                blank++;
                continue;
            }
            Map<BookColumn, String> values = new EnumMap<>(BookColumn.class);
            List<RowError> cellErrors = new ArrayList<>();
            for (Map.Entry<BookColumn, Integer> column : columns.entrySet()) {
                Cell cell = Sheets.valueCell(sheet, row, column.getValue());
                try {
                    values.put(column.getKey(), text.of(cell));
                } catch (CellText.CellErrorException e) {
                    cellErrors.add(new RowError(r + 1, column.getKey(), e.getMessage()));
                }
            }
            mapped.add(mapper.map(r + 1, values, cellErrors));
        }
        return new ParsedSheet(mapped, sheetErrors, blank);
    }

    private Map<BookColumn, Integer> headerColumns(Row header, List<RowError> errors) {
        Map<BookColumn, Integer> columns = new EnumMap<>(BookColumn.class);
        for (Cell cell : header) {
            try {
                BookColumn.forHeader(text.of(cell)).ifPresent(column -> {
                    if (columns.putIfAbsent(column, cell.getColumnIndex()) != null) {
                        errors.add(RowError.ofRow(header.getRowNum() + 1, "Column '" + column.header() + "' appears twice."));
                    }
                });
            } catch (CellText.CellErrorException e) {
                errors.add(RowError.ofRow(header.getRowNum() + 1, "The header row contains an error value."));
            }
        }
        for (BookColumn column : BookColumn.values()) {
            if (!columns.containsKey(column)) {
                errors.add(RowError.ofRow(header.getRowNum() + 1, "Missing column '" + column.header() + "'."));
            }
        }
        return columns;
    }
}

What the reader does, in the order it does it:

  1. Opens read-only with WorkbookFactory.create(file, null, true) inside try-with-resources, which closes the package.

  2. Rejects structural problems once, as sheet-level errors with row 1: an empty first sheet, a missing required column, a duplicate column, an error value in the header, and a sheet over the row cap. These stop the import, because no row can be interpreted without its columns, and they are reported together instead of one per attempt.

  3. Skips blank rows with Sheets.isBlank, and counts them. A row that is null, has no cells or has only cells that are blank after trimming is not data. Excel sheets routinely carry empty rows in the middle (a separator) and after the last record (formatting that was cleared but not deleted), so getLastRowNum is not the number of records.

  4. Reads each mapped cell through Sheets.valueCell (merged regions) and CellText.of (coercion), collecting cell-level problems such as error values.

  5. Hands the texts to the mapper, which converts, validates and returns the row result.

Source: POI quick guide, iterating over rows and cells and the Sheet Javadoc (getFirstRowNum, getLastRowNum, getMergedRegions).

The header map is by name, but the reader still decides which sheet to read (the first), which row is the header (the first row that exists) and which headers are required (all six). Those are the three decisions to make explicit for your own template. If users upload files exported by another tool with a title above the header, add a "find the header row" step that scans the first rows for a row containing the required headers, and report the row it found.

Turning cells into values

A cell is not a value. It is a type (CellType), a stored value and a style whose data format decides how the value is displayed. A user who types 2025-03-05 into a cell formatted as General gets a date cell displayed in a locale format; a user who types '9780134685991 gets a text cell; and a user who pastes from a web page gets a text cell holding an apparent number with a trailing space. The importer must give the same result for all of them.

Why not just read the string?

There are three obvious ways to get text from a numeric cell, and they give three different answers. The table is observed output for the five cells shown, with DataFormatter in two locales and the canonical text of this page.

Cell (format) getNumericCellValue() DataFormatter ROOT DataFormatter es_ES Canonical text Fit for import?

ISBN stored as a number (General)

9.780000000096E12

9.78E+12

9,78E+12

9780000000096

only the canonical text

Price 1234.5 (#,##0.00)

1234.5

1,234.50

1.234,50

1234.5

only the canonical text

Price 24.9 (0.00 "EUR")

24.9

24.90 EUR

24,90 EUR

24.9

only the canonical text

Copies 12 (0)

12.0

12

12

12

any of the last three

Date 2019-03-05 (dd/mm/yyyy)

43529.0

05/03/2019

05/03/2019

2019-03-05

only the canonical text

Date 2019-03-05 (d mmm yyyy)

43529.0

5 Mar 2019

5 mar 2019

2019-03-05

only the canonical text

The lesson is that DataFormatter returns what Excel would display, which is the right answer for a report and the wrong answer for an import: it changes with the cell’s format and with the Locale, it truncates a long number to scientific notation (the ISBN above), and it adds currency text. getNumericCellValue() returns the raw double, which for a date is a serial number and for an ISBN loses nothing only while the value fits in the 15 significant digits of a double. The importer therefore reads each cell by type and produces canonical text: a date as ISO-8601 through DateUtil, a number as a plain decimal through BigDecimal. Display text from DataFormatter is used only where display text is exactly what is wanted, as in the event reader below.

Source: DataFormatter Javadoc and DateUtil Javadoc. The same trade-offs, with the format-code table, are on Dates, Numbers & DataFormatter.

The coercion class

package bookshelf.imports;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.FormulaError;

/**
 * Turns any cell into canonical text, or null when it is blank. Dates become ISO-8601 whatever the cell format shows,
 * numbers become plain decimals (never "1.0E7"), formulas yield their cached result.
 */
public final class CellText {

    /** A cell holding an Excel error value such as #DIV/0!. */
    public static final class CellErrorException extends Exception {
        public CellErrorException(String message) {
            super(message);
        }
    }

    private final DataFormatter formatter = new DataFormatter(java.util.Locale.ROOT);

    public String of(Cell cell) throws CellErrorException {
        if (cell == null) {
            return null;
        }
        CellType type = cell.getCellType() == CellType.FORMULA ? cell.getCachedFormulaResultType() : cell.getCellType();
        switch (type) {
            case BLANK:
                return null;
            case STRING:
                return blankToNull(cell.getStringCellValue());
            case BOOLEAN:
                return String.valueOf(cell.getBooleanCellValue());
            case NUMERIC:
                if (DateUtil.isCellDateFormatted(cell)) {
                    LocalDateTime value = cell.getLocalDateTimeCellValue();
                    return value.toLocalTime().equals(java.time.LocalTime.MIDNIGHT)
                            ? value.toLocalDate().toString() : value.toString();
                }
                return BigDecimal.valueOf(cell.getNumericCellValue()).stripTrailingZeros().toPlainString();
            case ERROR:
                throw new CellErrorException("the cell contains the error " + FormulaError.forInt(cell.getErrorCellValue()).getString());
            default:
                return blankToNull(formatter.formatCellValue(cell));
        }
    }

    private static String blankToNull(String text) {
        if (text == null) {
            return null;
        }
        String stripped = text.replace(' ', ' ').strip();
        return stripped.isEmpty() ? null : stripped;
    }
}

How each case is handled:

Cell Handling

null cell, BLANK

Returns null. A cell that does not exist and a cell that exists but is empty are the same to the importer. Bean Validation then decides whether null is allowed (@NotNull).

STRING

Trimmed, with the non-breaking space (U+00A0) treated as a space, which String.strip() alone does not do. An all-blank string is null. This matters: a cell containing only a space is not "present".

NUMERIC, date format

DateUtil.isCellDateFormatted(cell) decides on the cell’s data format. The date is read with getLocalDateTimeCellValue(), which honours the workbook’s date system (1900 or 1904). A time of midnight becomes yyyy-MM-dd, anything else keeps the time.

NUMERIC, other

BigDecimal.valueOf(double).stripTrailingZeros().toPlainString(). valueOf uses the shortest representation that round-trips, so 24.9 stays 24.9, and toPlainString never produces an exponent. A value such as 24.90 becomes 24.9; the numeric type is restored by BigDecimal later.

BOOLEAN

"true" or "false". No column of the Bookshelf template is boolean, so Bean Validation or the conversion step rejects it if it appears in a numeric column.

FORMULA

The cached result, by switching on getCachedFormulaResultType() instead of on FORMULA. A formula cell stores the last value Excel computed, so reading it costs nothing and does not need a FormulaEvaluator.

ERROR

Throws CellErrorException with the error name from FormulaError, for example #DIV/0!. An error value is content, not blank, and the row is reported.

The formula case deserves a warning. The cached result exists only if the file was last saved by an application that calculates, such as Excel or LibreOffice. A workbook written by POI (or by any library that does not evaluate) has formulas without cached values, and reading the cached result gives 0 or an empty string. If your users upload files generated by code, evaluate them first with a FormulaEvaluator (evaluateAll() or evaluateInCell) and accept the cost: evaluation needs the functions in the file to be supported by POI, and external-workbook references need the other workbooks. The decision for an import is whether to trust the cached value (fast, as Excel showed it to the user) or recalculate (slower, consistent with the formulas); the page trusts the cached value, which is what the person saw when they saved the file. The evaluator is documented on Formulas & Evaluation.

Other numeric traps worth knowing, all of which this design handles:

  • Numbers stored as text. A text cell holding "24.90" returns that string unchanged, and BigDecimal parses it. The decimal comma of a European locale ("24,90") is normalised in the mapper by replacing the comma with a point; a thousands separator is not handled, and a value like 1.234,50 is reported as "not a number" rather than guessed.

  • Leading zeros. An ISBN, a postal code or a part number stored as a number has lost its leading zeros; only a text cell keeps them. Treat identifiers as text in the template (format the column as Text) and validate their length.

  • Dates as text. A text cell "2019-03-05" reaches the mapper as that string and parses as ISO. A text like "05/03/2019" does not, on purpose, because day-month and month-day are ambiguous. Reject it with a message that tells the user which format to use.

  • The 1904 date system. Workbooks created on old Mac versions can use it. getLocalDateTimeCellValue() accounts for the flag stored in the workbook.

  • Precision. Money as a double is wrong in general; the page goes through BigDecimal text so that 24.9 is exactly 24.9, but a cell that already holds a value with floating-point noise (0.30000000000000004) is reported by @Digits, not silently rounded.

Blank and merged cells

A merged region stores its value only in the top-left cell. The rest of the region exists as blank cells (or not at all), so a catalogue that merges the author across consecutive books would report "Author must not be blank" for every row but the first. Sheets.valueCell fixes that by looking in the sheet’s merged regions when a cell is blank, and returning the top-left cell of the region.

package bookshelf.imports;

import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.util.CellRangeAddress;

/** Small helpers for the cases that make a spreadsheet different from a table. */
public final class Sheets {

    private Sheets() {
    }

    /** True when the row is missing, has no cells, or has only blank / whitespace-only cells. */
    public static boolean isBlank(Row row, CellText text) {
        if (row == null) {
            return true;
        }
        for (Cell cell : row) {
            try {
                if (text.of(cell) != null) {
                    return false;
                }
            } catch (CellText.CellErrorException e) {
                return false; // an error value is content, not blank
            }
        }
        return true;
    }

    /**
     * A merged region stores its value only in the top-left cell; the other cells read as blank. Returns the cell that
     * really holds the value for (row, column), or the cell itself when it is not part of a merged region.
     */
    public static Cell valueCell(Sheet sheet, Row row, int column) {
        Cell own = row.getCell(column);
        if (own != null && own.getCellType() != org.apache.poi.ss.usermodel.CellType.BLANK) {
            return own;
        }
        for (CellRangeAddress region : sheet.getMergedRegions()) {
            if (region.isInRange(row.getRowNum(), column)) {
                Row top = sheet.getRow(region.getFirstRow());
                return top == null ? null : top.getCell(region.getFirstColumn());
            }
        }
        return own;
    }
}

Two cautions. Sheet.getMergedRegions() builds a list on each call, so for a big sheet read it once and index it (a map from row to regions). And merging across rows is a template decision: a merged header over several columns breaks the header map in a way that no importer can repair, so reject it (in the page’s reader it shows up as a missing column).

Validating every row

The mapper converts the texts to typed values (a date, a decimal, an integer) and then runs Validator.validate on the BookRow. These are two kinds of error and the mapper keeps them apart on purpose.

package bookshelf.imports;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/** Raw column texts of one row -> BookRow (coercion errors) -> Jakarta Bean Validation (constraint errors). */
public final class BookRowMapper {

    public record Mapped(int rowNumber, BookRow book, List<RowError> errors) {
        public boolean valid() {
            return errors.isEmpty();
        }
    }

    private final Validator validator;

    public BookRowMapper(Validator validator) {
        this.validator = validator;
    }

    public Mapped map(int rowNumber, Map<BookColumn, String> text) {
        return map(rowNumber, text, List.of());
    }

    /** {@code earlier} holds errors found while reading the cells (for example #DIV/0!); they are kept and not repeated. */
    public Mapped map(int rowNumber, Map<BookColumn, String> text, List<RowError> earlier) {
        List<RowError> errors = new ArrayList<>(earlier);
        Set<BookColumn> failed = EnumSet.noneOf(BookColumn.class);
        earlier.forEach(e -> failed.add(e.column()));

        LocalDate publishedOn = null;
        BigDecimal price = null;
        Integer copies = null;
        try {
            publishedOn = date(text.get(BookColumn.PUBLISHED_ON));
        } catch (IllegalArgumentException e) {
            fail(errors, failed, rowNumber, BookColumn.PUBLISHED_ON, e.getMessage());
        }
        try {
            price = decimal(text.get(BookColumn.PRICE));
        } catch (IllegalArgumentException e) {
            fail(errors, failed, rowNumber, BookColumn.PRICE, e.getMessage());
        }
        try {
            copies = integer(text.get(BookColumn.COPIES));
        } catch (IllegalArgumentException e) {
            fail(errors, failed, rowNumber, BookColumn.COPIES, e.getMessage());
        }

        BookRow book = new BookRow(text.get(BookColumn.ISBN), text.get(BookColumn.TITLE), text.get(BookColumn.AUTHOR),
                publishedOn, price, copies);

        for (ConstraintViolation<BookRow> violation : validator.validate(book)) {
            BookColumn column = BookColumn.forProperty(violation.getPropertyPath().toString()).orElse(null);
            if (column != null && failed.contains(column)) {
                continue; // already reported as a coercion error; "must not be null" would only repeat it
            }
            errors.add(new RowError(rowNumber, column, violation.getMessage()));
        }
        errors.sort(java.util.Comparator.comparing(e -> e.column() == null ? -1 : e.column().ordinal()));
        return new Mapped(rowNumber, book, errors);
    }

    private static void fail(List<RowError> errors, Set<BookColumn> failed, int row, BookColumn column, String message) {
        errors.add(new RowError(row, column, message));
        failed.add(column);
    }

    static LocalDate date(String text) {
        if (text == null) {
            return null;
        }
        try {
            return LocalDate.parse(text.length() > 10 ? text.substring(0, 10) : text);
        } catch (DateTimeParseException e) {
            throw new IllegalArgumentException("'" + text + "' is not a date (use a real Excel date or yyyy-MM-dd)");
        }
    }

    static BigDecimal decimal(String text) {
        if (text == null) {
            return null;
        }
        try {
            return new BigDecimal(text.replace(',', '.'));
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("'" + text + "' is not a number");
        }
    }

    static Integer integer(String text) {
        if (text == null) {
            return null;
        }
        try {
            return new BigDecimal(text).intValueExact();
        } catch (NumberFormatException | ArithmeticException e) {
            throw new IllegalArgumentException("'" + text + "' is not a whole number");
        }
    }
}

Notes on this class, because several decisions are hidden in it:

  • Conversion errors come first and win. If Price is abc, the mapper reports "'abc' is not a number" and records that the column failed; when the bean is then validated, the @NotNull violation for the same property is ignored, because it would only say the same thing again in worse words. Without this the person receives two messages for one cell.

  • Violations map back to columns through ConstraintViolation.getPropertyPath(), whose string form for a record component is the component name. That is why BookColumn has a property. A class-level constraint (for example "the price of a rare edition is at least 100") has an empty path, and forProperty returns empty, so the error becomes a row-level error with the column null.

  • Messages are for the sender. violation.getMessage() is the interpolated message. Do not return violation.getInvalidValue() or the constraint’s attributes to the client unfiltered when the value could be long or sensitive.

  • Validation does not stop at the first failure. validate returns every violation, so a row with five problems yields five errors in one pass.

  • The order is stable. The errors of a row are sorted by column so that the report reads left to right.

The Validator itself is created once and is thread-safe. Outside Spring, build it with a factory, and fix the locale if the report must not depend on the server’s default:

import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import java.util.Locale;
import org.hibernate.validator.HibernateValidator;

ValidatorFactory factory = Validation.byProvider(HibernateValidator.class)
        .configure()
        .defaultLocale(Locale.ENGLISH)
        .locales(Locale.ENGLISH)
        .buildValidatorFactory();
Validator validator = factory.getValidator();

The locale matters more than it looks. The first test of this page ran with the machine’s default locale, and half of the messages came back in Spanish while the conversion messages written in code stayed in English. In a Spring application the validator factory uses the request’s locale (from Accept-Language), so the report follows the client; decide whether that is what you want and, if the report is stored or shown to someone else, pin the locale.

Observed result of the usermodel reader on the file above (valid rows have an empty error list; the mapped value shows that the merged author, the formula price and the numeric ISBN were all read correctly):

Row Mapped value Errors

2

BookRow[isbn=9780000000011, title=The Quiet Library, author=Ana Moreno, publishedOn=2019-03-05, price=24.9, copies=12]

none

3

BookRow[isbn=9780000000028, title=Maps of Salt, author=Ana Moreno, publishedOn=2021-11-20, price=18.5, copies=7]

none (author read from the merged region)

4

BookRow[isbn=97800000000, title=null, author=Luis Vega, publishedOn=2031-01-01, price=-3, copies=null]

ISBN: must be exactly 13 digits; Title: must not be blank; Published on: must be a date in the past or in the present; Price: must be greater than or equal to 0.00; Copies: '2.5' is not a whole number

5

BookRow[isbn=9780000000042, title=Small Engines, author=Mara Quint, publishedOn=null, price=null, copies=4]

Published on: 'last spring' is not a date (use a real Excel date or yyyy-MM-dd); Price: 'abc' is not a number

6

BookRow[isbn=9780000000059, title=Late Trains, author=Mara Quint, publishedOn=2020-06-01, price=25, copies=3]

none (price from the cached formula result)

7

BookRow[isbn=9780000000011, title=The Quiet Library (2nd copy), author=Ana Moreno, publishedOn=2019-03-05, price=24.9, copies=1]

none yet; the duplicate is found in the next step

9

BookRow[isbn=9780000000096, title=Paper Rivers, author=Ana Moreno, publishedOn=2018-09-14, price=15, copies=9]

none (ISBN from a numeric cell)

10

BookRow[isbn=9780000000102, title=Broken Sums, author=Luis Vega, publishedOn=2017-02-02, price=null, copies=1]

Price: the cell contains the error #DIV/0!

Row 8 is blank and is counted, not reported. Compare the cell in row 4 for Copies: the number 2.5 is not an int, and intValueExact() makes the failure explicit instead of truncating to 2.

Duplicates and idempotency

An import can be repeated in three ways, and each needs its own defence:

Repetition Example Defence

The same row twice in one file

Rows 2 and 7 of the example both have ISBN 9780000000011.

An in-memory map from the natural key to the first row number. The later rows become errors that name the first one ("duplicate of row 2 in this file").

A row that is already in the database

Last month’s catalogue is uploaded again with a few new books.

A lookup of the keys of the valid rows against the table, before inserting, in chunks. The rows found become errors ("ISBN already in the catalogue"). A unique constraint on the key is still the real guard against two concurrent imports.

The same file twice

The user double-clicks, the client retries after a timeout, a proxy replays the request.

A SHA-256 of the file content stored in an import_batch table with a unique index. A second upload of identical bytes is answered with 409 and the id of the first batch.

The natural key of a book is the ISBN, not the row number and not the title. Choose the key of your domain with care: it is the thing that decides "is this a new record or one I have?" A composite key is just as easy (a record of the columns in a Set).

The file hash is the idempotency key. It is cheap, independent of the file name (people rename files) and of the client, and it is stored in the same transaction as the books: if the insert fails and the transaction rolls back, the hash is not recorded, so the user can fix the problem and upload the same file again. A hash recorded before the transaction would turn a failed import into a permanent "already imported".

A file hash does not catch the case where a person saves the workbook again (the bytes differ even though the content is the same, because the zip timestamps change). That is what the per-row duplicate check against the database is for. The two checks are complementary: the hash makes the retry safe and cheap, the key check makes the content safe.

When duplicates in the database should update instead of failing, use an upsert, not a check followed by an insert. The check-then-insert pattern has a window in which another request inserts the same key. The statements are specific to the database: PostgreSQL has INSERT ... ON CONFLICT (isbn) DO UPDATE, and standard SQL has MERGE. That changes the meaning of "duplicate" from an error to an update, and the report should then say how many rows were created and how many were updated.

Source: PostgreSQL INSERT, ON CONFLICT for the upsert syntax. The locking and isolation questions behind check-then-insert are on Transaction Isolation & Locking.

Persisting: all-or-nothing, partial and batches

The service ties the stages together. It hashes the file, refuses a repeat, parses, collects every error, applies the duplicate rules and then, depending on the mode, writes in one transaction.

package bookshelf.imports;

public enum ImportMode {
    /** Any error rejects the whole file; nothing is written. */
    ALL_OR_NOTHING,
    /** Valid rows are imported, invalid rows are reported. */
    PARTIAL
}
package bookshelf.imports;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.support.TransactionTemplate;

/** parse -> validate -> detect duplicates -> persist, for one stored upload. */
public class BookImportService {

    private final BookSheetReader reader;
    private final BookRepository repository;
    private final TransactionTemplate transaction;

    public BookImportService(BookSheetReader reader, BookRepository repository, TransactionTemplate transaction) {
        this.reader = reader;
        this.repository = repository;
        this.transaction = transaction;
    }

    public ImportReport importFile(Path stored, String clientFileName, ImportMode mode) throws IOException {
        String hash = sha256(stored);
        repository.findBatchByHash(hash).ifPresent(id -> {
            throw new AlreadyImportedException(id);
        });

        ParsedSheet parsed;
        try {
            parsed = reader.read(stored);
        } catch (IOException | RuntimeException e) { // not a workbook, or POI refused it
            throw new UnreadableWorkbookException("The file is not a readable .xlsx workbook.", e);
        }
        List<RowError> errors = new ArrayList<>(parsed.sheetErrors());
        List<BookRowMapper.Mapped> good = new ArrayList<>();

        // duplicates inside the file: the first row wins, later rows are errors that name the first one
        Map<String, Integer> firstRowOfIsbn = new HashMap<>();
        for (BookRowMapper.Mapped row : parsed.rows()) {
            errors.addAll(row.errors());
            if (!row.valid()) {
                continue;
            }
            Integer first = firstRowOfIsbn.putIfAbsent(row.book().isbn(), row.rowNumber());
            if (first != null) {
                errors.add(new RowError(row.rowNumber(), BookColumn.ISBN, "duplicate of row " + first + " in this file"));
            } else {
                good.add(row);
            }
        }

        // duplicates against the database
        Set<String> existing = repository.existingIsbns(good.stream().map(r -> r.book().isbn()).toList());
        List<BookRowMapper.Mapped> importable = new ArrayList<>();
        for (BookRowMapper.Mapped row : good) {
            if (existing.contains(row.book().isbn())) {
                errors.add(new RowError(row.rowNumber(), BookColumn.ISBN, "ISBN already in the catalogue"));
            } else {
                importable.add(row);
            }
        }
        errors.sort(java.util.Comparator.comparingInt(RowError::row));

        int read = parsed.rows().size();
        if (mode == ImportMode.ALL_OR_NOTHING && !errors.isEmpty()) {
            return new ImportReport(clientFileName, "REJECTED", read, 0, read, null, errors);
        }
        if (importable.isEmpty()) {
            return new ImportReport(clientFileName, errors.isEmpty() ? "EMPTY" : "REJECTED", read, 0,
                    read, null, errors);
        }

        List<BookRow> books = importable.stream().map(BookRowMapper.Mapped::book).collect(Collectors.toList());
        long batchId;
        try {
            batchId = transaction.execute(status -> {
                long id = repository.insertBatch(hash, clientFileName, books.size());
                repository.insertAll(id, books);
                return id;
            });
        } catch (DuplicateKeyException e) { // a concurrent upload won the race for the file hash; a clash on an ISBN is rethrown
            throw new AlreadyImportedException(repository.findBatchByHash(hash).orElseThrow(() -> e));
        }
        String status = errors.isEmpty() ? "IMPORTED" : "PARTIALLY_IMPORTED";
        return new ImportReport(clientFileName, status, read, books.size(), read - books.size(), batchId, errors);
    }

    static String sha256(Path file) throws IOException {
        try (InputStream in = Files.newInputStream(file)) {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] buffer = new byte[8192];
            int n;
            while ((n = in.read(buffer)) != -1) {
                digest.update(buffer, 0, n);
            }
            return HexFormat.of().formatHex(digest.digest());
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }
}

The two modes differ only in one line, and the difference is a product decision rather than a technical one:

Mode What happens with errors Use it when

ALL_OR_NOTHING

Any error (sheet, row or duplicate) rejects the file. Nothing is written, no batch is recorded, and the user fixes the file and uploads it again.

The rows depend on each other (a catalogue where totals must match, a price list that must be complete), or the sender cannot easily tell which rows were taken.

PARTIAL

Valid rows are written, invalid rows are reported. The batch records how many rows it imported.

Independent records, a large file where one typo should not block the other 4,000 rows, and a sender who can fix and re-upload only the failed rows.

The same file, the same code, the two modes (observed with the Spring Boot application, an in-memory H2 database and -Duser.language=en):

$ curl -s -w '\nHTTP %{http_code}\n' -F "file=@books-import.xlsx" http://localhost:8765/api/books/import
{"fileName":"books-import.xlsx","status":"REJECTED","rowsRead":8,"rowsImported":0,"rowsRejected":8,"batchId":null,"errors":[...]}
HTTP 422

$ curl -s -w '\nHTTP %{http_code}\n' -F "file=@books-import.xlsx" "http://localhost:8765/api/books/import?mode=PARTIAL"
{"fileName":"books-import.xlsx","status":"PARTIALLY_IMPORTED","rowsRead":8,"rowsImported":4,"rowsRejected":4,"batchId":1,"errors":[...]}
HTTP 200

$ curl -s -w '\nHTTP %{http_code}\n' -F "file=@books-import.xlsx" "http://localhost:8765/api/books/import?mode=PARTIAL"
{"detail":"This file was already imported as batch 1.","instance":"/api/books/import","status":409,"title":"Conflict","batchId":1}
HTTP 409

The errors array is elided in this listing and shown in full in the section on the JSON report. Eight rows were read (the blank row 8 was skipped), and in partial mode four rows (2, 3, 6 and 9) were written. A second upload of the same file is refused with the id of the first batch. In an ordinary application the hash check is the first thing that happens, so the repeat is answered without parsing the file again.

The transaction boundary

The transaction wraps only the writes: insert the batch row, then insert the books. It does not wrap the parse. That choice has reasons:

  • A parse can take seconds on a big sheet. A transaction that stays open during the parse holds a database connection and locks for no reason.

  • Nothing in the parse needs the database, except the key lookup, which is a separate read.

  • A failure during the write rolls back everything the import wrote, the batch row included, so no partial batch is visible and the same file can be retried.

For a very large import, a single transaction for hundreds of thousands of rows is itself a risk (log growth, lock duration, memory in the driver). The alternative is to commit per chunk, which is exactly what Spring Batch does for you with its chunk-oriented step, a restartable job and a persisted progress record; at that scale, run the import as a job and let the upload endpoint only store the file and start the job. The Spring Batch pages of this site, starting with Item Readers: Files, cover the reader side, and the Spring Batch page of this section covers spring-batch-excel.

The repository and the batch insert

package bookshelf.imports;

import java.sql.Date;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.jdbc.core.PreparedStatementCreator;

public class BookRepository {

    private static final int BATCH_SIZE = 500;

    private final JdbcTemplate jdbc;
    private final NamedParameterJdbcTemplate named;

    public BookRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
        this.named = new NamedParameterJdbcTemplate(jdbc);
    }

    public Optional<Long> findBatchByHash(String sha256) {
        List<Long> ids = jdbc.queryForList("select id from import_batch where file_sha256 = ?", Long.class, sha256);
        return ids.stream().findFirst();
    }

    /** The unique index on file_sha256 is the real guard against two concurrent uploads of the same file. */
    public long insertBatch(String sha256, String fileName, int rows) {
        KeyHolder keys = new GeneratedKeyHolder();
        PreparedStatementCreator creator = con -> {
            var ps = con.prepareStatement(
                    "insert into import_batch(file_sha256, file_name, rows_imported, imported_at) values (?, ?, ?, ?)",
                    new String[] {"id"});
            ps.setString(1, sha256);
            ps.setString(2, fileName);
            ps.setInt(3, rows);
            ps.setTimestamp(4, Timestamp.from(Instant.now()));
            return ps;
        };
        jdbc.update(creator, keys); // throws DuplicateKeyException when the file was imported before
        return keys.getKey().longValue();
    }

    /** ISBNs that are already in the table, looked up in chunks to keep the IN list short. */
    public Set<String> existingIsbns(Collection<String> isbns) {
        Set<String> found = new HashSet<>();
        List<String> all = new ArrayList<>(isbns);
        for (int from = 0; from < all.size(); from += BATCH_SIZE) {
            List<String> chunk = all.subList(from, Math.min(from + BATCH_SIZE, all.size()));
            found.addAll(named.queryForList("select isbn from book where isbn in (:isbns)",
                    new MapSqlParameterSource("isbns", chunk), String.class));
        }
        return found;
    }

    public void insertAll(long batchId, List<BookRow> books) {
        jdbc.batchUpdate(
                "insert into book(isbn, title, author, published_on, price, copies, batch_id) values (?, ?, ?, ?, ?, ?, ?)",
                books, BATCH_SIZE, (ps, book) -> {
                    ps.setString(1, book.isbn());
                    ps.setString(2, book.title());
                    ps.setString(3, book.author());
                    ps.setDate(4, Date.valueOf(book.publishedOn()));
                    ps.setBigDecimal(5, book.price());
                    ps.setInt(6, book.copies());
                    ps.setLong(7, batchId);
                });
    }
}

The schema it assumes:

create table import_batch (
    id            bigint generated always as identity primary key,
    file_sha256   char(64) not null unique,
    file_name     varchar(255) not null,
    rows_imported int not null,
    imported_at   timestamp not null
);

create table book (
    isbn         char(13) primary key,
    title        varchar(200) not null,
    author       varchar(120) not null,
    published_on date not null,
    price        decimal(8, 2) not null,
    copies       int not null,
    batch_id     bigint not null references import_batch (id)
);

Three details of the repository matter:

  • JdbcTemplate.batchUpdate with a batch size sends the inserts in groups of 500 (BATCH_SIZE) through one prepared statement. Batching removes a network round trip per row. Whether the driver really sends one multi-row statement depends on it: PostgreSQL needs reWriteBatchedInserts=true on the JDBC URL, and MySQL needs rewriteBatchedStatements=true. Measure before and after; the difference is large for remote databases.

  • existingIsbns queries in chunks. An IN (...) list with 20,000 parameters exceeds the parameter limit of several databases and hurts the query plan, so the lookup goes 500 keys at a time.

  • Everything is parameterised. A cell value is hostile input. A title such as Robert'); DROP TABLE book;-- is just a title to a prepared statement, and a catastrophe to a concatenated one. The same applies to JPA: with Hibernate set hibernate.jdbc.batch_size, and remember that an IDENTITY primary key disables JDBC batching for inserts, so a sequence or an assigned key (the ISBN here) is the right choice for bulk loads.

Source: Spring JDBC, batch operations and the PostgreSQL JDBC driver, connection parameters. The JPA alternative with saveAll and batch settings is on Spring Data JPA.

When a DuplicateKeyException still happens at write time (two imports of different files with the same ISBN raced past the pre-check), the service does not translate it into "already imported", because that would be false: it re-throws it unless the file hash is the cause. Map the remaining case to a 409 in the same exception handler, with a generic message.

Reading big files with SAX

The usermodel reader holds the sheet in memory. When the row cap has to go up to hundreds of thousands, replace only the reader. The mapper, the validator and the persistence stay the same, because the reader’s output is the same: for each row, the text of each column. That is why the mapper takes Map<BookColumn, String> and not a POI Row.

The event API reports each cell as text that has already been formatted by a DataFormatter that the handler calls. That is the problem the table above showed (a date comes back as 05/03/2019, an ISBN as 9.78E+12), so the reader supplies its own DataFormatter subclass that returns the canonical text instead of the display text.

package bookshelf.imports;

import java.math.BigDecimal;
import java.util.Locale;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.DateUtil;

/**
 * A DataFormatter for the event API that returns canonical text instead of display text: ISO dates for date formats,
 * plain decimals for numbers. The event handler calls formatRawCellContents for every numeric cell.
 */
public class CanonicalFormatter extends DataFormatter {

    public CanonicalFormatter() {
        super(Locale.ROOT);
    }

    @Override
    public String formatRawCellContents(double value, int formatIndex, String formatString) {
        if (DateUtil.isADateFormat(formatIndex, formatString)) {
            var dateTime = DateUtil.getLocalDateTime(value);
            return dateTime.toLocalTime().equals(java.time.LocalTime.MIDNIGHT)
                    ? dateTime.toLocalDate().toString() : dateTime.toString();
        }
        return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
    }
}

XSSFSheetXMLHandler calls formatRawCellContents(double, int, String) for every numeric cell, with the style’s format index and format string. DateUtil.isADateFormat(int, String) is the same date test that the usermodel path makes through the cell. The override gives ISO dates and plain decimals, so both readers agree.

package bookshelf.imports;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.xml.parsers.ParserConfigurationException;
import org.apache.poi.openxml4j.exceptions.OpenXML4JException;
import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.openxml4j.opc.PackageAccess;
import org.apache.poi.ss.util.CellReference;
import org.apache.poi.util.XMLHelper;
import org.apache.poi.xssf.eventusermodel.ReadOnlySharedStringsTable;
import org.apache.poi.xssf.eventusermodel.XSSFReader;
import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler;
import org.apache.poi.xssf.model.SharedStrings;
import org.apache.poi.xssf.model.Styles;
import org.apache.poi.xssf.usermodel.XSSFComment;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;

/** Event reader for big files: same header mapping and same BookRowMapper, but only formatted text per cell. */
public final class SaxBookSheetReader {

    private final BookRowMapper mapper;

    public SaxBookSheetReader(BookRowMapper mapper) {
        this.mapper = mapper;
    }

    public ParsedSheet read(Path file) throws IOException, OpenXML4JException, SAXException, ParserConfigurationException {
        try (OPCPackage pkg = OPCPackage.open(file.toFile(), PackageAccess.READ)) {
            XSSFReader reader = new XSSFReader(pkg);
            SharedStrings strings = new ReadOnlySharedStringsTable(pkg);
            Styles styles = reader.getStylesTable();
            Collector collector = new Collector();
            XMLReader xml = XMLHelper.newXMLReader();
            xml.setContentHandler(new XSSFSheetXMLHandler(styles, null, strings, collector, new CanonicalFormatter(), false));
            XSSFReader.SheetIterator sheets = (XSSFReader.SheetIterator) reader.getSheetsData();
            if (!sheets.hasNext()) {
                return new ParsedSheet(List.of(), List.of(RowError.ofRow(1, "The workbook has no sheets.")), 0);
            }
            try (InputStream first = sheets.next()) { // only the first sheet
                xml.parse(new InputSource(first));
            }
            return collector.result();
        }
    }

    private final class Collector implements XSSFSheetXMLHandler.SheetContentsHandler {
        private final Map<Integer, BookColumn> columnByIndex = new HashMap<>();
        private final List<RowError> sheetErrors = new ArrayList<>();
        private final List<BookRowMapper.Mapped> rows = new ArrayList<>();
        private Map<Integer, String> current = new HashMap<>();
        private boolean headerSeen;
        private int blank;

        @Override
        public void startRow(int rowNum) {
            current = new HashMap<>();
        }

        @Override
        public void cell(String cellReference, String formattedValue, XSSFComment comment) {
            if (cellReference != null && formattedValue != null && !formattedValue.isBlank()) {
                current.put(new CellReference(cellReference).getCol() & 0xFFFF, formattedValue.strip());
            }
        }

        @Override
        public void endRow(int rowNum) {
            if (!headerSeen) {
                headerSeen = true;
                current.forEach((index, header) -> BookColumn.forHeader(header).ifPresent(c -> columnByIndex.put(index, c)));
                for (BookColumn column : BookColumn.values()) {
                    if (!columnByIndex.containsValue(column)) {
                        sheetErrors.add(RowError.ofRow(rowNum + 1, "Missing column '" + column.header() + "'."));
                    }
                }
                return;
            }
            if (!sheetErrors.isEmpty()) {
                return;
            }
            if (current.isEmpty()) {
                blank++;
                return;
            }
            Map<BookColumn, String> text = new EnumMap<>(BookColumn.class);
            List<RowError> cellErrors = new ArrayList<>();
            current.forEach((index, value) -> {
                BookColumn column = columnByIndex.get(index);
                if (column == null) {
                    return;
                }
                if (value.startsWith("ERROR:")) { // the handler reports error cells (#DIV/0!) like this
                    cellErrors.add(new RowError(rowNum + 1, column, "the cell contains the error " + value.substring(6)));
                } else {
                    text.put(column, value);
                }
            });
            rows.add(mapper.map(rowNum + 1, text, cellErrors));
        }

        ParsedSheet result() {
            return new ParsedSheet(rows, sheetErrors, blank);
        }
    }
}

What to notice in the SAX reader:

  • It reads only the first sheet, with reader.getSheetsData(), and closes each stream.

  • It builds the same header map, from the first row, and the same missing-column errors.

  • Error cells arrive as text starting with ERROR: (the handler prints them that way), and are turned into the same RowError as in the usermodel reader.

  • A row with no cells is counted as blank; the handler skips cells that are truly absent, so the "blank" rule is the same.

  • It uses XMLHelper.newXMLReader(), POI’s hardened SAX factory that refuses a DOCTYPE, instead of SAXParserFactory.newInstance(). See the security page.

  • It has no random access, so it cannot follow a merged region: a merged author is blank in the second row and fails validation. Observed, with the same file and the same mapper:

Row Usermodel reader SAX reader

2, 4, 5, 6, 7, 9, 10

same values, same errors

same values, same errors

3

author=Ana Moreno (merged region), no error

author=null, error "Author: must not be blank"

If merged cells matter, ask the users not to merge (put the value in every row) or keep the usermodel reader with a lower cap. The decision is about the template, and the template is something you control.

Source: XSSFSheetXMLHandler Javadoc, XSSFReader Javadoc and the official example XLSX2CSV. The callbacks, the shared strings table and the options are described in Streaming Reads: Event & SAX APIs.

Returning an error report

A file that fails validation is a normal outcome, not an exception, so the response is a report, built from the same list of RowError in two representations: JSON for programs and an annotated workbook for people.

The JSON report

package bookshelf.imports;

import java.util.List;

/** What the client gets back, as JSON or as the annotated workbook. */
public record ImportReport(String fileName, String status, int rowsRead, int rowsImported, int rowsRejected,
                           Long batchId, List<RowError> errors) {
}

The JSON is the one a front end renders. The 422 response of the first call above, pretty-printed (observed):

{
  "fileName": "books-import.xlsx",
  "status": "REJECTED",
  "rowsRead": 8,
  "rowsImported": 0,
  "rowsRejected": 8,
  "batchId": null,
  "errors": [
    { "row": 4, "column": "ISBN", "message": "must be exactly 13 digits" },
    { "row": 4, "column": "TITLE", "message": "must not be blank" },
    { "row": 4, "column": "PUBLISHED_ON", "message": "must be a date in the past or in the present" },
    { "row": 4, "column": "PRICE", "message": "must be greater than or equal to 0.00" },
    { "row": 4, "column": "COPIES", "message": "'2.5' is not a whole number" },
    { "row": 5, "column": "PUBLISHED_ON", "message": "'last spring' is not a date (use a real Excel date or yyyy-MM-dd)" },
    { "row": 5, "column": "PRICE", "message": "'abc' is not a number" },
    { "row": 7, "column": "ISBN", "message": "duplicate of row 2 in this file" },
    { "row": 10, "column": "PRICE", "message": "the cell contains the error +#DIV/0!+" }
  ]
}

Design rules for the report that the code follows:

  • One list, one entry per cell problem, sorted by row, so a client can group by row or by column.

  • The row is Excel’s number. A person looks for row 7, so the first row of data is 2.

  • column is a stable identifier (the enum name), not the header text, so a client can map it to its own label. A row-level or sheet-level problem has "column": null.

  • No stack traces, no file paths, no parser messages. The unreadable-workbook case returns a fixed sentence and logs the cause. A POI message can contain part names and fragments of the document.

  • Cap the number of errors if the file can be hostile or huge: a 20,000-row file in which every cell is wrong would return 120,000 entries. Return the first N and the total count.

  • Use the problem-details media type for failures that are not about the content (409, 413, 422 for an unreadable file): Spring’s ProblemDetail produces application/problem+json, as the 409 and 422 responses above show.

Outcome Status Body

All rows imported

200

ImportReport with status IMPORTED and the batchId.

Some rows imported (partial mode)

200

ImportReport with status PARTIALLY_IMPORTED, the batchId and the errors of the rejected rows.

All-or-nothing, or nothing valid

422

ImportReport with status REJECTED and every error.

Same file again

409

Problem details with the batchId of the first import.

Not a readable .xlsx

422

Problem details with a fixed message.

Too large

413

From the container, before the controller runs.

No file part

400

Problem details.

The annotated workbook

Programs read JSON; the person who sent the file wants their file back, with the problems marked. That is the most useful thing an import can return: the sender sees each problem in its cell, fixes it in Excel and uploads the same file again. The writer opens the uploaded workbook, fills the offending cells red, puts a comment with the message on each cell and adds an Import errors column with all the messages of the row.

The uploaded workbook annotated with an Import errors column, red cells and comments
package bookshelf.imports;

import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.ClientAnchor;
import org.apache.poi.ss.usermodel.Comment;
import org.apache.poi.ss.usermodel.CreationHelper;
import org.apache.poi.ss.usermodel.Drawing;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

/** Writes an annotated copy of the uploaded workbook: red cells, a comment on each, and an "Import errors" column. */
public final class ErrorWorkbookWriter {

    public static final String ERRORS_HEADER = "Import errors";

    public void write(Path uploaded, List<RowError> errors, OutputStream out)
            throws IOException, InvalidFormatException {
        // XSSFWorkbook(File) saves its changes back into that file on close(), so work on a private copy
        Path copy = Files.createTempFile(uploaded.getParent(), "annotated-", ".xlsx");
        try {
            Files.copy(uploaded, copy, StandardCopyOption.REPLACE_EXISTING);
            annotate(copy, errors, out);
        } finally {
            Files.deleteIfExists(copy);
        }
    }

    private void annotate(Path copy, List<RowError> errors, OutputStream out)
            throws IOException, InvalidFormatException {
        try (XSSFWorkbook workbook = new XSSFWorkbook(copy.toFile())) {
            Sheet sheet = workbook.getSheetAt(0);
            CreationHelper factory = workbook.getCreationHelper();
            Drawing<?> drawing = sheet.createDrawingPatriarch();
            Row header = sheet.getRow(sheet.getFirstRowNum());
            int errorColumn = Math.max(header.getLastCellNum(), 0);

            Map<Integer, BookColumn> headerColumns = new HashMap<>();
            for (Cell cell : header) {
                BookColumn.forHeader(cell.getStringCellValue()).ifPresent(c -> headerColumns.put(cell.getColumnIndex(), c));
            }
            Map<BookColumn, Integer> indexOf = new HashMap<>();
            headerColumns.forEach((index, column) -> indexOf.put(column, index));

            Font bold = workbook.createFont();
            bold.setBold(true);
            CellStyle headerStyle = workbook.createCellStyle();
            headerStyle.cloneStyleFrom(header.getCell(0).getCellStyle());
            headerStyle.setFont(bold);
            Cell errorHeader = header.createCell(errorColumn);
            errorHeader.setCellValue(ERRORS_HEADER);
            errorHeader.setCellStyle(headerStyle);

            CellStyle wrap = workbook.createCellStyle();
            wrap.setWrapText(true);
            Map<Short, CellStyle> redByOriginal = new HashMap<>(); // one new style per original style, not per cell

            Map<Integer, List<RowError>> byRow = errors.stream().collect(
                    Collectors.groupingBy(RowError::row, TreeMap::new, Collectors.toList()));
            for (Map.Entry<Integer, List<RowError>> entry : byRow.entrySet()) {
                int rowIndex = entry.getKey() - 1;
                Row row = sheet.getRow(rowIndex);
                if (row == null) {
                    row = sheet.createRow(rowIndex);
                }
                Cell summary = row.createCell(errorColumn);
                summary.setCellValue(entry.getValue().stream()
                        .map(e -> (e.column() == null ? "" : e.column().header() + ": ") + e.message())
                        .collect(Collectors.joining("\n")));
                summary.setCellStyle(wrap);

                for (RowError error : entry.getValue()) {
                    Integer columnIndex = error.column() == null ? null : indexOf.get(error.column());
                    if (columnIndex == null) {
                        continue; // a row-level or sheet-level message lives only in the Import errors column
                    }
                    Cell cell = row.getCell(columnIndex);
                    if (cell == null) {
                        cell = row.createCell(columnIndex);
                    }
                    CellStyle original = cell.getCellStyle();
                    cell.setCellStyle(redByOriginal.computeIfAbsent(original.getIndex(), key -> {
                        CellStyle red = workbook.createCellStyle();
                        red.cloneStyleFrom(original);
                        red.setFillForegroundColor(IndexedColors.ROSE.getIndex());
                        red.setFillPattern(FillPatternType.SOLID_FOREGROUND);
                        return red;
                    }));
                    if (cell.getCellComment() != null) {
                        cell.removeCellComment(); // the upload already had a comment here; only one is allowed per cell
                    }
                    ClientAnchor anchor = factory.createClientAnchor();
                    anchor.setCol1(columnIndex); // in XSSF the anchor's first corner is also the cell that owns the comment
                    anchor.setCol2(columnIndex + 3);
                    anchor.setRow1(rowIndex);
                    anchor.setRow2(rowIndex + 3);
                    Comment comment = drawing.createCellComment(anchor);
                    comment.setString(factory.createRichTextString(error.message()));
                    comment.setAuthor("Bookshelf import");
                    cell.setCellComment(comment);
                }
            }
            sheet.setColumnWidth(errorColumn, 60 * 256);
            workbook.write(out);
        }
    }
}

The figure above is this class’s output for the example file in partial mode (the figure draws the first sheet; the numbers and messages are the ones of the run). The mechanics are what make it work, and each of them is a place where a first attempt goes wrong.

Mechanic Why it is written this way

Work on a copy of the upload

new XSSFWorkbook(File) opens the package for writing, and closing the workbook saves the package back into that file. Observed: the first version of this class opened the uploaded temp file directly, and after one request the "original" already contained the annotations, so the next request failed because the cells already had comments. Opening with PackageAccess.READ is no fix, because adding parts to a read-only package throws. The writer copies the upload to a private file, annotates the copy, writes the result to the output stream and deletes the copy. The original stays byte-for-byte what the user sent, which also keeps the file hash valid.

sheet.createDrawingPatriarch()

Comments live in a drawing. For .xlsx, createDrawingPatriarch() returns the existing drawing or creates one. For .xls (HSSF), calling it on a sheet that already has shapes replaces the drawing and loses them, as the comments page explains.

ClientAnchor with setCol1/setRow1 on the cell itself

In XSSF the first corner of the anchor is also the cell that owns the comment. The first attempt anchored the box at the next column, and POI answered IllegalArgumentException: Multiple cell comments in one cell are not allowed for the neighbouring cell. The second corner (+3 columns and rows here) only sets the size of the box.

cell.removeCellComment() before adding one

A cell can hold one comment. A file that already has comments (the sender added a note) would fail otherwise, so the writer replaces it.

A style cache keyed by the original style index

Each red cell needs a fill, but a workbook is limited to 64,000 cell styles and every createCellStyle adds one. cloneStyleFrom keeps the date format and alignment of the original cell, and the cache creates one red style per original style instead of one per cell. The rule is on Cell Styles, Fonts & Formats.

setWrapText(true) on the summary cell

The messages of a row are joined with newlines in one cell; wrapping shows them all. Row height grows when the user opens the file in Excel; POI does not compute it.

A row-level or sheet-level error has no column

It appears only in the Import errors column, and the red fill is applied only when the error has a cell.

The endpoint returns it when asked for format=xlsx and at least one row was read. Observed (headers only; the body is the workbook of the figure):

$ curl -s -D - -F "file=@books-import.xlsx" "http://localhost:8765/api/books/import?mode=PARTIAL&format=xlsx" -o books-import-errors.xlsx
HTTP/1.1 200
Content-Disposition: attachment; filename="books-import-errors.xlsx"
X-Import-Status: PARTIALLY_IMPORTED
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Length: 6314

The comments1.xml part of that file contains one comment element per failed cell, for example:

<comment ref="A4" authorId="1"><text><t>must be exactly 13 digits</t></text></comment>
<comment ref="B4" authorId="1"><text><t>must not be blank</t></text></comment>
<comment ref="D4" authorId="1"><text><t>must be a date in the past or in the present</t></text></comment>

Source: POI quick guide, cell comments, Comment, ClientAnchor and Drawing Javadoc. Comments, drawings and anchors are covered in detail on Images, Charts, Comments & Hyperlinks.

Two cautions about the annotated copy:

  • It is a usermodel operation, so it is bounded. Annotating a 500,000-row upload with XSSFWorkbook needs the heap the SAX reader was chosen to avoid. For big files, return a new workbook with only the failed rows and their messages, written with SXSSF (see Streaming Writes with SXSSF), plus the original row number in the first column.

  • The messages contain text from the file ('last spring' is not a date). They are written with setCellValue(String), so they are text and never formulas, and the fixed prefix of each message means a cell never starts with =, +, - or @. If a message could start with user text, sanitise it as in the formula-injection section of the security page before it is exported to CSV or opened by a tool that treats text as formulas.

Putting it together

The checklist for a new import endpoint, in the order of the pipeline:

  1. Limit the request in the framework and in the proxy (max-file-size, max-body-size, @MultipartConfig).

  2. Store the part in a private temporary file with a name you chose, and delete it in a finally block.

  3. Validate the type by content, the zip structure and macros (Security & Untrusted Files).

  4. Hash the file; refuse a repeat.

  5. Open read-only with WorkbookFactory.create(file, null, true), or use the SAX reader above the row cap.

  6. Map the header by name; report missing or duplicate columns once.

  7. Coerce each cell by type to canonical text; handle blank, merged, formula and error cells.

  8. Convert and validate each row; report every problem with the Excel row and column.

  9. Detect duplicates in the file and against the database.

  10. Decide the mode; write the valid rows in one transaction, in batches, with parameters.

  11. Report: JSON, and the annotated workbook when asked.

Observed on the unreadable case of the same application:

$ echo "not a workbook" > fake.xlsx
$ curl -s -w '\nHTTP %{http_code}\n' -F "file=@fake.xlsx" http://localhost:8765/api/books/import
{"detail":"The file is not a readable .xlsx workbook.","instance":"/api/books/import","status":422,"title":"Unprocessable Content"}
HTTP 422

The service itself is ordinary Java, so the best tests are plain unit tests that build a small workbook in memory with XSSFWorkbook, write it to a temporary file and call BookSheetReader.read and BookImportService.importFile against an in-memory database. Build one row per rule (a valid row, a blank ISBN, a text date, a formula, an error cell, a merged cell, a duplicate) and assert the row numbers and columns of the errors, as the observed table of this page does. The controller needs only one test per status code of the table above.

Pitfalls

  • Zero-based row numbers in messages. POI rows are zero-based; Excel shows one-based numbers. Add 1 once, in one place.

  • Using getLastRowNum as the record count. Blank rows at the end are common.

  • Reading by position instead of by name. One inserted column silently shifts every value into the wrong field.

  • DataFormatter for data. It returns display text, which changes with the cell format and the locale.

  • getNumericCellValue for identifiers. A number loses leading zeros and, beyond 15 digits, precision. Make the column text.

  • Primitives in the DTO. An int hides a missing cell as 0. Use wrappers and @NotNull.

  • Validating before coercing, or reporting both. Two messages for one cell confuse the sender.

  • Opening an upload with new XSSFWorkbook(File) and then closing it. The close writes back into the file. Use WorkbookFactory.create(file, null, true) to read, and annotate a copy.

  • Holding the transaction during the parse. Open it around the writes only.

  • Recording the idempotency key before the work succeeds. A failed import must be retryable.

  • Trusting the file name or content type. Both are chosen by the client.

  • Returning the parser’s exception message. It leaks part names and paths.

  • No row cap, or trusting it to bound the parse. A small file can declare a sheet of a million rows. Cap rows, columns and errors, but remember that the usermodel reader checks the cap after the sheet is loaded: the byte and zip limits bound the parse, and the cap bounds only the work after it. To cap before loading, read <dimension> or use SAX.

  • Leaving the validator locale to the machine. The messages of one run came back in two languages.

What POI does not provide here

  • No object-mapping layer. POI has no "sheet to bean" API. The header mapping, coercion and validation on this page are written by hand; community libraries exist, and spring-batch-excel provides readers for Spring Batch.

  • No validation framework. Jakarta Validation is the validation; POI only reads cells. The data validation rules of the workbook itself (dropdowns, ranges) are not enforced when reading: a cell can hold a value the list does not allow.

  • No upload handling. The multipart limits, the temp files and the HTTP status codes belong to the web framework.

  • No transaction or idempotency support. They belong to the database layer and to your design.

  • No guarantee about hostile files. The security page lists what POI protects and what it does not.

Changes between versions

Version Relevant change

4.x

The CellType enum replaced the Cell.CELL_TYPE_* int constants, which were removed in 4.0. Code written against 3.x with those constants does not compile on 4.x or later.

5.x

POI logs through the Log4j 2 API. Parsing goes through the hardened XMLHelper. Security limits (ZipSecureFile, IOUtils) were tightened across 5.x, and from 5.4.0 duplicate zip entry names are refused. WorkbookFactory.create(File, String, boolean) is the call used on this page.

5.5.1 (as of 2026-10-09)

The latest release, used to compile every example here.

6.0 (planned, not released as of 2026-10-09)

Announced to remove APIs deprecated in 5.x and to change how DataFormatter treats cached formula values. Re-test the coercion and the SAX formatter against the first milestone; nothing on this page depends on a removed API.

Spring Boot 4 and Spring Framework 7 keep the spring.servlet.multipart.* properties used above. Hibernate Validator 9 implements Jakarta Validation 3.1, with the jakarta package names already used here.

References