`ItemWriter`s: files & XML/JSON

This section documents the current Spring Batch line — 6.0.x, on Spring Framework 7 and Spring Boot 4.1.x, with a Java 17+ baseline — as published at the Spring Batch reference documentation. No specific patch version is pinned. Some surfaces (Spring Cloud Task and Spring Cloud Data Flow orchestration, the deployer-based partition handler, and JSR-352) are linked, not documented in depth.

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

An ItemWriter receives the whole chunk at once, which is what lets it write efficiently — one batched statement, one flush, one file append — instead of one operation per item.

The contract

public interface ItemWriter<T> {
    void write(Chunk<? extends T> chunk) throws Exception;
}

Chunk<T> is the list of items the reader and processor produced for this transaction. Two consequences:

  • the writer must handle a chunk of any size, including one — during a skip scan a fault-tolerant step writes items one at a time (Fault tolerance: skip & retry);

  • the writer is the right home for side effects, because it runs once per chunk inside the chunk’s transaction (ItemProcessors).

ItemStreamWriter<T> adds the ItemStream callbacks. Every file writer implements it, because the current file position has to be saved at each commit point for a restart to append rather than overwrite.

FlatFileItemWriter

Writing a flat file is the mirror image of reading one:

flowchart LR Item["item"] --> FE["FieldExtractor
object to Object[]"] FE --> LA["LineAggregator
Object[] to one line of text"] LA --> File["Resource
one line appended"]
@Bean
@StepScope
public FlatFileItemWriter<Trade> tradeCsvWriter(
        @Value("#{jobParameters['output.file']}") WritableResource outputFile) {
    return new FlatFileItemWriterBuilder<Trade>()
            .name("tradeCsvWriter")
            .resource(outputFile)
            .delimited()
            .delimiter(",")
            .names("externalId", "isin", "quantity", "price", "notional")
            .headerCallback(writer -> writer.write("externalId,isin,quantity,price,notional"))
            .shouldDeleteIfExists(true)
            .build();
}

.delimited() builds a DelimitedLineAggregator over a BeanWrapperFieldExtractor that reads the named properties. For fixed-width output use .formatted() with a java.util.Formatter pattern:

@Bean
public FlatFileItemWriter<Trade> tradeReportWriter() {
    return new FlatFileItemWriterBuilder<Trade>()
            .name("tradeReportWriter")
            .resource(new FileSystemResource("/data/out/trades.txt"))
            .formatted()
            .format("%-12s%-14s%10d%12.2f")
            .names("externalId", "isin", "quantity", "price")
            .build();
}

A custom FieldExtractor takes over when the output columns are computed rather than read straight off the object:

public class TradeFieldExtractor implements FieldExtractor<Trade> {

    @Override
    public Object[] extract(Trade trade) {
        return new Object[] {
                trade.getExternalId(),
                trade.getInstrument().getIsin(),
                trade.getQuantity(),
                trade.getPrice(),
                trade.getQuantity().multiply(trade.getPrice())   // computed
        };
    }
}

And a custom LineAggregator takes over when the line format is not "fields joined by something".

Headers and footers

FlatFileHeaderCallback writes before the first item; FlatFileFooterCallback writes after the last one, and is where control totals belong. A footer usually needs state gathered during the step, so it also implements StepExecutionListener:

public class TotalsFooterCallback implements FlatFileFooterCallback, StepExecutionListener {

    private StepExecution stepExecution;

    @Override
    public void beforeStep(StepExecution stepExecution) {
        this.stepExecution = stepExecution;
    }

    @Override
    public void writeFooter(Writer writer) throws IOException {
        writer.write("TRL" + String.format("%09d", stepExecution.getWriteCount()));
    }
}

Register the callback both on the writer (.footerCallback(…​)) and as a step listener, so beforeStep is called.

On restart, the header callback would otherwise run again and write a second header. FlatFileItemWriter handles this by tracking the written position in the ExecutionContext and appending — which is exactly why .name(…​) and stream registration matter.

File-management flags

Flag Effect

shouldDeleteIfExists(true) (default)

Delete an existing output file when the step starts fresh. Prevents stale data from a previous run.

shouldDeleteIfEmpty(true)

Remove the file at the end if nothing was written — no empty files for downstream systems to misinterpret.

appendAllowed(true)

Append to an existing file instead of deleting it; implies not writing a header. Mutually exclusive with shouldDeleteIfExists.

transactional(true) (default)

Buffer the chunk’s lines and flush them only on commit, so a rolled-back chunk leaves nothing behind. Set false only when the performance cost is proven and duplicates are acceptable.

forceSync(true)

fsync after each flush — durable, and considerably slower.

saveState(false)

Do not record the position; the writer becomes non-restartable but slightly cheaper (see Profiling & tuning).

XML with StaxEventItemWriter

@Bean
public StaxEventItemWriter<Trade> xmlTradeWriter() {
    Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
    marshaller.setClassesToBeBound(Trade.class);

    return new StaxEventItemWriterBuilder<Trade>()
            .name("xmlTradeWriter")
            .resource(new FileSystemResource("/data/out/trades.xml"))
            .marshaller(marshaller)
            .rootTagName("trades")
            .overwriteOutput(true)
            .build();
}

The root tag is opened once and closed at the end, so the file is well-formed even though it was streamed.

JSON with JsonFileItemWriter

@Bean
public JsonFileItemWriter<Trade> jsonTradeWriter() {
    return new JsonFileItemWriterBuilder<Trade>()
            .name("jsonTradeWriter")
            .resource(new FileSystemResource("/data/out/trades.json"))
            .jsonObjectMarshaller(new JacksonJsonObjectMarshaller<>())
            .build();
}

The output is a JSON array: an opening bracket, comma-separated objects, a closing bracket.

Rolling output over several files

MultiResourceItemWriter starts a new file every itemCountLimitPerResource items, using a ResourceSuffixCreator for the numbering. It keeps a single output file from growing past what downstream systems can ingest:

@Bean
public MultiResourceItemWriter<Trade> rollingTradeWriter(FlatFileItemWriter<Trade> tradeCsvWriter) {
    MultiResourceItemWriter<Trade> writer = new MultiResourceItemWriter<>();
    writer.setName("rollingTradeWriter");
    writer.setResource(new FileSystemResource("/data/out/trades"));   // trades1, trades2, ...
    writer.setDelegate(tradeCsvWriter);
    writer.setItemCountLimitPerResource(100_000);
    writer.setResourceSuffixCreator(index -> "-" + index + ".csv");
    return writer;
}

Composing writers

CompositeItemWriter sends every chunk to each delegate in turn — write to the database and an audit file:

@Bean
public CompositeItemWriter<Trade> auditingWriter(JdbcBatchItemWriter<Trade> databaseWriter,
                                                 FlatFileItemWriter<Trade> auditFileWriter) {
    CompositeItemWriter<Trade> composite = new CompositeItemWriter<>();
    composite.setDelegates(List.of(databaseWriter, auditFileWriter));
    return composite;
}

ClassifierCompositeItemWriter routes each item to one delegate instead:

@Bean
public ClassifierCompositeItemWriter<Trade> routingWriter(ItemWriter<Trade> domesticWriter,
                                                         ItemWriter<Trade> foreignWriter) {
    ClassifierCompositeItemWriter<Trade> writer = new ClassifierCompositeItemWriter<>();
    writer.setClassifier(trade -> trade.isDomestic() ? domesticWriter : foreignWriter);
    return writer;
}

In both cases the delegates are hidden from the step, so any delegate that implements ItemStream must be registered with .stream(…​) — see Chunk-oriented processing.

Further reading

For the full detail behind this page: