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.

flowchart LR subgraph Import direction LR X1["books-import.xlsx
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

org.springframework.batch.extensions:spring-batch-excel, the latest and only 0.3.x release is 0.3.0. Maven Central lists 0.1.0, 0.1.1, 0.2.0 and 0.3.0.

Who maintains it

It lives in the spring-projects/spring-batch-extensions repository ("Spring Batch Extensions"), not in the Spring Batch release train. The POM of 0.3.0 lists one developer and the licence is Apache-2.0. Treat it as a community extension.

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 (org.springframework.batch.infrastructure.item), so it does not work with Spring Batch 5. It ran unchanged on the 6.0.5 that Spring Boot 4.1.1 manages.

Apache POI

poi and poi-ooxml 5.5.1 are declared optional in its POM, so your application must declare poi-ooxml itself.

What it provides

Readers only. There is no writer, no builder class and no sheet selection.

The classes in the 0.3.0 jar (checked by listing the jar) are:

Package org.springframework.batch.extensions.excel Classes

(root)

AbstractExcelItemReader, RowMapper, RowCallbackHandler, ExcelFileParseException, DataFormatterCustomizer, FormulaEvaluatorFactory, IsoFormattingDateDataFormatter, Sheet

poi

PoiItemReader: reads with the usermodel (.xls and .xlsx).

streaming

StreamingXlsxItemReader: reads .xlsx row by row with an event parser. The README calls the streaming support experimental.

mapping

BeanWrapperRowMapper, PassThroughRowMapper

support.rowset

RowSet, RowSetMetaData, RowSetFactory, DefaultRowSetFactory, ColumnNameExtractor, RowNumberColumnNameExtractor, StaticColumnNameExtractor

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 an InputStream, which POI has to buffer completely. The workbook uses MissingCellPolicy.CREATE_NULL_AS_BLANK.

  • Every sheet is read. There is no sheet selection. linesToSkip is 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 the RowMapper receives a String[].

  • 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

resource

none, required

The Resource to read. setStrict(true) (the default) makes open fail if it does not exist or cannot be read; with false the step just reads nothing.

rowMapper

none, required

Maps the current row to an item. See the next section.

linesToSkip

0

Rows to skip at the start of every sheet. Use 1 when the first row is a header.

skippedRowsCallback

none

A RowCallbackHandler called for each skipped row, for example to capture a title block above the data.

endAfterBlankLines

1

Despite the name, this is about null items: when the mapper returns null, reading ends after this many consecutive `null`s (see the pitfalls).

rowSetFactory

DefaultRowSetFactory

Creates the RowSet of a sheet; it carries the ColumnNameExtractor that provides the column names.

password

none

Password of an encrypted file. The README says it works only for .xls, but PoiItemReader also opened an agile-encrypted .xlsx with it (observed), and StreamingXlsxItemReader failed to open the same file with Failed to initialize the reader. Read the password from configuration, never from source; encryption itself is covered in Encryption, Signing & Protection.

userLocale

JVM default

Locale of the DataFormatter. It decides decimal and grouping separators, so set it explicitly.

datesAsIso

false

Formats dates as ISO text instead of the cell’s format. See the table below; it has sharp edges.

dataFormatterCustomizer

DataFormatterCustomizer.DEFAULT

Callback to configure the DataFormatter. The default calls setUseCachedValuesForFormulaCells(true).

formulaEvaluatorFactory

FormulaEvaluatorFactory.NOOP

Supplies a FormulaEvaluator. The default supplies none, so formula cells give their cached value; FormulaEvaluatorFactory.SIMPLE evaluates formulas with POI.

name, saveState, maxItemCount, currentItemCount

inherited

Standard Spring Batch properties. saveState is true by default, which makes the reader restartable.

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

3/14/24

3/14/24

Format 14 is the locale short date.

date 2024-03-14, custom dd/mm/yyyy

14/03/2024

14/03/2024

The mapper must know the pattern.

date 2024-03-14, custom yyyy-mm-dd

2024-03-14

2024-03-14

Unambiguous; ask suppliers for this format.

1234.5, format #,##0.00

1,234.50

1,234.50

The grouping separator is part of the text.

12.345, format 0.00

12.35

12.35

The text is rounded to the cell format; the exact value is lost.

integer 7, format General

7

7

a formula that has no cached value

0

(empty)

Files written by POI have no cached results; files saved by Excel do.

boolean TRUE

TRUE

TRUE

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

2024-03-14

2024-03-14

date, custom format

2024-03-14T00:00:00

45365.0 (the raw serial number)

a row that contains a boolean cell

IllegalStateException: Cannot get a NUMERIC value from 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

rs.getCurrentRow()

The row as String[], by column position.

rs.getProperties()

A Properties of column name to text, using the column names of the sheet’s RowSetMetaData. Blank cells are present with an empty string.

rs.getCurrentRowIndex()

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).

rs.getMetaData().getSheetName()

The name of the sheet being read.

rs.getMetaData().getColumnNames()

The column names from the ColumnNameExtractor.

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

targetType / prototypeBeanName

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 record does not work.

distanceLimit

How different a column name may be from a property name and still match. The default of 5 is fuzzy: Quantity, quantity and ITEM_ID for itemId match, and two close candidates raise an error. Set it to 0 to require exact (case-insensitive) names.

strict

Default true: a column without a matching property fails the row. With false unknown columns are ignored.

customEditors

