Batch Import & Export with Spring Batch
|
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 has not been released), spring-batch-excel 0.3.0 (the latest release on that date), Spring Boot 4.1.1 with the Spring Batch 6.0.5 it manages, Java 21 and later. Every Java class shown here was compiled against exactly those versions and run in a throwaway Spring Boot application; the outputs marked as observed come from those runs.
Two jobs of the Bookshelf scenario need a batch tool instead of a web request. The nightly catalogue load reads
books-import.xlsx (thousands of rows, delivered by a supplier) into the book table. The monthly loans-report.xlsx
export writes every loan to a workbook and is far too large to build in one request. Spring Batch gives both jobs
chunking, transactions, skip, restart and an audit trail; this page shows how to connect it to Excel with Apache POI.
The general reader and writer model is in ItemReaders: files &
XML/JSON and ItemWriters: files & XML/JSON; this page covers only
what is specific to Excel.
stored on disk"] --> R1["PoiItemReader
or StreamingXlsxItemReader"] R1 --> M1["RowMapper
String[] to BookRow"] M1 --> W1["JdbcBatchItemWriter"] W1 --> D1[("book table")] end subgraph Export direction LR D2[("loan table")] --> R2["JdbcCursorItemReader"] R2 --> W2["SxssfItemWriter
custom, this page"] W2 --> X2["loans-report.xlsx"] end
The tools and their status
Apache POI itself has no Spring Batch support. For reading there is a small extension; for writing there is nothing, and the export half of this page is a custom writer.
| Item | Status on 2026-10-09 |
|---|---|
Coordinates |
|
Who maintains it |
It lives in the |
Release cadence |
0.2.0 was published on 2025-12-03 and 0.3.0 on 2026-06-17. The latest commit touching the module on 2026-10-09 is a one-word text fix from 2026-07-25. |
Version line |
It is a 0.x library, so its API can change between minor versions. The Spring Boot dependency management does not manage it, so the version is always explicit. |
Spring Batch it needs |
0.2.0 depends on Spring Batch 5.2.3. 0.3.0 depends on Spring Batch 6.0.4 and imports the Spring Batch 6 packages ( |
Apache POI |
|
What it provides |
Readers only. There is no writer, no builder class and no sheet selection. |
Source: spring-batch-excel README and Maven Central metadata
The classes in the 0.3.0 jar (checked by listing the jar) are:
Package org.springframework.batch.extensions.excel |
Classes |
|---|---|
(root) |
|
|
|
|
|
|
|
|
|
The README on the main branch also describes a StreamingXlsItemReader for legacy .xls files. It was added on
2026-06-28, after 0.3.0 was released, and it is not in the 0.3.0 jar. Until a release contains it, a streaming read of an
.xls file is not available from this library; use the HSSF event model described in
Streaming Reads: Event & SAX APIs or convert the file.
Dependencies
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-batch-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.5.1</version>
</dependency>
<dependency>
<groupId>org.springframework.batch.extensions</groupId>
<artifactId>spring-batch-excel</artifactId>
<version>0.3.0</version>
</dependency>
</dependencies>
dependencies {
implementation("org.springframework.boot:spring-boot-starter-batch-jdbc")
implementation("org.apache.poi:poi-ooxml:5.5.1")
implementation("org.springframework.batch.extensions:spring-batch-excel:0.3.0")
}
Source: spring-batch-excel README and POI components. The POI side of the setup (lite versus full jars, logging) is in Setup & Dependencies.
Reading with PoiItemReader
PoiItemReader is an ordinary ItemStreamReader (it extends Spring Batch’s AbstractItemCountingItemStreamItemReader)
that walks a workbook sheet by sheet and row by row. Reading the source of 0.3.0 and running it shows how it behaves:
-
Opening. For a resource that is a file it calls
WorkbookFactory.create(file, password, true), a read-only open that does not copy the file into memory first. For any other resource (a classpath resource, a stream) it opens anInputStream, which POI has to buffer completely. The workbook usesMissingCellPolicy.CREATE_NULL_AS_BLANK. -
Every sheet is read. There is no sheet selection.
linesToSkipis applied to the start of each sheet. -
Blank rows are skipped. A row whose cells are all empty is not handed to the mapper, and a missing row is skipped too.
-
Every cell becomes a String. The reader formats each cell with a POI
DataFormatter(see below) and theRowMapperreceives aString[]. -
One thread. The class is not thread-safe, as the README states; the POI usermodel is not either.
-
The whole workbook is in memory. It is the usermodel, so memory grows with the number of cells.
Properties
| Property | Default | Meaning |
|---|---|---|
|
none, required |
The |
|
none, required |
Maps the current row to an item. See the next section. |
|
0 |
Rows to skip at the start of every sheet. Use 1 when the first row is a header. |
|
none |
A |
|
1 |
Despite the name, this is about |
|
|
Creates the |
|
none |
Password of an encrypted file. The README says it works only for |
|
JVM default |
Locale of the |
|
|
Formats dates as ISO text instead of the cell’s format. See the table below; it has sharp edges. |
|
|
Callback to configure the |
|
|
Supplies a |
|
inherited |
Standard Spring Batch properties. |
What the reader hands to the mapper
The mapper sees text, exactly what Excel would display, because PoiSheet calls DataFormatter.formatCellValue for
every cell. That is convenient for text columns and a trap for numbers and dates, because the string depends on the
cell format, not only on the value. The table shows what 0.3.0 produced for one row of cells written with POI 5.5.1 and
read with Locale.US (observed):
| Cell value and format | PoiItemReader |
StreamingXlsxItemReader |
Remark |
|---|---|---|---|
date 2024-03-14, built-in format 14 |
|
|
Format 14 is the locale short date. |
date 2024-03-14, custom |
|
|
The mapper must know the pattern. |
date 2024-03-14, custom |
|
|
Unambiguous; ask suppliers for this format. |
1234.5, format |
|
|
The grouping separator is part of the text. |
12.345, format |
|
|
The text is rounded to the cell format; the exact value is lost. |
integer 7, format General |
|
|
|
a formula that has no cached value |
|
(empty) |
Files written by POI have no cached results; files saved by Excel do. |
boolean |
|
|
The same price cell read on a machine whose default locale is Spanish gave 1.201,50 (observed), which a
BigDecimal conversion rejects. Always call setUserLocale.
datesAsIso(true) was meant to remove the date ambiguity. In 0.3.0 with POI 5.5.1 it behaves like this (observed):
| Cell | PoiItemReader |
StreamingXlsxItemReader |
|---|---|---|
date, built-in format 14 |
|
|
date, custom format |
|
|
a row that contains a boolean cell |
|
reads normally |
Because of the boolean failure and the streaming result, the examples on this page leave datesAsIso off and rely on a
fixed yyyy-mm-dd cell format in the template that suppliers fill in. More about DataFormatter, DateUtil and
format codes is in Dates, Numbers & DataFormatter.
Mapping rows
public interface RowMapper<T> {
T mapRow(RowSet rs) throws Exception;
}
A RowSet is positioned on one row. The calls a mapper uses are:
| Call | Returns |
|---|---|
|
The row as |
|
A |
|
The zero-based count of the rows read from the sheet, header included. A row that does not exist in the file is not counted, so after a gap the index is smaller than the Excel row number minus one (observed: a bad row on Excel row 4 after a missing row 3 was reported with index 2). |
|
The name of the sheet being read. |
|
The column names from the |
Column names
The DefaultRowSetFactory asks a ColumnNameExtractor for the names. RowNumberColumnNameExtractor is the default: it
takes the values of row 0 (change it with setHeaderRowNumber). StaticColumnNameExtractor takes a fixed array. The
header row is still an ordinary row, so combine the default extractor with linesToSkip 1, otherwise the header is
passed to the mapper as data.
Two ready mappers exist. PassThroughRowMapper returns the String[] unchanged. BeanWrapperRowMapper binds the
properties to a bean with a Spring DataBinder, using these settings:
| Property | Effect |
|---|---|
|
Exactly one of them is required: a class instantiated with its default constructor for every row, or the name of a prototype-scoped bean. The target must be a mutable bean with setters, so a Java |
|
How different a column name may be from a property name and still match. The default of 5 is fuzzy: |
|
Default |
|
A map of type to |
A complete import job
The target bean has the six columns of books-import.xlsx:
package com.bookshelf.batch;
import java.math.BigDecimal;
import java.time.LocalDate;
/** One row of books-import.xlsx. A bean (default constructor and setters) because BeanWrapperRowMapper binds by property. */
public class BookRow {
private String isbn;
private String title;
private String author;
private int copies;
private BigDecimal price;
private LocalDate published;
public String getIsbn() { return isbn; }
public void setIsbn(String isbn) { this.isbn = isbn; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
public int getCopies() { return copies; }
public void setCopies(int copies) { this.copies = copies; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public LocalDate getPublished() { return published; }
public void setPublished(LocalDate published) { this.published = published; }
}
The configuration builds the reader, the mapper, the writer and a fault-tolerant step that skips rows that cannot be mapped:
package com.bookshelf.batch;
import java.beans.PropertyEditor;
import java.beans.PropertyEditorSupport;
import java.math.BigDecimal;
import java.text.NumberFormat;
import java.time.LocalDate;
import java.util.Locale;
import java.util.Map;
import javax.sql.DataSource;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.Step;
import org.springframework.batch.core.step.builder.ChunkOrientedStepBuilder;
import org.springframework.batch.extensions.excel.ExcelFileParseException;
import org.springframework.batch.extensions.excel.mapping.BeanWrapperRowMapper;
import org.springframework.batch.extensions.excel.poi.PoiItemReader;
import org.springframework.batch.infrastructure.item.database.JdbcBatchItemWriter;
import org.springframework.batch.infrastructure.item.database.builder.JdbcBatchItemWriterBuilder;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.batch.infrastructure.item.ItemReader;
import org.springframework.beans.propertyeditors.CustomNumberEditor;
import org.springframework.core.io.FileSystemResource;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class BooksImportJobConfig {
@Bean
public BeanWrapperRowMapper<BookRow> bookRowMapper() {
var mapper = new BeanWrapperRowMapper<BookRow>();
mapper.setTargetType(BookRow.class);
mapper.setDistanceLimit(0); // exact names only: no fuzzy matching of headers
mapper.setCustomEditors(Map.<Class<?>, PropertyEditor>of(
// "1,201.50" -> 1201.50; US symbols because the reader is told to format with Locale.US
BigDecimal.class, new CustomNumberEditor(BigDecimal.class, NumberFormat.getInstance(Locale.US), true),
LocalDate.class, new PropertyEditorSupport() {
@Override
public void setAsText(String text) {
setValue(text == null || text.isBlank() ? null : LocalDate.parse(text)); // 2024-03-14
}
}));
return mapper;
}
@Bean
@StepScope
@Profile("!streaming")
public PoiItemReader<BookRow> booksImportReader(
@Value("#{jobParameters['input.file']}") String inputFile,
BeanWrapperRowMapper<BookRow> bookRowMapper) {
var reader = new PoiItemReader<BookRow>();
reader.setName("booksImportReader");
reader.setResource(new FileSystemResource(inputFile));
reader.setLinesToSkip(1); // the header row is not data
reader.setUserLocale(Locale.US); // the DataFormatter uses this locale, not the server's default
reader.setRowMapper(bookRowMapper);
return reader;
}
@Bean
public JdbcBatchItemWriter<BookRow> booksImportWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<BookRow>()
.dataSource(dataSource)
.sql("INSERT INTO book (isbn, title, author, copies, price, published) "
+ "VALUES (:isbn, :title, :author, :copies, :price, :published)")
.beanMapped()
.build();
}
@Bean
public Step booksImportStep(JobRepository jobRepository, PlatformTransactionManager transactionManager,
ItemReader<BookRow> booksImportReader,
JdbcBatchItemWriter<BookRow> booksImportWriter) {
return new ChunkOrientedStepBuilder<BookRow, BookRow>("booksImportStep", jobRepository, 100)
.reader(booksImportReader)
.writer(booksImportWriter)
.transactionManager(transactionManager)
.faultTolerant()
.skip(ExcelFileParseException.class) // a row that cannot be mapped is skipped...
.skipLimit(10) // ...up to ten times, then the step fails
.listener(new ImportReportListener())
.build();
}
@Bean
public Job booksImportJob(JobRepository jobRepository, Step booksImportStep) {
return new JobBuilder("booksImportJob", jobRepository).start(booksImportStep).build();
}
}
Notes on this configuration:
-
ExcelFileParseExceptionis what the reader throws when the mapper fails. It carries the file name, the sheet name, the row index and the row text, which is what makes a useful error report possible. Exceptions thrown while POI itself iterates a sheet are not wrapped in it, so they are not skippable by this rule. -
The
BigDecimaleditor uses aNumberFormatforLocale.USso that1,201.50converts; theLocalDateeditor expects theyyyy-mm-ddtemplate format.CustomNumberEditorwithallowEmptyset totrueturns an empty cell intonull. -
setNamegives the keys saved in the step’sExecutionContexta prefix (observed:booksImportReader.read.count). -
The reader is
@StepScopeso that the file name can come from a job parameter. See Jobs, instances & parameters. -
The step uses the 6.0
ChunkOrientedStepBuilder. The olderStepBuilder.chunk(size, transactionManager)form is described in Chunk-oriented processing.
Reporting skipped rows
A skip listener turns the exception data into a message a person can act on. getRowNumber() + 1 is the row number that
Excel shows as long as the sheet has no gaps, because the index is zero-based, counts the header and does not count rows
that are absent from the file:
package com.bookshelf.batch;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.listener.SkipListener;
import org.springframework.batch.core.listener.StepExecutionListener;
import org.springframework.batch.core.step.StepExecution;
import org.springframework.batch.extensions.excel.ExcelFileParseException;
import org.springframework.validation.BindException;
import java.util.stream.Collectors;
/** Reports which rows were skipped and why, with the sheet and row number the reader recorded. */
public class ImportReportListener implements SkipListener<BookRow, BookRow>, StepExecutionListener {
private static final Logger log = LoggerFactory.getLogger(ImportReportListener.class);
@Override
public void onSkipInRead(Throwable t) {
if (t instanceof ExcelFileParseException ex) {
// getRowNumber() counts the rows read, header included, from zero; +1 is the row Excel shows when the sheet has no gaps
log.warn("Skipped {} row {}: {}", ex.getSheet(), ex.getRowNumber() + 1, reason(ex));
}
}
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
log.info("{}: read={} written={} skipped={}", stepExecution.getStepName(), stepExecution.getReadCount(),
stepExecution.getWriteCount(), stepExecution.getSkipCount());
return null;
}
private static String reason(Throwable t) {
while (t.getCause() != null && !(t instanceof BindException)) {
t = t.getCause();
}
if (t instanceof BindException bind) {
return bind.getFieldErrors().stream()
.map(e -> e.getField() + " = '" + e.getRejectedValue() + "'")
.collect(Collectors.joining(", "));
}
return t.getMessage();
}
}
Running the job against a six-row file in which row 4 has an empty Copies cell and row 6 has the text many there
(observed output, shortened):
java -jar bookshelf-batch-1.0.0.jar --spring.batch.job.name=booksImportJob input.file=/data/books-import.xlsx
Executing step: [booksImportStep]
Skipped Books row 4: copies = ''
Skipped Books row 6: copies = 'many'
booksImportStep: read=4 written=4 skipped=2
Job: [SimpleJob: [name=booksImportJob]] completed ... and the following status: [COMPLETED]
Spring Boot turns input.file=... into an identifying job parameter. Running the same command again fails with a
"job instance already complete" error unless a parameter changes, see
Running a job. The skip and retry model is in
Fault tolerance: skip & retry.
The same file written with dd/mm/yyyy dates is rejected row by row (observed): Skipped Books row 2: published = '02/01/2000'. That is the price of reading formatted text, and the reason to control the template.
A hand-written RowMapper
When names do not line up, or one column needs two accepted formats, a mapper by position is simpler than editors:
package com.bookshelf.batch;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.ParseException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Locale;
import org.springframework.batch.extensions.excel.RowMapper;
import org.springframework.batch.extensions.excel.support.rowset.RowSet;
/** Maps a row by column position. The reader has already formatted every cell to a String. */
public class BookRowMapper implements RowMapper<BookRow> {
private static final DateTimeFormatter DAY_MONTH_YEAR = DateTimeFormatter.ofPattern("d/M/yyyy");
@Override
public BookRow mapRow(RowSet rs) throws Exception {
String[] cells = rs.getCurrentRow();
if (cells.length < 6) {
throw new IllegalArgumentException("Expected 6 columns but found " + cells.length);
}
var book = new BookRow();
book.setIsbn(required(cells[0], "ISBN"));
book.setTitle(required(cells[1], "Title"));
book.setAuthor(required(cells[2], "Author"));
book.setCopies(Integer.parseInt(required(cells[3], "Copies").trim()));
book.setPrice(cells[4].isBlank() ? null : parseMoney(cells[4]));
book.setPublished(cells[5].isBlank() ? null : parseDate(cells[5].trim()));
return book;
}
private static String required(String value, String column) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(column + " is required");
}
return value;
}
private static BigDecimal parseMoney(String text) throws ParseException {
var format = new DecimalFormat("#,##0.##", DecimalFormatSymbols.getInstance(Locale.US));
format.setParseBigDecimal(true);
return (BigDecimal) format.parse(text.trim());
}
private static LocalDate parseDate(String text) {
try {
return LocalDate.parse(text); // 2024-03-14
}
catch (DateTimeParseException ex) {
return LocalDate.parse(text, DAY_MONTH_YEAR); // 14/3/2024
}
}
}
It is wired with reader.setRowMapper(new BookRowMapper()) and linesToSkip 1. Observed with the same six-row file:
rows 4 and 6 failed with Copies is required and NumberFormatException wrapped in ExcelFileParseException, the other
four were mapped, and a file with 14/03/2024-style dates was read correctly.
Source: RowMapper and DataFormatter, POI Javadoc
Large files with StreamingXlsxItemReader
PoiItemReader loads the workbook. StreamingXlsxItemReader does not: it opens the file as an OPC package, reads the
shared strings and styles tables, and parses each sheet with POI’s XSSFSheetXMLHandler through a StAX reader, keeping
only the current row. It is the same technique as the SAX reader of
Streaming Reads: Event & SAX APIs, packaged as an
ItemReader. Compared with PoiItemReader:
-
Only
.xlsx. No password support. -
It cannot go back to row 0 to read the header, so
RowNumberColumnNameExtractordoes not work: give the column names with aStaticColumnNameExtractor(or usePassThroughRowMapper). -
Formulas are not evaluated; a formula cell gives the cached value stored in the file, or an empty string when the file has none (see the table above).
-
Date handling differs, see the
datesAsIsotable above. -
The README marks the streaming support as experimental and says not everything is supported while streaming.
package com.bookshelf.batch;
import java.util.Locale;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.extensions.excel.mapping.BeanWrapperRowMapper;
import org.springframework.batch.extensions.excel.streaming.StreamingXlsxItemReader;
import org.springframework.batch.extensions.excel.support.rowset.DefaultRowSetFactory;
import org.springframework.batch.extensions.excel.support.rowset.StaticColumnNameExtractor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.FileSystemResource;
@Configuration
@Profile("streaming")
public class StreamingBooksImportConfig {
@Bean
@StepScope
public StreamingXlsxItemReader<BookRow> booksImportReader(
@Value("#{jobParameters['input.file']}") String inputFile,
BeanWrapperRowMapper<BookRow> bookRowMapper) {
// a streaming reader cannot look back at row 0, so the column names are given instead of read
var rowSetFactory = new DefaultRowSetFactory();
rowSetFactory.setColumnNameExtractor(new StaticColumnNameExtractor(
new String[] {"isbn", "title", "author", "copies", "price", "published"}));
var reader = new StreamingXlsxItemReader<BookRow>();
reader.setName("booksImportReader");
reader.setResource(new FileSystemResource(inputFile));
reader.setRowSetFactory(rowSetFactory);
reader.setLinesToSkip(1);
reader.setUserLocale(Locale.US);
reader.setRowMapper(bookRowMapper);
return reader;
}
}
The class replaces the PoiItemReader bean of the previous example (it is activated by a Spring profile there); the
mapper, writer and step are unchanged. With the file books-import.xlsx and 3,000 rows, both readers loaded the same
rows (observed).
Source: spring-batch-excel README, Configuration of StreamingXlsxItemReader and XSSFSheetXMLHandler, POI Javadoc
Measured memory
A workbook of 300,000 rows by 6 columns (1.8 million cells, a 10.7 MB .xlsx) was read with PassThroughRowMapper and
no database, with different heap limits on a Java 26 JVM on a laptop. The numbers are indicative, not a benchmark:
| Reader | Heap limit | Outcome | Time |
|---|---|---|---|
|
-Xmx128m, 512m, 1g |
|
|
|
-Xmx2g |
read all 300,000 rows |
about 9.7 s |
|
-Xmx32m |
|
|
|
-Xmx64m |
read all 300,000 rows |
about 4.6 s |
That is the practical rule. For files of a few thousand rows use PoiItemReader: it handles .xls and .xlsx,
passwords and evaluated formulas. For files of hundreds of thousands of rows, or when the batch runs in a small
container, use the streaming reader and control the cell formats. The same memory arithmetic is explained in
Performance, Memory & Threading.
Restart, threads and untrusted input on the read side
Restart
Both readers save the number of items read in the ExecutionContext (saveState is on by default). On restart the
base class calls jumpToItem, which in 0.3.0 reads and discards the rows up to the saved position without mapping them.
The cost is parsing the first part of the file again, but not mapping or writing it. Observed, with a 3,000-row file, a
chunk size of 100 and a processor that threw once on row 1,500:
| Run | Status | Items read | Items written | Rows in the book table afterwards |
|---|---|---|---|---|
first |
|
1,500 |
1,400 |
1,400 |
restart |
|
1,600 |
1,600 |
3,000 |
The restart resumed after the last committed chunk and no row was loaded twice. This assumes that the file is the same file: if the supplier replaces it between the two runs, the saved position points at different rows. Put the file name into an identifying job parameter and never overwrite a processed file. See Stopping, restart & recovery.
Threads and scaling
The readers are single-threaded: Spring Batch’s counting reader base class and the POI API are both unsafe for concurrent
reads, and the README says reading from several threads is not supported. A multi-threaded step therefore cannot speed up
the read. What scales is everything after the read (processing and writing, using a single reading thread) and the number
of files: a partitioned step with one file per partition, or one job per file. Because the readers implement
ResourceAwareItemReaderItemStream, they have the type that MultiResourceItemReader requires from its delegate for a
directory of workbooks. See Scaling & parallel processing.
Untrusted files
A batch job that opens an uploaded workbook has the same exposure as a web request: zip bombs, huge records and
disguised files. POI’s limits (ZipSecureFile, IOUtils.setByteArrayMaxOverride) are JVM-wide and apply here, and the
README’s FAQ shows setting the byte-array override in main before anything else touches POI. Validate and store the
upload before launching the job, as described in
Security & Untrusted Files, and pass only the stored path as
a job parameter.
Exporting with a custom ItemStreamWriter around SXSSF
Spring Batch has writers for flat files, XML, JSON and databases, and spring-batch-excel has none for Excel. A workbook writer has three requirements that no stock writer meets:
-
It must use SXSSF (see Streaming Writes with SXSSF), because an
XSSFWorkbookof a million loans does not fit in a normal heap. -
Its lifecycle is a step’s lifecycle: the workbook is created in
open, filled by everywrite(Chunk)and released inclose. That is whatItemStreamis for. -
The finished file must appear only if the step succeeded.
The writer below does that. The row layout is a lambda (RowFiller), so the same class serves any item type:
package com.bookshelf.batch;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import org.apache.poi.ss.SpreadsheetVersion;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.CreationHelper;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.streaming.SXSSFSheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.listener.StepExecutionListener;
import org.springframework.batch.core.step.StepExecution;
import org.springframework.batch.infrastructure.item.Chunk;
import org.springframework.batch.infrastructure.item.ExecutionContext;
import org.springframework.batch.infrastructure.item.ItemStreamException;
import org.springframework.batch.infrastructure.item.ItemStreamWriter;
/**
* Writes items as rows of an .xlsx file with SXSSF. The file is built in a temporary workbook and published to its
* final name only when the step has completed, so a failed run never leaves a half-written report behind.
* It is not restartable from the middle: a restart rebuilds the whole file.
*/
public class SxssfItemWriter<T> implements ItemStreamWriter<T>, StepExecutionListener {
/** Fills one data row of the current sheet from one item. */
@FunctionalInterface
public interface RowFiller<T> {
void fill(T item, Row row, Styles styles);
}
/** The cell styles of the workbook, created once per workbook. */
public record Styles(CellStyle header, CellStyle date, CellStyle money) {
}
private final Path target;
private final String sheetName;
private final List<String> headers;
private final RowFiller<T> filler;
private final int window;
private SXSSFWorkbook workbook;
private Styles styles;
private SXSSFSheet sheet;
private int sheetNumber;
private int nextRow;
public SxssfItemWriter(Path target, String sheetName, List<String> headers, RowFiller<T> filler, int window) {
this.target = target;
this.sheetName = sheetName;
this.headers = List.copyOf(headers);
this.filler = filler;
this.window = window;
}
@Override
public void open(ExecutionContext executionContext) throws ItemStreamException {
closeWorkbook(); // a restart in the same JVM must not reuse the old workbook
try {
Files.createDirectories(target.toAbsolutePath().getParent()); // fail now, not after an hour of work
}
catch (IOException ex) {
throw new ItemStreamException("Cannot create the directory of " + target, ex);
}
workbook = new SXSSFWorkbook(window);
workbook.setCompressTempFiles(true); // gzip the temporary row files
styles = createStyles(workbook);
sheetNumber = 0;
startSheet();
}
@Override
public synchronized void write(Chunk<? extends T> chunk) {
for (T item : chunk) {
if (nextRow >= SpreadsheetVersion.EXCEL2007.getMaxRows()) {
startSheet(); // a sheet is full: continue on a new one
}
filler.fill(item, sheet.createRow(nextRow++), styles);
}
}
/**
* Called after the last chunk and before close(). A completed step publishes the file; if that fails, the step is
* marked FAILED here, because an exception thrown from close() would leave the step COMPLETED.
*/
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
if (workbook == null || stepExecution.getStatus() != BatchStatus.COMPLETED) {
return null; // failed or stopped: publish nothing
}
try {
publish();
return null; // null keeps the step's own exit status
}
catch (IOException | RuntimeException ex) {
stepExecution.addFailureException(ex);
stepExecution.setStatus(BatchStatus.FAILED);
return ExitStatus.FAILED.addExitDescription(ex);
}
}
@Override
public void close() throws ItemStreamException {
closeWorkbook(); // always: removes the temporary row files
}
private void publish() throws IOException {
Path part = target.resolveSibling(target.getFileName() + ".part");
try {
try (OutputStream out = Files.newOutputStream(part)) {
workbook.write(out);
}
Files.move(part, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}
finally {
Files.deleteIfExists(part); // after a failed write or move; a no-op once the move has succeeded
}
}
private void closeWorkbook() {
if (workbook != null) {
try {
workbook.close();
}
catch (IOException ex) {
throw new ItemStreamException("Cannot close the workbook", ex);
}
finally {
workbook = null; // close() has already deleted the temporary row files
}
}
}
private void startSheet() {
sheetNumber++;
sheet = workbook.createSheet(sheetNumber == 1 ? sheetName : sheetName + " " + sheetNumber);
Row headerRow = sheet.createRow(0);
for (int i = 0; i < headers.size(); i++) {
var cell = headerRow.createCell(i);
cell.setCellValue(headers.get(i));
cell.setCellStyle(styles.header());
}
sheet.createFreezePane(0, 1);
nextRow = 1;
}
private static Styles createStyles(SXSSFWorkbook wb) {
CreationHelper helper = wb.getCreationHelper();
Font bold = wb.createFont();
bold.setBold(true);
CellStyle header = wb.createCellStyle();
header.setFont(bold);
CellStyle date = wb.createCellStyle();
date.setDataFormat(helper.createDataFormat().getFormat("yyyy-mm-dd"));
CellStyle money = wb.createCellStyle();
money.setDataFormat(helper.createDataFormat().getFormat("#,##0.00"));
return new Styles(header, date, money);
}
}
How the pieces behave (all observed unless marked otherwise):
| Callback | What it does |
|---|---|
|
Creates the output directory (so a wrong path fails at the start, not after an hour), creates a fresh |
|
Appends one row per item. When a sheet reaches |
|
Runs after the last chunk and before |
|
Closes the workbook, which deletes the temporary row files (POI 5.3.0 and later). |
Why the publish is in afterStep and not in close: in a first version it was in close. When the target directory
did not exist the exception was recorded as a failure exception but the step and the job ended COMPLETED and no file was
produced (observed). An exception thrown by afterStep would be logged and swallowed too, which is why the writer sets
the status and the exit status itself.
The row layout and the wiring for the monthly loans report:
package com.bookshelf.batch;
import java.math.BigDecimal;
import java.time.LocalDate;
/** One row of loans-report.xlsx. */
public record LoanRow(long id, String reader, String title, LocalDate loanedOn, LocalDate dueOn, BigDecimal fine) {
}
package com.bookshelf.batch;
import java.nio.file.Path;
import java.util.List;
import javax.sql.DataSource;
import org.apache.poi.ss.usermodel.Row;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.Step;
import org.springframework.batch.core.step.builder.ChunkOrientedStepBuilder;
import org.springframework.batch.infrastructure.item.database.JdbcCursorItemReader;
import org.springframework.batch.infrastructure.item.database.builder.JdbcCursorItemReaderBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.DataClassRowMapper;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class LoansExportJobConfig {
@Bean
public JdbcCursorItemReader<LoanRow> loansReader(DataSource dataSource) {
return new JdbcCursorItemReaderBuilder<LoanRow>()
.name("loansReader")
.dataSource(dataSource)
.sql("SELECT id, reader, title, loaned_on, due_on, fine FROM loan ORDER BY id")
.rowMapper(new DataClassRowMapper<>(LoanRow.class))
.fetchSize(1000)
.saveState(false) // the file is rebuilt from scratch, so the reader must start from row 1 too
.build();
}
@Bean
@StepScope
public SxssfItemWriter<LoanRow> loansWorkbookWriter(@Value("#{jobParameters['output.file']}") String outputFile) {
return new SxssfItemWriter<>(Path.of(outputFile), "Loans",
List.of("Id", "Reader", "Title", "Loaned on", "Due on", "Fine"),
LoansExportJobConfig::fillRow, 200);
}
private static void fillRow(LoanRow loan, Row row, SxssfItemWriter.Styles styles) {
row.createCell(0).setCellValue(loan.id());
row.createCell(1).setCellValue(loan.reader());
row.createCell(2).setCellValue(loan.title());
var loaned = row.createCell(3);
loaned.setCellValue(loan.loanedOn());
loaned.setCellStyle(styles.date());
var due = row.createCell(4);
due.setCellValue(loan.dueOn());
due.setCellStyle(styles.date());
var fine = row.createCell(5);
fine.setCellValue(loan.fine().doubleValue());
fine.setCellStyle(styles.money());
}
@Bean
public Step loansExportStep(JobRepository jobRepository, PlatformTransactionManager transactionManager,
JdbcCursorItemReader<LoanRow> loansReader,
SxssfItemWriter<LoanRow> loansWorkbookWriter) {
return new ChunkOrientedStepBuilder<LoanRow, LoanRow>("loansExportStep", jobRepository, 500)
.reader(loansReader)
.writer(loansWorkbookWriter)
.listener(loansWorkbookWriter) // afterStep tells the writer whether to publish the file
.transactionManager(transactionManager)
.build();
}
@Bean
public Job loansExportJob(JobRepository jobRepository, Step loansExportStep) {
return new JobBuilder("loansExportJob", jobRepository).start(loansExportStep).build();
}
}
Notes:
-
The cell styles are created once in
openand shared by all rows, the rule from Cell Styles, Fonts & Formats. -
listener(loansWorkbookWriter)registers the same bean as a step listener.writer(...)alone would register it as a stream but would not callafterStep. -
fetchSize(1000)with a cursor reader streams the rows from the database; the memory of the whole job stays flat. -
The reader has
saveState(false). The next section explains why that is not optional.
Source: POI Spreadsheet How-To, SXSSF and
SXSSFWorkbook, POI Javadoc. The
ItemStream contract is in the Spring Batch ItemStream documentation.
Results (observed, H2 in the same JVM, so the heap includes the database):
| Export | Heap limit | Time | Result |
|---|---|---|---|
100,000 loans |
-Xmx128m |
about 2.9 s |
one sheet, 100,001 rows including the header, a 3.5 MB file, frozen header row |
1,200,000 loans |
-Xmx1g |
about 21.8 s |
two sheets: |
The sheet roll-over matters: Excel stops at 1,048,576 rows per sheet, and a writer that ignores the limit fails with an error from POI in the middle of the export. Row limits and the other format limits are in File Formats: OOXML & OLE2.
Restart and failure semantics of the export
SXSSF cannot resume. A flushed row lives in a private temporary file of one JVM, the workbook is only written to its
final form at the end, and nothing in the ExecutionContext can bring it back. The consequence is the central rule of
the export:
The export restarts from scratch: the reader must start from the first row again, and the writer rebuilds the whole file.
The writer stores no state, so the reader must not either. Observed with a failure injected at loan 60,000 (the 100,000-row export, chunk size 500):
| Run | Step status | Items read | Final file | Remark |
|---|---|---|---|---|
first run, failure at loan 60,000 |
|
60,000 |
none, and no |
The temporary row files were deleted. |
restart, reader |
|
100,000 |
complete, 100,001 rows |
The step rebuilt the file. |
restart, reader with the default |
|
40,500 |
incomplete: 40,501 rows, starting at loan 59,501 |
Silently incomplete: the reader resumed, the new workbook only has the remaining items. |
The last row is the dangerous one, because nothing fails. Keep the two settings together and comment them, as in the configuration above.
Skip, retry and rollback
The workbook is not a transactional resource. When a chunk is rolled back, the rows already appended for it stay in the
workbook. If the step then retries the chunk, the rows are appended again. Observed with a CompositeItemWriter of the
Excel writer followed by a second writer that failed once (a database that went away), in a step with
.faultTolerant().retry(IllegalStateException.class): the step completed and the sheet contained 100,501 rows instead of
100,001, the 500 rows of the retried chunk twice. The skip-scan mechanism, which rewrites a failed chunk item by item,
re-invokes the writer in the same way; that case was not measured.
The rules that follow:
-
Do not combine this writer with retry or skip on write failures. Let a failure fail the step; the restart rebuilds the file.
-
If the Excel file is one of several outputs, make it the last writer of the composite, so that nothing fails after it, or write it in a separate step that reads the same data again.
-
Make the
RowFillerunable to throw for bad data (validate in anItemProcessorbefore it): an exception in the middle of a chunk leaves a part of the chunk in the workbook.
The skip model is described in Fault tolerance: skip & retry.
When the export is too big to rebuild
If rebuilding the report after a failure is too expensive, change the shape of the work rather than the writer:
-
Partition by file. Split the data (by month, by branch library) into partitions that each produce their own workbook. A restart reruns only the partitions that did not complete, because completed partition steps are not executed again. See Scaling & parallel processing.
-
Write a restartable intermediate format. A
FlatFileItemWriterappends and resumes at the last commit (ItemWriters: files & XML/JSON); convert the CSV to a workbook in a last, short step. -
Deliver CSV. If the consumer only opens the file in Excel, CSV avoids the workbook, the memory and the 1,048,576-row limit altogether; the formula-injection rule of Security & Untrusted Files still applies to its cells.
Two observations about POI 5.5.1
-
Closing an
SXSSFWorkbookthat was never written (the failure path above) logs aWARNwith the messageFailed to dispose sheetand a stack trace that ends injava.io.IOException: Stream closed. In the runs the temporary files were still deleted (thepoifilesdirectory underjava.io.tmpdirwas empty), the step status was not affected, and the writer does not calldispose()a second time. Expect the line in the log of a failed export. -
Temporary files can be as large as the sheet data. Keep
java.io.tmpdiron a volume with space, or choose a directory as described in Streaming Writes with SXSSF.
Choosing a tool
| Need | PoiItemReader |
StreamingXlsxItemReader |
Own code with the SAX reader |
|---|---|---|---|
|
yes |
no |
HSSF event model |
Memory for 300,000 rows by 6 columns |
more than 1 GB (measured) |
64 MB was enough (measured) |
similar to streaming |
Cell access by type (numeric, date, boolean) |
no, text only |
no, text only |
yes |
Passwords |
yes (also |
no |
no |
Own code is the way out of the text-only limit, at the price of writing the reader; the SAX page of this section shows a complete one. Below a few thousand rows and without a need for restart, a plain service that reads the upload with the usermodel is simpler than a Spring Batch job.
Pitfalls
| Pitfall | What happens and what to do |
|---|---|
A mapper that returns |
With |
Other sheets are read as data |
There is no sheet selection, so a |
Default locale |
Numbers come as |
Blank cells and primitives |
A blank cell becomes an empty string, and an empty string cannot be bound to an |
Precision |
The text is rounded to the cell format (12.345 shows as 12.35). Money columns must have a format with enough decimals. |
|
Fails on a row with a boolean cell with the usermodel reader and gives serial numbers for custom formats with the streaming reader (observed). Control the template instead. |
Restart state for the export |
A writer that cannot resume next to a reader that does gives a silently truncated file. |
Retry on the export writer |
Duplicated rows (observed). No retry or write-skip with this writer. |
Failure in the writer’s |
Does not fail the step. Publish in |
Several threads |
Neither the readers nor |
Step-scoped file name |
A reader or writer that takes a job parameter must be |
What this combination does not provide
-
No Excel writer in spring-batch-excel 0.3.0; the writer on this page is application code, exercised only by the throwaway project used to write this page.
-
No typed access to cells: the reader gives formatted text.
-
No sheet selection, no per-sheet configuration and no header detection other than a fixed row index.
-
No streaming read of
.xlsand no password support for streaming in 0.3.0. -
No restart from the middle of a workbook export, and no transactional rollback of rows already in the workbook.
-
No multi-threaded read.
-
No support commitment beyond the repository’s issue tracker.
Changes between versions
| Version | Change relevant to this page |
|---|---|
spring-batch-excel 0.1.x |
First releases of the readers ( |
spring-batch-excel 0.2.0 (2025-12-03) |
|
spring-batch-excel 0.3.0 (2026-06-17) |
Built on Spring Batch 6.0.4 and Apache POI 5.5.1; the item API moved to the |
spring-batch-excel after 0.3.0 |
|
Spring Batch 5 to 6 |
The reader, writer and |
Apache POI 6.0 |
Not released on 2026-10-09. The expected changes (for example the default of |
Related pages
-
ItemReaders: files & XML/JSON and ItemWriters: files & XML/JSON: the general file reader and writer model, including the custom reader contract.
-
Steps, executions & the ExecutionContext: how
ItemStreamstate is saved. -
Stopping, restart & recovery and Scaling & parallel processing.
-
Testing Spring Batch: launching a job in a test and asserting on the produced workbook.
-
Streaming Writes with SXSSF and Streaming Reads: Event & SAX APIs: the POI APIs behind both halves.
-
Security & Untrusted Files: before a job opens an upload.
References
-
spring-batch-excel README and source: https://github.com/spring-projects/spring-batch-extensions/tree/main/spring-batch-excel
-
Spring Batch Extensions repository: https://github.com/spring-projects/spring-batch-extensions
-
Maven Central metadata for spring-batch-excel: https://repo1.maven.org/maven2/org/springframework/batch/extensions/spring-batch-excel/maven-metadata.xml
-
Spring Batch, ItemReaders and ItemWriters: https://docs.spring.io/spring-batch/reference/readersAndWriters.html
-
Spring Batch, ItemStream: https://docs.spring.io/spring-batch/reference/readers-and-writers/item-stream.html
-
Spring Batch, configuring skip logic: https://docs.spring.io/spring-batch/reference/step/chunk-oriented-processing/configuring-skip.html
-
Spring Batch, scalability: https://docs.spring.io/spring-batch/reference/scalability.html
-
POI Spreadsheet How-To, SXSSF: https://poi.apache.org/components/spreadsheet/how-to.html#sxssf
-
POI FAQ (thread safety of the usermodel): https://poi.apache.org/help/faq.html
-
Javadoc,
SXSSFWorkbook: https://poi.apache.org/apidocs/dev/org/apache/poi/xssf/streaming/SXSSFWorkbook.html -
Javadoc,
DataFormatter: https://poi.apache.org/apidocs/dev/org/apache/poi/ss/usermodel/DataFormatter.html -
Javadoc,
XSSFSheetXMLHandler: https://poi.apache.org/apidocs/dev/org/apache/poi/xssf/eventusermodel/XSSFSheetXMLHandler.html -
Javadoc,
WorkbookFactory: https://poi.apache.org/apidocs/dev/org/apache/poi/ss/usermodel/WorkbookFactory.html