It’s 2 a.m., a scheduled job that imports a CSV of products into your database has just died halfway through, and someone is going to ask you a question you need to answer correctly: is it safe to just run it again? If the answer is “I don’t know, let’s see what happens,” you don’t have a batch job yet — you have a script that got lucky every time until it didn’t.
Spring Batch’s whole reason to exist is to make that 2 a.m. question have a real answer. This article builds one job — import products from a CSV, then report how many made it in — and then deliberately breaks it, on purpose, with a row that fails a database constraint partway through a run. You’ll see exactly how much work is lost, watch a second process resume from precisely where the first one stopped, and see the two ways to make that not happen again: fix the data and restart, or configure the step to skip the bad row and keep going. Every number below came out of a real run of a real Spring Boot application — nothing is invented for the sake of a clean example.
This is also, incidentally, a good moment to be learning Spring Batch, because the ground shifted under it recently. Spring Boot 4.1 ships Spring Batch 6.0, and 6.0 rewrote enough of the internals — a new chunk-processing model, a reorganized package structure, batch autoconfiguration split into separate JDBC and MongoDB modules — that a fair amount of what you’ll find by searching no longer compiles, or compiles but quietly does something different than it used to. Where that matters, this article says so and shows the real jar, not a summary of one.
Versions used throughout. Spring Boot 4.1.1 (Spring Boot 4.1.0 went GA on 10 June 2026), which manages Spring Batch 6.0.5 and Spring Framework 7.0.9 — confirmed against spring-boot-dependencies-4.1.1.pom, not assumed. JDK 25 (Temurin 25.0.4.1+1). Maven 3.9. H2 2.4.240, file-based rather than in-memory, because a job repository that forgets everything when the JVM exits is exactly the failure mode this article is about.
A job is steps, a step is a loop of read-process-write, and one rule decides everything else
Spring Batch has five nouns, and if you’re new to it, everything else in this article is really just consequences of how these five relate to each other.
A Job is a named, ordered list of Steps. A step is either a Tasklet — run once, do one thing — or chunk-oriented: read one item at a time from an ItemReader, transform it with an ItemProcessor, and once you’ve collected a chunk’s worth, hand the whole chunk to an ItemWriter in one go. A JobRepository sits underneath all of it, recording every job and step execution — status, timestamps, and exactly how many items were read, filtered, written, skipped, committed, and rolled back.
The diagram shows this article’s actual job: productImportJob has two steps, an importStep that reads a CSV of products and writes the valid ones to a database table, and a reportStep that counts how many made it in. Hold onto one sentence from this section, because the rest of the article is mostly about what follows from it: a chunk commits as a whole, or it doesn’t commit at all. There is no such thing as a chunk that’s half-written. That single fact is the entire explanation for why restarting a failed job doesn’t mean starting over, and for why one bad row in this article’s demo data costs nine perfectly good rows their place in that run — you’ll see exactly how much, with real numbers, in a few sections.
For readers who want the fuller picture before moving on: JobRepository in Spring Batch 6.0 also does the job of the old JobExplorer (querying past executions), so there’s one bean instead of two, and JobOperator — what actually starts a job — now folds in what JobLauncher used to do plus operational methods like restart(executionId) and stop(executionId). The full anatomy, including where each interface lives now, is in the companion repository’s chapter on it.
The smallest thing that works: 60 rows in, 58 products out
Here’s the whole importStep, using the current builder API:
Source: BatchConfig.java.
new StepBuilder("importStep", jobRepository)
.<Product, Product>chunk(10)
.transactionManager(transactionManager)
.reader(productReader)
.processor(productProcessor)
.writer(productWriter)
.build();
Chunk size 10 against 60 rows of CSV data is six chunks. Run it against clean data and this is what comes back — a real run, queried straight out of Spring Batch’s own metadata table:
Output: docs/output/05-happy-path.txt.
$ SELECT read_count, filter_count, write_count, commit_count FROM BATCH_STEP_EXECUTION;
READ_COUNT | FILTER_COUNT | WRITE_COUNT | COMMIT_COUNT
60 | 2 | 58 | 6
60 read, 2 filtered out by validation (more on that in a moment), 58 written, and — this is the number worth noticing — 6 separate commits, one per chunk. Every one of those six transactions succeeded independently. That’s unremarkable when nothing goes wrong. It stops being unremarkable in two sections, when one of them doesn’t.
If you’ve written Spring Batch code before 2023 or so, two things above might look wrong to you.JobBuilderFactory/StepBuilderFactory, autowired as beans and called asjobBuilderFactory.get("name"), were removed in Spring Batch 5 — that’s not new here, it’s just still copy-pasted from old tutorials. What is new in 6.0:chunk(10, transactionManager)— passing the transaction manager as a second argument — still compiles, but it silently returns the legacySimpleStepBuilderinstead of the newChunkOrientedStepBuilderused above. Both exposereader/processor/writer/faultTolerant/skip, so picking the wrong one doesn’t error — your step just quietly runs on the old chunk-processing engine. Realjavapoutput for both overloads, from this project’s own jar, is in docs/output/03-stepbuilder-chunk-overloads.txt.
For the intermediate reader: the package reorganization in 6.0 goes further than just this one method — RepeatStatus moved from core.repeat to infrastructure.repeat, item readers and writers moved under core.infrastructure.item, and JobParameters is now an immutable record backed by a Set instead of a Map. None of that is guesswork here — the RepeatStatus move was found by writing the old import and letting javac say package does not exist, which is faster and more reliable than trying to remember a package table. The full move list and the exact compiler error are in chapter 2 and chapter 9 of the companion repository.
- Full builder API and constructor signatures: the anatomy-of-a-job chapter.
- Why chunk size 10 and not 100 or 1,000: the chunk-processing chapter has the trade-off in both directions.
- Official reference: Spring Batch — chunk-oriented processing.
A processor that returns null is a filter, not a failure
Two of those 60 rows didn’t make it, and it’s worth being precise about how. ItemProcessor<I, O> has one method, and returning null from it means something specific: this item is filtered. It’s counted separately from both reads and writes, and it never reaches the writer at all.
Source: ProductValidatingProcessor.java.
if (!SKU_PATTERN.matcher(item.sku()).matches()) {
return null; // filtered: does not match ^[A-Z]{3}-\d{4}$
}
if (item.priceCents() <= 0) {
return null; // filtered: priceCents must be positive
}
Output, from a plain JUnit test with no Spring context at all — a processor is just a function, so testing it doesn’t need a framework: docs/output/01-processor-filter.txt.
input : Product[sku=abc-0012, name=Widget 12, priceCents=1012] <- lowercase sku, fails ^[A-Z]{3}-\d{4}$
result : null
input : Product[sku=ABC-0033, name=Widget 33, priceCents=0] <- priceCents is zero
result : null
Notice what’s not in this list: a duplicate SKU. That’s deliberate, and it’s the setup for the next two sections. A processor only ever sees one item at a time — that’s what lets Spring Batch process items independently within a chunk — so it can’t know a SKU is a duplicate without checking everything already written, which defeats the point of a cheap per-row check. The database already enforces that with a UNIQUE constraint, so a duplicate shows up as a write failure, not a filtered row. That distinction is about to matter a lot.
One more thing worth a paragraph: the writer here is deliberately not using beanMapped(), the usual shortcut for a JDBC writer. Product is a Java record, and its accessors are sku()/name()/priceCents() — no get prefix — while beanMapped()‘s reflection looks for standard JavaBean getters. Against a plain record that either binds nothing (silent NULLs) or fails outright, depending on the exact introspector version. The writer here uses itemPreparedStatementSetter instead, setting each column explicitly, which works identically regardless of what shape the item class is. Full explanation and the exact writer code: chapter 6.
- Filtering vs. skipping vs. failing, and which one a given check belongs in: chapter 4.
beanMapped()and Java records, including when newer Spring Framework versions might actually support it: chapter 6.- Official reference: Spring Batch — item processing.
What breaks: a duplicate SKU at row 47 fails a whole chunk, not one row
Now the failure. products-poison.csv is identical to the clean fixture except one row: row 47’s SKU is changed to ABC-0005, a duplicate of row 5. Row 47 lands in chunk 5 — rows 41 through 50 at chunk size 10. Run the broken profile (no fault tolerance configured) against it:
Output: docs/output/07-restart-run1-fails.txt.
org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO PRODUCT (sku, name, price_cents) VALUES (?, ?, ?)]; Unique index or primary key violation:
"PUBLIC.CONSTRAINT_18 INDEX PUBLIC.CONSTRAINT_INDEX_1 ON PUBLIC.PRODUCT(SKU NULLS FIRST) VALUES ( /* 5 */ 'ABC-0005' )"
$ SELECT status, read_count, filter_count, write_count, commit_count, rollback_count FROM BATCH_STEP_EXECUTION;
STATUS | READ_COUNT | FILTER_COUNT | WRITE_COUNT | COMMIT_COUNT | ROLLBACK_COUNT
FAILED | 50 | 2 | 38 | 4 | 1
This is the sentence from the mental-model section, cashed in: a chunk commits as a whole or not at all. Chunks 1 through 4 — rows 1 through 40, minus the 2 filtered earlier — committed cleanly: 38 rows in the table. Chunk 5 rolled back entirely. The reader had read through row 50 by the time the writer failed (READ_COUNT is 50), but not one of chunk 5’s other nine, perfectly valid rows made it into the table. They’re not wrong. They just happened to share a transaction with the one that was.
WhyDataIntegrityViolationExceptionis the right thing to catch for this, even though H2 throwsDuplicateKeyException.DuplicateKeyException extends DataIntegrityViolationException— Spring’s JDBC exception translation maps a unique-constraint violation to the more specific subclass, and a check registered against the parent still matches it. Verified directly rather than assumed: docs/output/02-exception-hierarchy.txt. This matters again in two sections.
- Chunk size as a trade-off between how much you lose on failure and how much overhead you pay per row: chapter 3.
- The exception translation chain in full: Spring Framework — consistent exception hierarchy.
The restart: a brand-new JVM resumes at row 41, not row 1
Here’s the part that makes all of this worth building. Someone fixes the bad row — changes ABC-0005 back to something unique, on the same line, in the same file. Then the exact same command runs again, in a completely separate java -jar process, using the exact same job parameters as the failed run:
The exact fix and rerun, quoted from the scenario script: scripts/capture-scenarios.sh.
sed -i '48s/ABC-0005/ABC-0999/' scenario-data/restart-demo/input.csv # the only change
Output: docs/output/08-restart-run2-resumes.txt.
$ SELECT step_execution_id, job_execution_id, status, read_count, write_count, commit_count, rollback_count
FROM BATCH_STEP_EXECUTION ORDER BY step_execution_id;
STEP_EXECUTION_ID | JOB_EXECUTION_ID | STATUS | READ_COUNT | WRITE_COUNT | COMMIT_COUNT | ROLLBACK_COUNT
1 | 1 | FAILED | 50 | 38 | 4 | 1
2 | 2 | COMPLETED | 20 | 20 | 2 | 0
$ SELECT COUNT(*) FROM PRODUCT;
COUNT(*)
58
Read the second execution’s row carefully: READ_COUNT is 20, not 60. This brand-new process, with no memory of anything except what’s in the database, did not re-read the CSV from the top. It read rows 41 through 60 — exactly the two chunks that hadn’t committed yet — wrote all 20, and finished. 58 rows total: the 38 that survived the first run, plus these 20.
The reader’s position isn’t inferred after the fact — it’s saved by the reader itself into the step’s ExecutionContext on every chunk commit, inside the same transaction as that chunk’s writes. That’s the whole mechanism: if the chunk commits, the updated read position is persisted right alongside the rows it wrote; if the chunk rolls back, the position update rolls back too, so it stays at the end of the last chunk that actually succeeded. There’s no separate “resume point” to configure. It falls directly out of transactional commits plus a reader that participates in the stream lifecycle — and a custom reader that doesn’t save its position this way will always restart from scratch, which is fine for something idempotent and a quiet bug for anything else.
And the call that produced this restart is not a different call from the one that ran the job the first time. ImportRunner calls jobOperator.start(job, params) both times, with the same identifying parameter (batch.run=demo). Spring Batch itself decides, from the state in the job repository, whether this is a fresh attempt or a resume of an existing one — source and the exact reasoning: ImportRunner.java, chapter 5.
A trap that nearly ruined this exact demonstration. The first attempt at reproducing this restart usedmvn spring-boot:runfor both runs, editing the CSV file insrc/main/resources/between them. It didn’t work — the “fix” kept disappearing.spring-boot:runre-copiessrc/main/resourcesovertarget/classeson every invocation, silently reverting any in-place edit to a classpath resource made between two runs. The fix: point the reader at an external file path outsidetarget/(--import.file=file:./scenario-data/...) and run the packaged jar directly withjava -jar. If your own “restart” test seems to keep failing on data you’re sure you fixed, check whether your build tool just quietly undid the fix.
Two different Java types are both called ExecutionContext in Spring Batch 6.0, in two different packages, doing two different jobs — the mutable one an ItemStream reads and writes (org.springframework.batch.infrastructure.item.ExecutionContext), and an immutable record used internally for persistence (org.springframework.batch.core.repository.persistence.ExecutionContext). Get the import wrong and your IDE will happily autocomplete you into the wrong one. Both, verified with real javap output: docs/output/04-two-executioncontext-classes.txt.
- The full mechanism, with the exact
ItemStreamcontract: chapter 7. - Official reference on configuring restart behaviour per step: Spring Batch — configuring a step for restart.
What the defaults don’t do: fail the whole job, or skip one row and keep going
Failing and restarting is the right response when a bad row genuinely needs a human decision. It’s the wrong response when the bad row is expected background noise in a large feed, and stopping the whole job for each one isn’t realistic. The skip profile configures the same step differently — faultTolerant().skip(...) instead of nothing. Source: BatchConfig.java.
.faultTolerant()
.skip(DataIntegrityViolationException.class)
.skipLimit(3)
.listener(skipListener())
Run against the same poisoned file, in one pass, no restart needed. Output: docs/output/10-skip-instead-of-fail.txt.
SKIPPED on write: ABC-0005 (DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO PRODUCT (sku, name, price_cents) VALUES (?, ?, ?)]; Unique index or primary key violation: "PUBLIC.CONSTRAINT_18 INDEX PUBLIC.CONSTRAINT_INDEX_1 ON PUBLIC.PRODUCT(SKU NULLS FIRST) VALUES ( /* 5 */ 'ABC-0005' )"; SQL statement:
REPORT: 57 products now in the PRODUCT table
$ SELECT status, read_count, filter_count, write_count, write_skip_count, commit_count, rollback_count FROM BATCH_STEP_EXECUTION;
STATUS | READ_COUNT | FILTER_COUNT | WRITE_COUNT | WRITE_SKIP_COUNT | COMMIT_COUNT | ROLLBACK_COUNT
COMPLETED | 60 | 2 | 57 | 1 | 6 | 2
One pass, all 60 rows read, the duplicate skipped (not filtered — the difference is that this decision happened at the database, not in the processor, see the earlier section on filtering), 57 written, job COMPLETED. Notice ROLLBACK_COUNT is 2, not 0: when a fault-tolerant chunk’s batch write fails, Spring Batch doesn’t give up on the whole chunk — it rolls back once, then reprocesses that chunk’s items one at a time to find out which single item is actually the problem, so it can skip only that one and keep the rest. That’s the extra rollback. skipLimit(3) is the safety valve: past three skips in one execution, the step gives up and fails anyway, because “one bad row” and “the whole feed is corrupt” shouldn’t be handled the same way.
There’s a second knob worth knowing even though this demo doesn’t need it: allowStartIfComplete(true), used on reportStep here. By default a step that already finished COMPLETED is skipped on restart — the entire point of the previous section is not redoing committed work. But a status-reporting step should reflect the current table on every run, including a restart, so it opts back in explicitly. There’s also startLimit(n), which caps how many times a step may be attempted before Spring Batch refuses to try again — useful for a step where an unbounded automatic retry loop would make things worse than a human looking at it.
The trap hiding in your dependency list: a restart that forgets it ever happened
Everything in the previous two sections depends on one dependency choice, and getting it wrong produces no error at all — the application starts, the job runs, nothing complains, right up until a redeploy.
Spring Boot 4.1 splits batch autoconfiguration into separate modules. spring-boot-starter-batch alone gives you Batch’s infrastructure basics and, critically, a resourceless — in-memory, non-persistent — JobRepository if nothing more specific is configured. spring-boot-starter-batch-jdbc (what this module actually depends on) adds a real JDBC-backed one. The second normally wins automatically once both are on the classpath, which they are together by default — but nothing about compiling or starting the app tells you which one actually did.
Proving it: exclude the JDBC autoconfiguration explicitly, then run the exact same job twice, in two separate JVMs, against the exact same database. Output: docs/output/11-resourceless-forgets-everything.txt.
-- run 1 --
REPORT: 58 products now in the PRODUCT table
JOB FINISHED: status=COMPLETED exitCode=COMPLETED
-- run 2: a second, completely fresh JVM, same jar, same job parameters, same PRODUCT table --
org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO PRODUCT (sku, name, price_cents) VALUES (?, ?, ?)]; Unique index or primary key violation: "PUBLIC.CONSTRAINT_18 INDEX PUBLIC.CONSTRAINT_INDEX_1 ON PUBLIC.PRODUCT(SKU NULLS FIRST) VALUES ( /* 1 */ 'ABC-0001' )"; SQL statement:
JOB FINISHED: status=FAILED exitCode=FAILED
Run 2 doesn’t throw JobInstanceAlreadyCompleteException, which is what you’d expect from re-running an already-successful job (see the restart section above). It doesn’t know run 1 happened at all. The resourceless job repository lived only in run 1’s heap; when that process exited, every job and step execution it ever recorded went with it. Run 2 starts productImportJob as if for the very first time, tries to insert ABC-0001 again, and collides with what run 1 already committed to the PRODUCT table — a table that, unlike the job repository, really is backed by the same on-disk database both times.
How to check which one you actually have. Queryinformation_schema.tables(or your database’s equivalent) for a table namedBATCH_JOB_INSTANCEafter your application starts. If it’s not there, you have the resourceless job repository regardless of what your dependency list looks like on paper — check for aspring.autoconfigure.excludeentry, a conflictingJobRepositorybean of your own, or simply a missing-jdbc/-mongodbstarter.
The practical shape of this bug is not a startup error. It’s “my restart didn’t restart” or “why did this job double-insert everything,” discovered in whatever environment happens to restart the JVM between runs — which, for anything running in a container orchestrator, is any redeploy, crash-restart, or scale event.
- The full split across
spring-boot-starter-batch,-batch-jdbc, and-batch-data-mongodb, and the exact autoconfiguration classes each one adds: chapter 10. spring.batch.jdbc.initialize-schemaand the rest of the JDBC job repository’s configuration surface: thespring-configuration-metadata.jsoninside the actualspring-boot-batch-jdbc-4.1.1.jar, more current than any article including this one.
Should you even build a custom batch job for this? Sometimes the honest answer is no. If the volume fits inside one request-response cycle and doesn’t need to survive a mid-way crash, a scheduled method with its own try/catch is less machinery than a full Job/Step/JobRepository setup, and easier for the next person to read cold. Spring Batch earns its complexity when you need the things this article actually demonstrated — durable progress tracking, transactional chunking, a real restart story — not by default just because the word “batch” showed up in the ticket.
Further reading: the companion repository has all eleven chapters and every transcript quoted above, plus a production checklist collecting every trap in this article into one list. Official references used throughout: the Spring Batch reference documentation, What’s new in Spring Batch 6, and the Spring Batch 6.0 migration guide (which gets one thing wrong — see chapter 9 of the companion repo for the correction). On related Boot 4 territory: Spring Boot 3 to 4 Migration Guide and The Spring Cache Abstraction on Boot 4.1, both on this site.
No Comments yet!