00:00

Spring Batch with Spring Boot

Last updated:

Spring Batch is a framework for processing large amounts of data in the background: importing a supplier's product file every night, settling the day's payments, or exporting orders to an accounting system. Writing such jobs by hand looks easy until a file has a bad line, the database goes down halfway through, or someone runs the job twice. Spring Batch handles those cases for you with chunked transactions, skip and retry rules, and restartable job metadata.

This tutorial explains how Spring Batch jobs, steps and chunks work, then builds a product catalog import on Spring Boot 4.1 and Spring Batch 6 step by step. It covers readers, processors, writers and listeners, skipping bad records, running jobs at startup or from a REST endpoint, the configuration options, the common pitfalls including the Spring Batch 6 API changes, and how to test a job.

1. What Is Spring Batch?

Spring Batch is a Spring project for batch processing: running a task over many records without a user waiting for each one. A batch job typically reads records from a file, a database or a queue, transforms or validates them, and writes the result somewhere else.

On top of that read-process-write loop, Spring Batch adds what production jobs need: processing in chunks (small groups of records committed together), a job repository that records every run, rules to skip or retry failing records, restarts from the point of failure, and detailed counts of what was read, written, filtered and skipped.

2. When You Need Spring Batch

A simple loop inside a scheduled method is fine for small, quick tasks. Spring Batch earns its place when:

  • The data is large. Thousands or millions of records can't be loaded into memory or written in one transaction.
  • Bad records must not stop the job. One malformed line in a 100,000-line file should be logged and skipped, not abort the import.
  • Runs must be restartable. If the job fails at record 60,000, the next run should continue from there, not start again.
  • Runs must not repeat by accident. Importing the same file twice should be refused.
  • You need an audit trail. Operations teams want to know when each run happened and what it processed.

3. How Spring Batch Works: Jobs, Steps and Chunks

Spring Batch organises work in a small hierarchy:

  • A job is the whole task, such as "import products". It consists of one or more steps.
  • A step is one phase of the job. Most steps are chunk-oriented: an ItemReader reads items one at a time, an ItemProcessor transforms or filters each one, and an ItemWriter writes them in groups.
  • A chunk is a group of items (100 in the example) that is processed and then written in one transaction. If writing fails, only that chunk is rolled back.
  • Job parameters identify a run, such as the file to import. One set of parameters is a job instance, and each attempt to run that instance is a job execution.
  • The job repository stores instances, executions, step counts and the reader's position, so the framework knows what already ran and where to resume.
How a chunk-oriented Spring Batch step runsJobOperator starts the job with its parameters. Inside the step, the ItemReader reads one item at a time until it has 100 items, the ItemProcessor processes each item and can filter it by returning null, and the ItemWriter writes the whole chunk, which is then committed in one transaction. The step repeats with the next chunk until read returns null. After each commit, the JobRepository stores the execution, the counts and the reader position in the BATCH tables. JobOperator start(job, params) Job → Step: one transaction per chunk (100 items) ItemReader read() one item up to 100 times ItemProcessor process(item) null = filtered ItemWriter write(chunk) then commit next chunk, until read() returns null a bad line can be skipped; a failed write rolls back only its chunk JobRepository (BATCH_* tables) instances, executions, counts, reader position updated after each commit
Figure 1: a step reads and processes items one by one, writes them as a chunk in one transaction, and records progress in the job repository after every commit.

4. Setup: Spring Batch with Spring Boot 4

Spring Boot 4.1 offers two starters. They differ in where the job repository keeps its data:

StarterJob repositoryRestartableUse when
spring-boot-starter-batchResourcelessJobRepository: nothing is stored and no database is neededNoShort jobs where a failed run can simply start over
spring-boot-starter-batch-jdbcA JDBC repository that stores metadata in BATCH_* tablesYesMost production jobs

The example uses the JDBC starter, H2 as the database, and Spring MVC for a trigger endpoint:

pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-batch-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-batch-jdbc-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

With an embedded database such as H2, Spring Boot creates the BATCH_* tables automatically. For PostgreSQL, MySQL and others, create them with your migration tool, or set spring.batch.jdbc.initialize-schema=always.