A map of type to PropertyEditor, used to convert text to BigDecimal, LocalDate and so on.

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:

  • ExcelFileParseException is 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 BigDecimal editor uses a NumberFormat for Locale.US so that 1,201.50 converts; the LocalDate editor expects the yyyy-mm-dd template format. CustomNumberEditor with allowEmpty set to true turns an empty cell into null.

  • setName gives the keys saved in the step’s ExecutionContext a prefix (observed: booksImportReader.read.count).

  • The reader is @StepScope so that the file name can come from a job parameter. See Jobs, instances & parameters.

  • The step uses the 6.0 ChunkOrientedStepBuilder. The older StepBuilder.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.

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 RowNumberColumnNameExtractor does not work: give the column names with a StaticColumnNameExtractor (or use PassThroughRowMapper).

  • 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 datesAsIso table 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).

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

PoiItemReader

-Xmx128m, 512m, 1g

OutOfMemoryError

PoiItemReader

-Xmx2g

read all 300,000 rows

about 9.7 s

StreamingXlsxItemReader

-Xmx32m

OutOfMemoryError

StreamingXlsxItemReader

-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

FAILED

1,500

1,400

1,400

restart

COMPLETED

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:

  1. It must use SXSSF (see Streaming Writes with SXSSF), because an XSSFWorkbook of a million loans does not fit in a normal heap.

  2. Its lifecycle is a step’s lifecycle: the workbook is created in open, filled by every write(Chunk) and released in close. That is what ItemStream is for.

  3. 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

open

Creates the output directory (so a wrong path fails at the start, not after an hour), creates a fresh SXSSFWorkbook with a window of the given size and gzip-compressed temporary files, creates the styles once and starts the first sheet with a bold header and a frozen header row.

write

Appends one row per item. When a sheet reaches SpreadsheetVersion.EXCEL2007.getMaxRows() (1,048,576 rows) it starts the next sheet, Loans 2. It is synchronized only as a guard: ordering still requires one thread.

afterStep

Runs after the last chunk and before close. If the step is COMPLETED it writes the workbook to the target name plus .part and atomically renames it to the final name; if that fails it deletes the .part file and marks the step FAILED. If the step failed, it publishes nothing.

close

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 open and 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 call afterStep.

  • 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.

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: Loans with 1,048,576 rows and Loans 2 with 151,426 rows (headers included), a 41 MB file

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

FAILED

60,000

none, and no .part file

The temporary row files were deleted.

restart, reader saveState(false)

COMPLETED

100,000

complete, 100,001 rows

The step rebuilt the file.

restart, reader with the default saveState(true)

COMPLETED

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 RowFiller unable to throw for bad data (validate in an ItemProcessor before 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 FlatFileItemWriter appends 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 SXSSFWorkbook that was never written (the failure path above) logs a WARN with the message Failed to dispose sheet and a stack trace that ends in java.io.IOException: Stream closed. In the runs the temporary files were still deleted (the poifiles directory under java.io.tmpdir was empty), the step status was not affected, and the writer does not call dispose() 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.tmpdir on 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

.xls files

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 .xlsx, observed)

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 null ends the import

With endAfterBlankLines at its default of 1, the first null is treated as the end of the input; observed: a mapper returning null for the second data row delivered one item and ended the step with COMPLETED. With endAfterBlankLines 3 the same mapper skipped that row and continued. Prefer throwing a skippable exception, or returning an item and filtering it in an ItemProcessor.

Other sheets are read as data

There is no sheet selection, so a Notes sheet is read, its first row is skipped as a header and its second row is mapped (observed). Reject workbooks that have more than the expected sheet, or tag items with rs.getMetaData().getSheetName() and filter them.

Default locale

Numbers come as 1.201,50 on a Spanish server. Set userLocale and the editors together.

Blank cells and primitives

A blank cell becomes an empty string, and an empty string cannot be bound to an int (observed: copies = ''). Use Integer where blanks are allowed or validate with a custom mapper.

Precision

The text is rounded to the cell format (12.345 shows as 12.35). Money columns must have a format with enough decimals.

datesAsIso

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. saveState(false) on the reader.

Retry on the export writer

Duplicated rows (observed). No retry or write-skip with this writer.

Failure in the writer’s close

Does not fail the step. Publish in afterStep and set the status.

Several threads

Neither the readers nor SXSSFWorkbook are thread-safe. One thread for the read, one for the workbook.

Step-scoped file name

A reader or writer that takes a job parameter must be @StepScope, and a step-scoped class must not be final.

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 .xls and 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 (PoiItemReader, StreamingXlsxItemReader, the mappers and the RowSet classes carry @since 0.1.0).

spring-batch-excel 0.2.0 (2025-12-03)

DataFormatterCustomizer and FormulaEvaluatorFactory (@since 0.2.0). Built on Spring Batch 5.2.3.

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 infrastructure.item packages of Spring Batch 6.

spring-batch-excel after 0.3.0

StreamingXlsItemReader for .xls on the main branch, not released on 2026-10-09.

Spring Batch 5 to 6

The reader, writer and ItemStream interfaces moved from org.springframework.batch.item to org.springframework.batch.infrastructure.item; the chunk step gained ChunkOrientedStepBuilder.

Apache POI 6.0

Not released on 2026-10-09. The expected changes (for example the default of setUseCachedValuesForFormulaCells) are listed in What Changed & Migration. spring-batch-excel 0.3.0 sets that option explicitly, so it is not affected by the default.