Spring Batch
Use when building batch jobs, ETL pipelines, scheduled imports/exports, or any chunk-oriented bulk processing with Spring Batch. Covers the Spring Batch 5 / Boot 3 builder API, restartability and idempotent job parameters, reader/writer thread-safety, fault tolerance, and chunk transaction boundaries.
View SKILL.mdSpring Boot 3.x ships Spring Batch 5. The API changed significantly from 4.x — most online examples are wrong. The two rules that break the most agent-generated code:
- Do NOT add
@EnableBatchProcessing. Boot auto-configures theJobRepository,JobLauncher, and transaction manager. Adding@EnableBatchProcessingdisables that auto-configuration and you lose all the wired beans. JobBuilderFactoryandStepBuilderFactoryare gone. Build jobs and steps withnew JobBuilder(name, jobRepository)/new StepBuilder(name, jobRepository), injecting theJobRepositoryandPlatformTransactionManagerBoot already created.
Job & Step (Spring Batch 5 API)
@Configuration
@RequiredArgsConstructor
public class OrderExportJobConfig {
@Bean
public Job orderExportJob(JobRepository jobRepository, Step exportStep) {
return new JobBuilder("orderExportJob", jobRepository)
.incrementer(new RunIdIncrementer()) // lets the same job be re-run; see "Idempotency"
.start(exportStep)
.build();
}
@Bean
public Step exportStep(JobRepository jobRepository,
PlatformTransactionManager txManager, // Boot's, injected — do NOT new one up
ItemReader<Order> reader,
ItemProcessor<Order, OrderRow> processor,
ItemWriter<OrderRow> writer) {
return new StepBuilder("exportStep", jobRepository)
.<Order, OrderRow>chunk(500, txManager) // chunk size is the commit interval — and a TX boundary
.reader(reader)
.processor(processor)
.writer(writer)
.faultTolerant()
.skip(FlatFileParseException.class)
.skipLimit(50)
.build();
}
}
chunk(500, txManager) means: read 500 items, process each, hand the list of 500 to the writer,
commit one transaction, repeat. The chunk is the unit of restart and the unit of rollback.
Idempotency & Restartability — the #1 operational gotcha
A JobInstance is identified by its identifying JobParameters. Launch the same job with the
same identifying parameters twice and you get:
JobInstanceAlreadyCompleteException: A job instance already exists and is complete
This is by design — Batch refuses to re-run completed work. Two ways to handle it:
// Option A — RunIdIncrementer on the job (above) + JobLauncherApplicationRunner bumps run.id each launch.
// Option B — add a unique identifying parameter yourself when launching:
JobParameters params = new JobParametersBuilder()
.addString("status", "COMPLETED") // identifying — part of the instance key
.addLong("run.id", System.currentTimeMillis()) // identifying & unique — makes each run a new instance
.toJobParameters();
Mark a parameter non-identifying with the false flag when it's metadata that shouldn't change
the instance identity (e.g. a request id you log but don't key on):
.addString("requestId", requestId, false) // non-identifying — excluded from the instance key
A failed job, by contrast, is resumed when relaunched with the same parameters — it skips completed steps and restarts the failed step from the last committed chunk. That is the point of the metadata tables. Don't defeat it by always passing a unique parameter if you want resume-on-failure.
ItemReader — sort key and thread-safety
@Bean
@StepScope // required: late-binds jobParameters at step execution, not context startup
public JpaPagingItemReader<Order> orderReader(
EntityManagerFactory emf,
@Value("#{jobParameters['status']}") String status) {
return new JpaPagingItemReaderBuilder<Order>()
.name("orderReader")
.entityManagerFactory(emf)
.queryString("SELECT o FROM Order o WHERE o.status = :status ORDER BY o.id") // ORDER BY is MANDATORY
.parameterValues(Map.of("status", OrderStatus.valueOf(status)))
.pageSize(500) // keep pageSize == chunk size
.build();
}
- Paging readers require a deterministic
ORDER BYon a unique column. Without it the DB returns rows in arbitrary order across pages → rows get skipped or processed twice. This is silent data corruption, not an error. JdbcCursorItemReaderis NOT thread-safe.JdbcPagingItemReader/JpaPagingItemReaderare safe for multi-threaded steps. For a non-thread-safe reader in a multi-threaded step, wrap it inSynchronizedItemStreamReader.- Don't mutate the column you page on inside the same job. If the writer flips
statusfromPENDINGtoDONEwhile the reader pagesWHERE status = 'PENDING' ORDER BY id, the result set shifts under you and pages are missed. Read into a stable snapshot, page by immutableid, or use a cursor reader.
ItemProcessor — returning null filters
@Component
public class OrderProcessor implements ItemProcessor<Order, OrderRow> {
@Override
public OrderRow process(Order order) {
if (order.getTotal().isZero()) {
return null; // ⚠️ null = FILTER this item; it is NOT written and NOT an error
}
return OrderRow.from(order);
}
}
Returning null silently drops the item from the chunk. That's a feature (filtering) but a footgun
if you returned null by accident expecting it to pass through.
ItemWriter — Spring Batch 5 signature change
In 5.x the writer receives a Chunk<? extends T>, not List<? extends T>:
@Override
public void write(Chunk<? extends OrderRow> chunk) { // was List<? extends T> in 4.x
repository.saveAll(chunk.getItems());
}
For SQL writes, prefer the batched JDBC writer over per-row saves — it uses one addBatch():
@Bean
public JdbcBatchItemWriter<OrderRow> orderWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<OrderRow>()
.dataSource(dataSource)
.sql("INSERT INTO order_export (id, total) VALUES (:id, :total)")
.beanMapped()
.build();
}
The writer runs inside the chunk transaction. Never fire emails, publish to Kafka, or call webhooks from a writer — if the chunk rolls back you've already sent it. Bind side effects to the job completion instead (see [[transactional-patterns]] and the listener below).
Launching jobs
Boot runs every Job bean on startup by default. For scheduled or on-demand jobs, turn that off
and launch explicitly:
spring:
batch:
job:
enabled: false # don't run jobs on app startup; we trigger them ourselves
jdbc:
initialize-schema: never # manage BATCH_* tables with Flyway in prod (see below)
@Component
@RequiredArgsConstructor
public class OrderExportScheduler {
private final JobLauncher jobLauncher;
private final Job orderExportJob;
@Scheduled(cron = "0 0 2 * * *")
public void runNightly() throws JobExecutionException {
JobParameters params = new JobParametersBuilder()
.addString("status", "COMPLETED")
.addLong("run.id", System.currentTimeMillis())
.toJobParameters();
jobLauncher.run(orderExportJob, params);
}
}
The default JobLauncher is synchronous (SyncTaskExecutor) — jobLauncher.run(...) blocks the
@Scheduled thread until the whole job finishes. For fire-and-forget, configure the launcher with an
async TaskExecutor, or trigger from a request thread only if you accept the block.
Metadata schema in production
Spring Batch needs its BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION, BATCH_STEP_EXECUTION, … tables.
initialize-schema: always is fine for dev/embedded DBs but don't let Batch DDL your production
database on startup. Set initialize-schema: never and ship the schema as a versioned
[[flyway-migrations]] migration (the canonical DDL lives in org/springframework/batch/core/schema-*.sql
inside spring-batch-core).
Listeners — work that must run after the job, not per chunk
@Bean
public Job orderExportJob(JobRepository jobRepository, Step exportStep) {
return new JobBuilder("orderExportJob", jobRepository)
.incrementer(new RunIdIncrementer())
.listener(new JobExecutionListener() {
@Override public void afterJob(JobExecution exec) {
if (exec.getStatus() == BatchStatus.COMPLETED) {
notifier.notifyExportReady(exec.getJobParameters()); // safe: all chunks committed
}
}
})
.start(exportStep)
.build();
}
Don't reach for Batch when you don't need it
Spring Batch earns its complexity (metadata tables, restart, chunking) on large, restartable,
auditable bulk jobs. For a quick one-off async task, @Async or a @Scheduled loop is lighter.
For durable background jobs with retry, a job queue is a better fit. Match the tool to the scale.
Gotchas
- Agent adds
@EnableBatchProcessing— on Boot 3 it disables auto-config; remove it, just injectJobRepository - Agent uses
JobBuilderFactory/StepBuilderFactory— removed in Batch 5; usenew JobBuilder(name, repo)/new StepBuilder(name, repo) - Agent calls
.chunk(500)without a transaction manager — Batch 5 requires.chunk(500, txManager) - Agent writes
write(List<? extends T> items)— Batch 5 signature iswrite(Chunk<? extends T> chunk) - Agent re-runs a job with identical parameters and hits
JobInstanceAlreadyCompleteException— addRunIdIncrementeror a unique identifying param - Agent adds a unique param every run on a job that should resume-on-failure — kills restartability; only add it when you want a fresh instance
- Agent writes a paging reader query with no
ORDER BY(or a non-unique one) — pages skip/duplicate rows silently; order by a unique column - Agent uses
JdbcCursorItemReaderin a multi-threaded step — not thread-safe; use a paging reader orSynchronizedItemStreamReader - Agent pages on a column the writer mutates in the same job — result set shifts; page on an immutable id
- Agent returns
nullfrom a processor expecting pass-through —nullfilters (drops) the item - Agent sends email / publishes events from the
ItemWriter— runs inside the chunk TX; do it in anafterJoblistener - Agent forgets
@StepScopeon a reader that readsjobParameters—@Value("#{jobParameters[...]}")only binds in step scope - Agent leaves jobs running on startup in a web app — set
spring.batch.job.enabled=falseand launch explicitly - Agent lets
initialize-schema: alwaysDDL the prod DB — usenever+ a Flyway migration for theBATCH_*tables
Examples & templates
bad-batch-config.javaexamples
// ❌ BAD — every line here is a Spring Batch 4.x habit that breaks on Boot 3 / Batch 5,
// or a silent data-correctness bug.
@Configuration
@EnableBatchProcessing // ❌ on Boot 3 this DISABLES auto-config — JobRepository/JobLauncher vanish
@RequiredArgsConstructor
public class OrderExportJobConfig {
private final JobBuilderFactory jobBuilderFactory; // ❌ removed in Batch 5 — won't compile
private final StepBuilderFactory stepBuilderFactory; // ❌ removed in Batch 5 — won't compile
@Bean
public Job orderExportJob(Step exportStep) {
return jobBuilderFactory.get("orderExportJob") // ❌ factory API gone
.start(exportStep)
.build();
// ❌ no incrementer → re-running with same params throws JobInstanceAlreadyCompleteException
}
@Bean
public Step exportStep(ItemReader<Order> reader,
ItemWriter<OrderRow> writer) {
return stepBuilderFactory.get("exportStep")
.<Order, OrderRow>chunk(500) // ❌ Batch 5 needs chunk(500, txManager)
.reader(reader)
.writer(writer)
.build();
}
@Bean
public JdbcCursorItemReader<Order> reader(DataSource ds) { // ❌ cursor reader is NOT thread-safe
JdbcCursorItemReader<Order> r = new JdbcCursorItemReader<>();
r.setDataSource(ds);
r.setSql("SELECT id, total FROM orders WHERE status = 'PENDING'"); // ❌ no ORDER BY — pages drift
r.setRowMapper(new OrderRowMapper());
return r;
// ❌ not @StepScope, so it can't read jobParameters
}
@Bean
public ItemWriter<OrderRow> writer(EmailService email, OrderExportRepository repo) {
return items -> { // ❌ Batch 5 passes Chunk<>, not List<>
repo.saveAll(items);
email.send("export-done@co", "batch finished"); // ❌ inside chunk TX — fires even if chunk rolls back
};
}
}good-batch-config.javaexamples
// ✅ GOOD — Spring Batch 5 / Boot 3: no @EnableBatchProcessing, builders take JobRepository,
// chunk takes a transaction manager, reader is @StepScope with a deterministic sort, job is restartable.
@Configuration
@RequiredArgsConstructor
public class OrderExportJobConfig {
@Bean
public Job orderExportJob(JobRepository jobRepository, Step exportStep) {
return new JobBuilder("orderExportJob", jobRepository)
.incrementer(new RunIdIncrementer()) // re-runnable; resumes a failed run on same params
.listener(new ExportCompletionListener()) // side effects AFTER the job, never in the writer
.start(exportStep)
.build();
}
@Bean
public Step exportStep(JobRepository jobRepository,
PlatformTransactionManager txManager, // injected — Boot already created it
ItemReader<Order> orderReader,
ItemProcessor<Order, OrderRow> orderProcessor,
ItemWriter<OrderRow> orderWriter) {
return new StepBuilder("exportStep", jobRepository)
.<Order, OrderRow>chunk(500, txManager) // commit interval == transaction boundary
.reader(orderReader)
.processor(orderProcessor)
.writer(orderWriter)
.faultTolerant()
.skip(FlatFileParseException.class)
.skipLimit(50) // tolerate up to 50 bad rows, then fail the step
.build();
}
@Bean
@StepScope // late-binds jobParameters at execution time
public JpaPagingItemReader<Order> orderReader(
EntityManagerFactory emf,
@Value("#{jobParameters['status']}") String status) {
return new JpaPagingItemReaderBuilder<Order>()
.name("orderReader")
.entityManagerFactory(emf)
.queryString("SELECT o FROM Order o WHERE o.status = :status ORDER BY o.id") // unique sort key
.parameterValues(Map.of("status", OrderStatus.valueOf(status)))
.pageSize(500) // matches chunk size
.build();
}
@Bean
public ItemProcessor<Order, OrderRow> orderProcessor() {
return order -> order.getTotal().isZero() ? null // null = filter; documented intent
: OrderRow.from(order);
}
@Bean
public JdbcBatchItemWriter<OrderRow> orderWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<OrderRow>()
.dataSource(dataSource)
.sql("INSERT INTO order_export (id, total) VALUES (:id, :total)")
.beanMapped()
.build();
}
}ChunkJobConfig.javatemplates
// Copy-paste starting point for a chunk-oriented Spring Batch 5 job (Spring Boot 3.x).
// Replace Order / OrderRow / the reader query and writer SQL with your domain.
// Do NOT add @EnableBatchProcessing — Boot auto-configures JobRepository + transaction manager.
@Configuration
@RequiredArgsConstructor
public class ChunkJobConfig {
private static final int CHUNK_SIZE = 500;
@Bean
public Job exampleJob(JobRepository jobRepository, Step exampleStep) {
return new JobBuilder("exampleJob", jobRepository)
.incrementer(new RunIdIncrementer())
.start(exampleStep)
.build();
}
@Bean
public Step exampleStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
ItemReader<Order> reader,
ItemProcessor<Order, OrderRow> processor,
ItemWriter<OrderRow> writer) {
return new StepBuilder("exampleStep", jobRepository)
.<Order, OrderRow>chunk(CHUNK_SIZE, txManager)
.reader(reader)
.processor(processor)
.writer(writer)
.faultTolerant()
.retry(TransientDataAccessException.class).retryLimit(3) // retry transient failures
.skip(FlatFileParseException.class).skipLimit(50) // tolerate bad input rows
.build();
}
@Bean
@StepScope
public JpaPagingItemReader<Order> reader(
EntityManagerFactory emf,
@Value("#{jobParameters['status']}") String status) {
return new JpaPagingItemReaderBuilder<Order>()
.name("exampleReader")
.entityManagerFactory(emf)
.queryString("SELECT o FROM Order o WHERE o.status = :status ORDER BY o.id") // unique sort key
.parameterValues(Map.of("status", OrderStatus.valueOf(status)))
.pageSize(CHUNK_SIZE)
.build();
}
@Bean
public ItemProcessor<Order, OrderRow> processor() {
return OrderRow::from; // return null from a processor to FILTER an item out of the chunk
}
@Bean
public JdbcBatchItemWriter<OrderRow> writer(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<OrderRow>()
.dataSource(dataSource)
.sql("INSERT INTO order_export (id, total) VALUES (:id, :total)")
.beanMapped()
.build();
}
}JobScheduler.javatemplates
// Copy-paste starting point for triggering a Spring Batch job on a schedule.
// Pair with application.yml:
// spring.batch.job.enabled: false # don't auto-run jobs on startup
// spring.batch.jdbc.initialize-schema: never # manage BATCH_* tables via Flyway in prod
//
// The default JobLauncher is SYNCHRONOUS — run(...) blocks until the job finishes.
// Inject an async TaskExecutor-backed launcher if you need fire-and-forget.
@Component
@RequiredArgsConstructor
@Slf4j
public class JobScheduler {
private final JobLauncher jobLauncher;
private final Job exampleJob;
@Scheduled(cron = "0 0 2 * * *") // 02:00 daily
public void run() {
JobParameters params = new JobParametersBuilder()
.addString("status", "COMPLETED") // identifying — part of the JobInstance key
.addLong("run.id", System.currentTimeMillis()) // identifying + unique — forces a fresh instance
// .addString("requestId", id, false) // non-identifying — logged, not part of the key
.toJobParameters();
try {
JobExecution execution = jobLauncher.run(exampleJob, params);
log.info("Job {} finished with status {}", exampleJob.getName(), execution.getStatus());
} catch (JobExecutionException e) {
// JobInstanceAlreadyCompleteException, JobRestartException, etc.
log.error("Failed to launch {}", exampleJob.getName(), e);
}
}
}Verification scope
- Compiled sources
- No
- Runtime checks
- No
Coverage describes checked-in fixtures, not a guarantee for every snippet or project.
Coverage matrix & agent evidenceADD TO YOUR AGENTInstall Boot 3 skills
Install Boot 3 skills
claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-3-skills@spring-boot-skillscodex plugin marketplace add rrezartprebreza/spring-boot-skills
codex plugin add spring-boot-3-skills@spring-boot-skills