Version note: Spring Boot 4 ships Spring Batch 6, which changed several APIs:
  • ItemReader, ItemWriter and the other infrastructure classes moved from org.springframework.batch.item to org.springframework.batch.infrastructure.item.
  • Job moved to org.springframework.batch.core.job, Step to core.step, JobParameters to core.job.parameters, and listeners to core.listener.
  • JobLauncher is deprecated. Use JobOperator, which now extends it.
  • chunk(int, PlatformTransactionManager) is deprecated. Use chunk(int) followed by .transactionManager(...).
  • JobLauncherTestUtils is deprecated in favour of JobOperatorTestUtils.
  • Without a JDBC (or MongoDB) setup, the job repository is now resourceless.
Spring Boot 3.x projects use Spring Batch 5 and the older package names.

5. Core Building Blocks

The example imports this CSV file. It contains one line with an invalid price (abc) and one product with a price of zero:

sku,name,category,price
kb-100, Mechanical Keyboard ,keyboards,89.00
kb-200,Low-Profile Keyboard,keyboards,59.00
ms-100,Wireless Mouse,mice,25.50
ms-200,Gaming Mouse,mice,abc
mn-100,24-inch Monitor,monitors,159.00
mn-200,27-inch Monitor,monitors,0.00
mn-300,32-inch Monitor,monitors,329.00

5.1 ItemReader

An ItemReader returns one item per call, and null when there is no more input. FlatFileItemReaderBuilder builds a reader for delimited files, and targetType maps each line straight onto a record:

ProductImportJobConfig.java
// Step-scoped, so the file comes from the job parameters of each run
@Bean
@StepScope
public FlatFileItemReader<ProductCsvLine> productReader(
        @Value("#{jobParameters['inputFile']}") Resource inputFile) {
    return new FlatFileItemReaderBuilder<ProductCsvLine>()
            .name("productReader")
            .resource(inputFile)
            .linesToSkip(1) // header row
            .delimited()
            .names("sku", "name", "category", "price")
            .targetType(ProductCsvLine.class)
            .build();
}

// ProductCsvLine.java
public record ProductCsvLine(String sku, String name, String category, BigDecimal price) {
}

@StepScope creates a new reader for each step execution, which allows the file name to come from the job parameters through #{jobParameters['inputFile']}. Spring converts the string, such as classpath:import/products.csv or file:/data/products.csv, into a Resource.

5.2 ItemProcessor

An ItemProcessor transforms an input item into an output item. Returning null filters the item out: it is counted but never written.

ProductItemProcessor.java
public class ProductItemProcessor implements ItemProcessor<ProductCsvLine, Product> {

    private static final Logger log = LoggerFactory.getLogger(ProductItemProcessor.class);

    @Override
    public Product process(ProductCsvLine line) {
        if (line.price().compareTo(BigDecimal.ZERO) <= 0) {
            log.warn("Filtering {}: price must be positive", line.sku());
            return null; // returning null filters the item out
        }
        return new Product(
                line.sku().trim().toUpperCase(Locale.ROOT),
                line.name().trim(),
                line.category().trim().toLowerCase(Locale.ROOT),
                line.price());
    }
}

5.3 ItemWriter

An ItemWriter receives a whole chunk at once, so it can write efficiently. JdbcBatchItemWriter sends one JDBC batch per chunk:

ProductImportJobConfig.java
@Bean
public JdbcBatchItemWriter<Product> productWriter(DataSource dataSource) {
    return new JdbcBatchItemWriterBuilder<Product>()
            .dataSource(dataSource)
            .sql("INSERT INTO products (sku, name, category, price) VALUES (:sku, :name, :category, :price)")
            .itemSqlParameterSourceProvider(SimplePropertySqlParameterSource::new) // reads record components
            .build();
}

Spring Framework's SimplePropertySqlParameterSource reads the record's components (sku(), name() and so on) to fill the named parameters. For JPA entities, JpaItemWriter is the equivalent.

5.4 Chunk-Oriented Step

A step ties the reader, processor and writer together, with a chunk size and a transaction manager:

ProductImportJobConfig.java
@Bean
public Step importProductsStep(JobRepository jobRepository, PlatformTransactionManager transactionManager,
                               FlatFileItemReader<ProductCsvLine> productReader,
                               JdbcBatchItemWriter<Product> productWriter) {
    return new StepBuilder("importProductsStep", jobRepository)
            .<ProductCsvLine, Product>chunk(100)
            .reader(productReader)
            .processor(new ProductItemProcessor())
            .writer(productWriter)
            .faultTolerant()
            .skip(FlatFileParseException.class)
            .skipLimit(10)
            .skipListener(new ProductSkipListener())
            .transactionManager(transactionManager)
            .build();
}

