You kick off a Spring Batch job that ran successfully yesterday, and instead of processing anything, it throws JobInstanceAlreadyCompleteException and exits immediately. No data gets touched. Nothing crashes in your business logic — Spring Batch itself is refusing to run.
JobInstance, which is a combination of the job name and its JobParameters. If you launch a job with the exact same name and parameters as a run that already completed successfully, Spring Batch assumes there's nothing left to do and refuses to start a new instance. Add a unique, changing parameter (like a timestamp or a JobParametersIncrementer) to force a new instance.
Why This Happens
Spring Batch’s metadata tables (BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION, BATCH_JOB_EXECUTION_PARAMS) track every job that’s ever run. When you call jobLauncher.run(job, jobParameters), Spring Batch first checks whether a JobInstance with that exact job name and parameter set already exists.
If it finds one, and that instance’s last execution ended in COMPLETED status, Spring Batch throws JobInstanceAlreadyCompleteException rather than silently re-running work it thinks is already done. This is by design — batch jobs are often idempotent-sensitive (think: “charge every customer once”), so re-running a completed job with identical inputs is treated as a mistake, not a feature.
The catch is that “identical inputs” includes parameters you might not think of as meaningful, like a runDate you hardcoded, or no parameters at all. Two calls to jobLauncher.run(job, new JobParameters()) will always resolve to the same JobInstance, and the second one will fail the moment the first one succeeds.
It’s worth noting that this check happens before any of your business logic runs. Spring Batch isn’t inspecting your data or your database — it’s purely comparing the job name and parameter map against what’s already stored in BATCH_JOB_INSTANCE and BATCH_JOB_EXECUTION_PARAMS. That means the fix is almost never in your ItemReader, ItemProcessor, or ItemWriter. It’s in how the job gets launched, full stop.
If you want to confirm this is really what’s happening rather than guessing from the stack trace, you can query the job’s history directly through Spring Batch’s own API instead of writing raw SQL:
@Autowired
private JobExplorer jobExplorer;
public void inspectJobHistory(String jobName) {
List<JobInstance> instances = jobExplorer.getJobInstances(jobName, 0, 10);
for (JobInstance instance : instances) {
List<JobExecution> executions = jobExplorer.getJobExecutions(instance);
executions.forEach(exec ->
System.out.printf("Instance %d, params=%s, status=%s%n",
instance.getInstanceId(), exec.getJobParameters(), exec.getStatus()));
}
}
Run that before you touch any code, and you’ll know immediately whether you’re dealing with a genuine duplicate instance or something else entirely, like a misconfigured JobRepository pointing at the wrong database.
Solutions by Scenario
Scenario 1: You’re manually rerunning a job for testing
If you’re just testing locally and want to run the same job repeatedly, the cleanest fix is a JobParametersIncrementer. It automatically bumps a run.id parameter every time the job is launched.
// This code triggers the error on the second run:
JobParameters params = new JobParametersBuilder()
.addString("inputFile", "customers.csv")
.toJobParameters();
jobLauncher.run(customerImportJob, params);
// The error you'll see:
// org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException:
// A job instance already exists and is complete for parameters={inputFile=customers.csv}.
// If you want to run this job again, change the parameters.
// Here's the fix — add an incrementer to the job definition:
@Bean
public Job customerImportJob(JobRepository jobRepository, Step importStep) {
return new JobBuilder("customerImportJob", jobRepository)
.incrementer(new RunIdIncrementer())
.start(importStep)
.build();
}
RunIdIncrementer adds a run.id long parameter that increments on every launch, so each execution gets a fresh JobInstance regardless of your other parameters.
Scenario 2: A scheduled job is meant to run daily with the same logical input
If you’re triggering the job with @Scheduled and passing something like a fixed environment name, you need a parameter that’s unique per intended run — not per deploy. A timestamp works, but a date is often more meaningful for daily batch jobs:
@Scheduled(cron = "0 0 2 * * *")
public void runDailyImport() throws Exception {
JobParameters params = new JobParametersBuilder()
.addString("inputFile", "customers.csv")
.addLocalDate("runDate", LocalDate.now())
.toJobParameters();
jobLauncher.run(customerImportJob, params);
}
Using runDate instead of run.id gives you a meaningful, queryable parameter in BATCH_JOB_EXECUTION_PARAMS, and it still guarantees uniqueness because the cron only fires once a day. This also means if the job legitimately needs to run twice in one day (say, an operator retriggers it), you’re back to the same exception — which is usually what you want for a daily import.
Scenario 3: You genuinely want to allow re-running a completed job
Sometimes the “already complete” check is actively wrong for your use case — for example, a job that recomputes a report and it’s fine to recompute it again with the same inputs. For this, don’t fight the framework with fake parameters; tell it explicitly that re-running is allowed:
@Bean
public Step reportStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
Tasklet reportTasklet) {
return new StepBuilder("reportStep", jobRepository)
.tasklet(reportTasklet, txManager)
.allowStartIfComplete(true)
.build();
}
allowStartIfComplete(true) on the step tells Spring Batch it’s fine to re-execute this step even if its last execution completed successfully. Note this is set on the step, not the job — you’re overriding the completeness check at the step level, which is more precise than trying to spoof job parameters.
Scenario 4: Cleaning up test data between integration test runs
If you’re hitting this in a test suite where each test spins up the Spring context fresh but shares a database, the batch metadata tables persist across test runs and you get false positives. The simplest fix is to use an in-memory job repository per test, or truncate the batch tables in a @BeforeEach:
@BeforeEach
void resetBatchMetadata() {
jdbcTemplate.execute("DELETE FROM BATCH_STEP_EXECUTION_CONTEXT");
jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION_CONTEXT");
jdbcTemplate.execute("DELETE FROM BATCH_STEP_EXECUTION");
jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION_PARAMS");
jdbcTemplate.execute("DELETE FROM BATCH_JOB_EXECUTION");
jdbcTemplate.execute("DELETE FROM BATCH_JOB_INSTANCE");
}
Order matters here because of foreign key constraints — instances reference executions, and executions reference their params and contexts, so you have to delete child rows first.
Scenario 5: A CI/CD pipeline retriggers the same deployment job
Teams often run a Spring Batch job as part of a deployment step — say, a data migration that runs once per release. If a pipeline gets retried after a flaky step unrelated to the job itself, the job launch call fires again with the same release version as its parameter, and you get the exact same exception on the second attempt.
The fix here usually isn’t a code change at all — it’s making the pipeline idempotent at the orchestration level. Either check the job’s completion status before attempting to launch it again:
JobParameters params = new JobParametersBuilder()
.addString("releaseVersion", releaseVersion)
.toJobParameters();
JobInstance existingInstance = jobExplorer.getJobInstance("dataMigrationJob", params);
JobExecution lastExecution = existingInstance != null
? jobExplorer.getLastJobExecution(existingInstance)
: null;
if (lastExecution == null || lastExecution.getStatus() != BatchStatus.COMPLETED) {
jobLauncher.run(dataMigrationJob, params);
} else {
log.info("Migration for release {} already completed, skipping.", releaseVersion);
}
…or catch JobInstanceAlreadyCompleteException at the call site and treat it as a no-op success rather than a deployment failure. Which approach fits depends on whether “already done” should be a loud skip or a silent one — for deployment migrations, I’d lean toward logging it loudly so nobody’s confused later about why the step “did nothing.”
Prevention Tips
- Decide your uniqueness strategy up front. Ask whether “one run per day,” “one run per file,” or “always a new run” actually matches your business rule, and pick your job parameter accordingly instead of reaching for
RunIdIncrementerby default. - Don’t confuse
JobInstanceuniqueness with idempotency. Adding arun.idmakes Spring Batch happy, but if your step itself isn’t idempotent (e.g., it inserts rows without a uniqueness check), you can still double-process data on a legitimate retry. - Query
BATCH_JOB_EXECUTIONbefore assuming a bug. A quickSELECT * FROM BATCH_JOB_INSTANCE WHERE JOB_NAME = 'customerImportJob'will show you exactly which parameter combinations Spring Batch already considers “done.” - Watch out for this alongside
@Transactionalsteps — if a step partially commits before failing, its status matters just as much as job-level completeness. If you’re also debugging transaction rollback surprises, our guide on Spring@Transactionalnot working covers related pitfalls. - If jobs are launched from a scheduler, make sure the trigger itself isn’t firing more often than you think. Our post on Spring scheduled tasks not running is useful even in reverse — it helps you confirm exactly when and how often
@Scheduledis actually firing.
Wrap-Up
JobInstanceAlreadyCompleteException isn’t a bug in Spring Batch — it’s the framework protecting you from accidentally redoing completed work. The fix is almost always about being deliberate with your JobParameters: use a RunIdIncrementer when you genuinely want a fresh run every time, use a meaningful parameter like a date when uniqueness should track something real, or explicitly allow restarts with allowStartIfComplete when re-running the same inputs is intentional.
Next time a batch job throws a stack trace like this at 2 AM, don’t just skim it — paste it into Debugly’s trace formatter to quickly parse and analyze Java stack traces, so you can see exactly which class and job configuration triggered the exception instead of hunting through a wall of text.