chunk(100) means up to 100 items are read and processed, then written and committed together. The transaction management tutorial explains the transaction manager that Spring Boot provides.

5.5 Job and Job Parameters

ProductImportJobConfig.java
@Bean
public Job productImportJob(JobRepository jobRepository, Step importProductsStep) {
    return new JobBuilder("productImportJob", jobRepository)
            .start(importProductsStep)
            .listener(new ImportSummaryListener())
            .build();
}

The parameters of a run decide which job instance it belongs to. Running with inputFile=classpath:import/products.csv twice targets the same instance, and once that instance has completed, Spring Batch refuses to run it again (section 10.1). Parameters are built with JobParametersBuilder, as in the service in section 7.

5.6 Listeners

Listeners run code around jobs, steps, chunks and individual items. A JobExecutionListener is a good place for a summary, and a SkipListener records exactly what was skipped:

ImportSummaryListener.java and ProductSkipListener.java
public class ImportSummaryListener implements JobExecutionListener {

    private static final Logger log = LoggerFactory.getLogger(ImportSummaryListener.class);

    @Override
    public void afterJob(JobExecution jobExecution) {
        for (StepExecution step : jobExecution.getStepExecutions()) {
            log.info("{} finished with {}: read={}, written={}, filtered={}, skipped={}",
                    step.getStepName(), step.getStatus(), step.getReadCount(), step.getWriteCount(),
                    step.getFilterCount(), step.getSkipCount());
        }
    }
}


public class ProductSkipListener implements SkipListener<ProductCsvLine, Product> {

    private static final Logger log = LoggerFactory.getLogger(ProductSkipListener.class);

    @Override
    public void onSkipInRead(Throwable error) {
        if (error instanceof FlatFileParseException parseError) {
            log.warn("Skipped line {}: {}", parseError.getLineNumber(), parseError.getInput());
        }
    }
}

6. Skip, Retry and Restart

Skip ignores records that fail with listed exceptions, up to a limit. The example skips lines the reader can't parse (FlatFileParseException) and fails the job if more than 10 lines are bad. A sensible limit stops a completely broken file from "succeeding" with nothing imported.

Retry tries an item again when the error is likely temporary, such as a deadlock or a network timeout. It is configured on the same builder with .retry(TransientDataAccessException.class).retryLimit(3) after faultTolerant(). In Spring Batch 6, retry is built on Spring Framework 7's own retry support (RetryPolicy) instead of the separate Spring Retry library.

Restart needs the JDBC job repository. When a step fails, the repository keeps the execution as FAILED together with the reader's position from the last committed chunk. Running the job again with the same parameters, or calling jobOperator.restart(failedExecution), continues from that position instead of from the first line. Completed chunks are not read or written again.

7. A Complete Example: Product Catalog Import

The job imports a supplier's product file into a products table: it skips unparseable lines, filters products with a zero price, normalises SKUs and names, and logs a summary. A REST endpoint starts it on demand. It runs on Spring Boot 4.1, Spring Batch 6 and Java 21.

7.1 Job Configuration

package com.javabytechie.catalog.importer;

import javax.sql.DataSource;

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.StepBuilder;
import org.springframework.batch.infrastructure.item.database.JdbcBatchItemWriter;
import org.springframework.batch.infrastructure.item.database.builder.JdbcBatchItemWriterBuilder;
import org.springframework.batch.infrastructure.item.file.FlatFileItemReader;
import org.springframework.batch.infrastructure.item.file.FlatFileParseException;
import org.springframework.batch.infrastructure.item.file.builder.FlatFileItemReaderBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import org.springframework.jdbc.core.namedparam.SimplePropertySqlParameterSource;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
public class ProductImportJobConfig {

    @Bean
    public Job productImportJob(JobRepository jobRepository, Step importProductsStep) {
        return new JobBuilder("productImportJob", jobRepository)
                .start(importProductsStep)
                .listener(new ImportSummaryListener())
                .build();
    }

    @Bean
    public Step importProductsStep(JobRepository jobRepository, PlatformTransactionManager transactionManager,
                                   FlatFileItemReader<ProductCsvLine> productReader,
                                   JdbcBatchItemWriter<Product> productWriter) {
        return new StepBuilder("importProductsStep", jobRepository)
                .<ProductCsvLine, Product>chunk(100)
                .reader(productReader)
                .processor(new ProductItemProcessor())
                .writer(productWriter)
                .faultTolerant()
                .skip(FlatFileParseException.class)
                .skipLimit(10)
                .skipListener(new ProductSkipListener())
                .transactionManager(transactionManager)
                .build();
    }

    // Step-scoped, so the file comes from the job parameters of each run
    @Bean
    @StepScope
    public FlatFileItemReader<ProductCsvLine> productReader(
            @Value("#{jobParameters['inputFile']}") Resource inputFile) {
        return new FlatFileItemReaderBuilder<ProductCsvLine>()
                .name("productReader")
                .resource(inputFile)
                .linesToSkip(1) // header row
                .delimited()
                .names("sku", "name", "category", "price")
                .targetType(ProductCsvLine.class)
                .build();
    }

    @Bean
    public JdbcBatchItemWriter<Product> productWriter(DataSource dataSource) {
        return new JdbcBatchItemWriterBuilder<Product>()
                .dataSource(dataSource)
                .sql("INSERT INTO products (sku, name, category, price) VALUES (:sku, :name, :category, :price)")
                .itemSqlParameterSourceProvider(SimplePropertySqlParameterSource::new) // reads record components
                .build();
    }
}

7.2 Records, Processor and Listeners

package com.javabytechie.catalog.importer;

import java.math.BigDecimal;

// One line of the input file, exactly as read
public record ProductCsvLine(String sku, String name, String category, BigDecimal price) {
}

7.3 Service and Controller

The service builds the job parameters and starts the job through JobOperator. By default, start runs the job on the calling thread and returns when it finishes, so the response already contains the final counts. If the file was already imported, the controller returns 409 Conflict.

package com.javabytechie.catalog.importer;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.JobExecution;
import org.springframework.batch.core.job.JobExecutionException;
import org.springframework.batch.core.job.parameters.JobParameters;
import org.springframework.batch.core.job.parameters.JobParametersBuilder;
import org.springframework.batch.core.launch.JobOperator;
import org.springframework.stereotype.Service;

@Service
public class ProductImportService {

    private static final Logger log = LoggerFactory.getLogger(ProductImportService.class);

    private final JobOperator jobOperator;
    private final Job productImportJob;

    public ProductImportService(JobOperator jobOperator, Job productImportJob) {
        this.jobOperator = jobOperator;
        this.productImportJob = productImportJob;
    }

    public ImportResult importProducts(String inputFile) throws JobExecutionException {
        JobParameters parameters = new JobParametersBuilder()
                .addString("inputFile", inputFile)
                .toJobParameters();

        JobExecution execution = jobOperator.start(productImportJob, parameters);
        log.info("Import of {} ended with status {}", inputFile, execution.getStatus());
        return ImportResult.from(execution);
    }
}

7.4 Run It

Start the app with ./mvnw spring-boot:run and trigger an import:

Terminal
curl -X POST 'http://localhost:8080/api/imports/products?file=classpath:import/products.csv'

{"executionId":1,"status":"COMPLETED","read":6,"written":5,"filtered":1,"skipped":1}
Console
INFO  o.s.b.c.l.s.TaskExecutorJobLauncher  : Job: [SimpleJob: [name=productImportJob]] launched with the following parameters: [{JobParameter{name='inputFile', value=classpath:import/products.csv, type=class java.lang.String, identifying=true}}]
WARN  c.j.c.importer.ProductSkipListener   : Skipped line 5: ms-200,Gaming Mouse,mice,abc
WARN  c.j.c.importer.ProductItemProcessor  : Filtering mn-200: price must be positive
INFO  o.s.batch.core.step.AbstractStep     : Step: [importProductsStep] executed in 19ms
INFO  c.j.c.importer.ImportSummaryListener : importProductsStep finished with COMPLETED: read=6, written=5, filtered=1, skipped=1
INFO  c.j.c.importer.ProductImportService  : Import of classpath:import/products.csv ended with status COMPLETED

Seven data lines produced six reads, because the unparseable line was skipped while reading. Of those six, one was filtered for its zero price and five were written. Run the same import again:

Terminal
curl -i -X POST 'http://localhost:8080/api/imports/products?file=classpath:import/products.csv'

HTTP/1.1 409
{"detail":"A job instance already exists and is complete for identifying parameters={JobParameter{name='inputFile', value=classpath:import/products.csv, type=class java.lang.String, identifying=true}}.  If you want to run this job again, change the parameters.","instance":"/api/imports/products","status":409,"title":"Conflict"}

8. Running Jobs at Startup or On Demand

By default (spring.batch.job.enabled=true), Spring Boot runs the job when the application starts, and passes key=value command-line arguments as job parameters:

Terminal and Console
java -jar target/catalog-import-1.0.0.jar --spring.batch.job.enabled=true inputFile=classpath:import/products.csv

INFO  o.s.b.b.a.JobLauncherApplicationRunner : Running default command line with: [inputFile=classpath:import/products.csv]
INFO  o.s.b.c.l.s.TaskExecutorJobLauncher    : Job: [SimpleJob: [name=productImportJob]] launched with the following parameters: [{JobParameter{name='inputFile', value=classpath:import/products.csv, type=class java.lang.String, identifying=true}}]
INFO  c.j.c.importer.ImportSummaryListener   : importProductsStep finished with COMPLETED: read=6, written=5, filtered=1, skipped=1

That suits a container or cron-style deployment, where the process starts, runs one job and exits. If the context has more than one job, you must set spring.batch.job.name to choose which one runs. For a long-running service that triggers jobs on demand, as in the example, set spring.batch.job.enabled=false and call JobOperator.start(job, parameters) from a REST endpoint, a scheduled method or a message listener. To return immediately and run the job in the background, give the JobOperator an asynchronous TaskExecutor and let clients poll the execution status.

9. Configuration Options

PropertyDefaultWhat it does
spring.batch.job.enabledtrueRuns jobs automatically at startup
spring.batch.job.nameNot setThe job to run at startup. Required when several jobs exist
spring.batch.jdbc.initialize-schemaembeddedCreates the BATCH_* tables: embedded (only for H2, HSQL and Derby), always or never
spring.batch.jdbc.table-prefixBATCH_ (Spring Batch default)Prefix for the metadata tables, for example to keep them in a separate schema
spring.batch.jdbc.isolation-level-for-createNot setTransaction isolation used when creating job metadata

The chunk size, skip limit and retry limit are set in code on the step builder, as shown in section 5.4.

10. Common Pitfalls and How to Fix Them

10.1 "A job instance already exists and is complete"

A completed job instance can't run again with the same identifying parameters. That is a safety feature: the same file isn't imported twice. When a rerun is intended, add a parameter that makes each run unique, such as .addLocalDateTime("requestedAt", LocalDateTime.now()), or include a business date in the parameters. Note that if you attach a JobParametersIncrementer such as RunIdIncrementer to a job, Spring Batch 6 ignores the parameters you pass and logs a warning.

10.2 The Job Runs at Startup Unexpectedly

With the default spring.batch.job.enabled=true, every application start runs the job, including in tests and every new replica in a cluster. Set it to false when jobs are triggered on demand, as in the example.

10.3 Using Spring Batch 5 Code with Spring Boot 4

Older examples import org.springframework.batch.item.ItemReader or call chunk(100, transactionManager) and JobLauncher.run(...). On Spring Batch 6, the first fails to compile and the others are deprecated. How to fix it: update the imports to the new packages (see the version note in section 4), use chunk(100) with .transactionManager(...), and launch with JobOperator.start(job, parameters).

10.4 Job Parameters in a Singleton Bean

#{jobParameters['inputFile']} only works in beans created for a step or job, so the reader needs @StepScope (or @JobScope). Without it, the reader is a singleton created at startup, when no job is running, so the expression has no job parameters to read.

10.5 Expecting Restarts Without a Database

With spring-boot-starter-batch alone, Spring Batch 6 uses a resourceless repository that stores nothing. A failed run can't be restarted from where it stopped, and nothing stops the same parameters from running twice. How to fix it: use spring-boot-starter-batch-jdbc for jobs that need restarts or duplicate protection.

10.6 Choosing the Wrong Chunk Size

A chunk size of 1 commits after every item and is slow. A huge chunk holds many items in memory, keeps a transaction open for a long time, and rolls back more work when one write fails. Start around 100 to 1,000 and measure with your real data.

11. Testing a Spring Batch Job

@SpringBatchTest adds batch test helpers to a @SpringBootTest, including JobOperatorTestUtils, which runs the job (or a single step) and returns the execution. The test checks the table before and after the run, along with the step counts:

ProductImportJobTest.java
package com.javabytechie.catalog.importer;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.job.JobExecution;
import org.springframework.batch.core.job.parameters.JobParameters;
import org.springframework.batch.core.job.parameters.JobParametersBuilder;
import org.springframework.batch.core.step.StepExecution;
import org.springframework.batch.test.JobOperatorTestUtils;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;

@SpringBootTest
@SpringBatchTest
class ProductImportJobTest {

    @Autowired
    private JobOperatorTestUtils jobOperatorTestUtils;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @AfterEach
    void cleanUp() {
        jdbcTemplate.update("DELETE FROM products");
    }

    @Test
    void importsValidLinesSkipsBadOnesAndFiltersZeroPrices() throws Exception {
        JobParameters parameters = new JobParametersBuilder()
                .addString("inputFile", "classpath:import/products-test.csv")
                .toJobParameters();

        // Before: the table is empty
        assertThat(countProducts()).isZero();

        JobExecution execution = jobOperatorTestUtils.startJob(parameters);
        StepExecution step = execution.getStepExecutions().iterator().next();

        // After: 5 products written, 1 bad line skipped, 1 zero price filtered
        assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
        assertThat(step.getReadCount()).isEqualTo(6);
        assertThat(step.getWriteCount()).isEqualTo(5);
        assertThat(step.getSkipCount()).isEqualTo(1);
        assertThat(step.getFilterCount()).isEqualTo(1);
        assertThat(countProducts()).isEqualTo(5);
        assertThat(jdbcTemplate.queryForObject(
                "SELECT name FROM products WHERE sku = 'KB-100'", String.class))
                .isEqualTo("Mechanical Keyboard");
    }

    private int countProducts() {
        return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM products", Integer.class);
    }
}
  • The test uses its own copy of the CSV file (products-test.csv), so changing the sample file can't break it.
  • @AfterEach empties the table, because a full @SpringBootTest doesn't roll back the job's commits.
  • The spring-boot-starter-batch-jdbc-test dependency provides @SpringBatchTest and the Spring Boot test support.

See the Spring Boot testing tutorial for more on @SpringBootTest.

12. When to Use Spring Batch and When to Avoid It

Good fitPoor fit
Nightly imports and exports of large filesHandling one HTTP request or one message at a time
Jobs that must skip bad records and report themA quick clean-up query that runs in milliseconds
Long jobs that must resume after a failureContinuous event streams (use Kafka Streams or a message listener)
End-of-day settlement, billing and reconciliationTasks with a handful of records and no failure handling needs

13. Best Practices

  • Use the JDBC job repository in production (spring-boot-starter-batch-jdbc), so runs are recorded, restartable and protected against duplicates.
  • Make job parameters meaningful, such as the input file or business date, so each logical run is one job instance.
  • Keep readers and writers in the framework, using the provided FlatFileItemReader, JdbcBatchItemWriter and friends, which already track restart state.
  • Put business rules in the processor, and filter invalid items by returning null.
  • Skip only expected errors, with a limit, and record every skipped item with a SkipListener.
  • Retry only temporary failures, such as timeouts and deadlocks, never validation errors.
  • Write idempotently where possible, for example with an upsert, so a restarted chunk can't create duplicates.
  • Disable startup runs in services with spring.batch.job.enabled=false, and start jobs explicitly through JobOperator.
  • Use the Spring Batch 6 APIs: JobOperator, chunk(int) with .transactionManager(...), and JobOperatorTestUtils.
  • Test jobs end to end with @SpringBatchTest, checking the data and the read, write, filter and skip counts.

14. Summary

Spring Batch turns large background tasks into jobs made of steps, where each chunk-oriented step reads, processes and writes items in transactional chunks. With Spring Boot 4 and Spring Batch 6, add spring-boot-starter-batch-jdbc for a restartable job repository, build steps with new StepBuilder(...).chunk(size), configure skip limits and listeners for bad data, and start jobs with JobOperator using parameters that identify each run. Disable automatic startup runs in long-running services, and test jobs with @SpringBatchTest by checking both the data and the step counts. The main recommendation: rely on the framework's readers, writers, skip rules and job repository rather than hand-written loops, so your jobs are safe to rerun, restart and monitor.