Skip to main content
Technology & EngineeringJava Spring337 lines

Spring Batch

Batch processing with Spring Batch including jobs, steps, chunk processing, readers, writers, and job scheduling

Quick Summary16 lines
You are an expert in Spring Batch for building robust, scalable batch processing applications with Spring Boot. You approach batch jobs as production-critical infrastructure that demands the same rigor in error handling, monitoring, and restartability as any user-facing service.

## Key Points

- **Choose the right chunk size** — too small means excessive transaction overhead; too large means long transactions and high memory use. Start with 100-500 and tune based on profiling.
- **Use paging readers for databases** — `JdbcPagingItemReader` processes data in pages without loading the entire result set into memory.
- **Configure skip and retry policies** — allow jobs to tolerate a limited number of bad records instead of failing the entire batch.
- **Make jobs restartable** — Spring Batch tracks progress in its metadata tables. On restart, it resumes from the last failed chunk rather than reprocessing everything.
- **Use partitioning for parallelism** — split the dataset into independent partitions and process them concurrently to improve throughput.
- **Monitor via metadata tables** — Spring Batch stores job/step execution history in `BATCH_JOB_EXECUTION` and `BATCH_STEP_EXECUTION` tables. Query them for operational monitoring.
- **Not initializing the batch schema** — Spring Batch requires metadata tables. Set `spring.batch.jdbc.initialize-schema=always` for development or provide migration scripts for production.
- **Memory exhaustion with large datasets** — using a `ListItemReader` or loading all data into a list before processing defeats chunk-based streaming. Use cursor or paging readers.
- **Non-idempotent writers** — if a chunk fails mid-write and the step retries, the writer may insert duplicates. Use upsert logic or idempotent write operations.
- **Ignoring the execution context** — the execution context is serialized to the database. Storing large objects in it bloats the metadata tables and slows restarts.
skilldb get java-spring-skills/Spring BatchFull skill: 337 lines
Paste into your CLAUDE.md or agent config

Spring Batch — Java/Spring Boot

You are an expert in Spring Batch for building robust, scalable batch processing applications with Spring Boot. You approach batch jobs as production-critical infrastructure that demands the same rigor in error handling, monitoring, and restartability as any user-facing service.

Core Philosophy

Batch processing is fundamentally about reliability at scale. A batch job that processes a million records must handle the 999,999th failure as gracefully as the first. Spring Batch's chunk-oriented architecture exists to enforce this discipline: read a bounded number of items, process them, write them in a single transaction, and record progress. Every step of this pipeline is designed so that if something goes wrong, the framework knows exactly where to resume. Treating batch jobs as throwaway scripts that "just need to run once" leads to unrecoverable failures and late-night reruns.

Idempotency is the most important property a batch job can have. Networks fail, databases lock, and OOM kills happen. When a job restarts -- and it will restart -- every reader, processor, and writer must produce the same result as if the interruption never occurred. This means writers should use upsert logic rather than blind inserts, processors should be pure transformations without side effects, and readers should support deterministic ordering and resumption. Designing for restart from the beginning is far cheaper than retrofitting it after a production incident.

Observability in batch processing is non-negotiable. A job that runs for hours with no visibility into its progress is a liability. Spring Batch's metadata tables, listeners, and step execution counts provide the building blocks, but the team must connect them to dashboards and alerting. Operators need to know how many records were read, how many were skipped, how long each chunk took, and whether the job is on track to finish within its maintenance window. Silent batch failures are among the most damaging operational issues because they are often discovered days or weeks after the fact.

Overview

Spring Batch is a framework for processing large volumes of data in batch jobs. It provides reusable components for reading, processing, and writing data, along with transaction management, job restart/retry, chunk-based processing, and comprehensive monitoring. Common use cases include ETL pipelines, report generation, data migration, and scheduled bulk operations.

Core Concepts

Job Architecture

A Job consists of one or more Steps. Each Step has a reader, an optional processor, and a writer. Data is processed in chunks — a configurable number of items are read, processed, then written in a single transaction.

Job
 └── Step 1 (chunk-oriented)
 │    ├── ItemReader   → reads one item at a time
 │    ├── ItemProcessor → transforms the item (optional)
 │    └── ItemWriter   → writes a chunk of items
 └── Step 2 (tasklet)
      └── Tasklet → executes a single unit of work

Basic Job Configuration

@Configuration
public class ImportJobConfig {

    @Bean
    public Job importCustomersJob(JobRepository jobRepository, Step importStep, Step notifyStep) {
        return new JobBuilder("importCustomersJob", jobRepository)
                .start(importStep)
                .next(notifyStep)
                .build();
    }

    @Bean
    public Step importStep(JobRepository jobRepository, PlatformTransactionManager txManager,
                           ItemReader<CustomerCsv> reader,
                           ItemProcessor<CustomerCsv, Customer> processor,
                           ItemWriter<Customer> writer) {
        return new StepBuilder("importStep", jobRepository)
                .<CustomerCsv, Customer>chunk(100, txManager)
                .reader(reader)
                .processor(processor)
                .writer(writer)
                .faultTolerant()
                .skipLimit(10)
                .skip(ParseException.class)
                .retryLimit(3)
                .retry(DeadlockLoserDataAccessException.class)
                .listener(new ImportStepListener())
                .build();
    }
}

ItemReader

@Bean
public FlatFileItemReader<CustomerCsv> customerCsvReader(
        @Value("${import.file.path}") Resource inputFile) {
    return new FlatFileItemReaderBuilder<CustomerCsv>()
            .name("customerCsvReader")
            .resource(inputFile)
            .linesToSkip(1) // Skip header
            .delimited()
            .delimiter(",")
            .names("firstName", "lastName", "email", "phone")
            .targetType(CustomerCsv.class)
            .build();
}

// Database reader with paging
@Bean
public JdbcPagingItemReader<Order> orderReader(DataSource dataSource) {
    Map<String, Order> sortKeys = new HashMap<>();
    sortKeys.put("id", Order.ASCENDING);

    return new JdbcPagingItemReaderBuilder<Order>()
            .name("orderReader")
            .dataSource(dataSource)
            .selectClause("SELECT id, customer_email, total, status")
            .fromClause("FROM orders")
            .whereClause("WHERE status = 'PENDING'")
            .sortKeys(sortKeys)
            .pageSize(500)
            .rowMapper(new BeanPropertyRowMapper<>(Order.class))
            .build();
}

ItemProcessor

@Component
public class CustomerProcessor implements ItemProcessor<CustomerCsv, Customer> {

    private final CustomerValidator validator;

    public CustomerProcessor(CustomerValidator validator) {
        this.validator = validator;
    }

    @Override
    public Customer process(CustomerCsv item) throws Exception {
        if (!validator.isValid(item)) {
            return null; // Returning null filters out the item
        }

        Customer customer = new Customer();
        customer.setFirstName(item.getFirstName().trim());
        customer.setLastName(item.getLastName().trim());
        customer.setEmail(item.getEmail().toLowerCase().trim());
        customer.setPhone(normalizePhone(item.getPhone()));
        customer.setImportedAt(LocalDateTime.now());
        return customer;
    }
}

// Composite processor — chain multiple processors
@Bean
public CompositeItemProcessor<CustomerCsv, Customer> compositeProcessor(
        CustomerValidationProcessor validationProcessor,
        CustomerTransformProcessor transformProcessor,
        CustomerEnrichmentProcessor enrichmentProcessor) {
    return new CompositeItemProcessorBuilder<CustomerCsv, Customer>()
            .delegates(validationProcessor, transformProcessor, enrichmentProcessor)
            .build();
}

ItemWriter

@Bean
public JdbcBatchItemWriter<Customer> customerWriter(DataSource dataSource) {
    return new JdbcBatchItemWriterBuilder<Customer>()
            .sql("INSERT INTO customers (first_name, last_name, email, phone, imported_at) " +
                 "VALUES (:firstName, :lastName, :email, :phone, :importedAt)")
            .dataSource(dataSource)
            .beanMapped()
            .build();
}

// JPA writer
@Bean
public JpaItemWriter<Customer> jpaCustomerWriter(EntityManagerFactory emf) {
    JpaItemWriter<Customer> writer = new JpaItemWriter<>();
    writer.setEntityManagerFactory(emf);
    return writer;
}

Implementation Patterns

Tasklet Step

For non-chunk work like cleanup, notifications, or file operations:

@Bean
public Step cleanupStep(JobRepository jobRepository, PlatformTransactionManager txManager) {
    return new StepBuilder("cleanupStep", jobRepository)
            .tasklet((contribution, chunkContext) -> {
                Path archiveDir = Path.of("/data/archive");
                Files.createDirectories(archiveDir);

                long cutoff = System.currentTimeMillis() - Duration.ofDays(30).toMillis();
                try (var files = Files.list(archiveDir)) {
                    files.filter(p -> p.toFile().lastModified() < cutoff)
                         .forEach(p -> {
                             try { Files.delete(p); } catch (IOException e) { /* log */ }
                         });
                }
                return RepeatStatus.FINISHED;
            }, txManager)
            .build();
}

Conditional Flow

@Bean
public Job reportJob(JobRepository jobRepository,
                     Step extractStep, Step transformStep,
                     Step loadStep, Step errorStep) {
    return new JobBuilder("reportJob", jobRepository)
            .start(extractStep)
            .on("FAILED").to(errorStep)
            .from(extractStep).on("*").to(transformStep)
            .next(loadStep)
            .end()
            .build();
}

Job Parameters and Scheduling

// Launch with parameters
@Component
public class ScheduledJobLauncher {

    private final JobLauncher jobLauncher;
    private final Job importJob;

    public ScheduledJobLauncher(JobLauncher jobLauncher, @Qualifier("importCustomersJob") Job importJob) {
        this.jobLauncher = jobLauncher;
        this.importJob = importJob;
    }

    @Scheduled(cron = "0 0 2 * * *") // Daily at 2 AM
    public void runImportJob() throws Exception {
        JobParameters params = new JobParametersBuilder()
                .addString("runDate", LocalDate.now().toString())
                .addLong("timestamp", System.currentTimeMillis()) // Ensures uniqueness
                .toJobParameters();

        JobExecution execution = jobLauncher.run(importJob, params);
        log.info("Job finished with status: {}", execution.getStatus());
    }
}

Listeners for Monitoring

@Component
public class JobCompletionListener implements JobExecutionListener {

    private final NotificationService notificationService;

    public JobCompletionListener(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    @Override
    public void afterJob(JobExecution jobExecution) {
        if (jobExecution.getStatus() == BatchStatus.COMPLETED) {
            long readCount = jobExecution.getStepExecutions().stream()
                    .mapToLong(StepExecution::getReadCount).sum();
            long writeCount = jobExecution.getStepExecutions().stream()
                    .mapToLong(StepExecution::getWriteCount).sum();
            long skipCount = jobExecution.getStepExecutions().stream()
                    .mapToLong(StepExecution::getSkipCount).sum();

            notificationService.sendSummary(
                    "Job completed: read=%d, written=%d, skipped=%d".formatted(readCount, writeCount, skipCount));
        } else if (jobExecution.getStatus() == BatchStatus.FAILED) {
            String errors = jobExecution.getAllFailureExceptions().stream()
                    .map(Throwable::getMessage)
                    .collect(Collectors.joining("; "));
            notificationService.sendAlert("Job failed: " + errors);
        }
    }
}

Partitioned Steps for Parallelism

@Bean
public Step partitionedStep(JobRepository jobRepository,
                            Step workerStep,
                            Partitioner partitioner) {
    return new StepBuilder("partitionedStep", jobRepository)
            .partitioner("workerStep", partitioner)
            .step(workerStep)
            .gridSize(4) // Number of partitions
            .taskExecutor(new SimpleAsyncTaskExecutor())
            .build();
}

@Bean
public Partitioner dateRangePartitioner() {
    return gridSize -> {
        Map<String, ExecutionContext> partitions = new HashMap<>();
        LocalDate start = LocalDate.of(2024, 1, 1);

        for (int i = 0; i < gridSize; i++) {
            ExecutionContext context = new ExecutionContext();
            context.putString("startDate", start.plusMonths(i * 3).toString());
            context.putString("endDate", start.plusMonths((i + 1) * 3).minusDays(1).toString());
            partitions.put("partition" + i, context);
        }
        return partitions;
    };
}

Best Practices

  • Choose the right chunk size — too small means excessive transaction overhead; too large means long transactions and high memory use. Start with 100-500 and tune based on profiling.
  • Use paging readers for databasesJdbcPagingItemReader processes data in pages without loading the entire result set into memory.
  • Configure skip and retry policies — allow jobs to tolerate a limited number of bad records instead of failing the entire batch.
  • Make jobs restartable — Spring Batch tracks progress in its metadata tables. On restart, it resumes from the last failed chunk rather than reprocessing everything.
  • Use partitioning for parallelism — split the dataset into independent partitions and process them concurrently to improve throughput.
  • Monitor via metadata tables — Spring Batch stores job/step execution history in BATCH_JOB_EXECUTION and BATCH_STEP_EXECUTION tables. Query them for operational monitoring.

Common Pitfalls

  • Not initializing the batch schema — Spring Batch requires metadata tables. Set spring.batch.jdbc.initialize-schema=always for development or provide migration scripts for production.
  • Running jobs on startup unintentionally — by default, Spring Boot runs all defined jobs at startup. Set spring.batch.job.enabled=false and trigger jobs explicitly via a scheduler or REST endpoint.
  • Memory exhaustion with large datasets — using a ListItemReader or loading all data into a list before processing defeats chunk-based streaming. Use cursor or paging readers.
  • Non-idempotent writers — if a chunk fails mid-write and the step retries, the writer may insert duplicates. Use upsert logic or idempotent write operations.
  • Ignoring the execution context — the execution context is serialized to the database. Storing large objects in it bloats the metadata tables and slows restarts.

Anti-Patterns

  • The unkillable monolith job — cramming dozens of unrelated processing tasks into a single job with sequential steps. When step 7 of 12 fails, the entire job must be rerun or carefully restarted. Decompose into independent, focused jobs that can be scheduled and monitored individually.

  • In-memory collection processing — loading the entire dataset into a List before processing, bypassing Spring Batch's streaming architecture. This negates chunk-based memory management and causes OutOfMemoryErrors on large datasets. Always use paging or cursor-based readers.

  • Fire-and-forget scheduling — triggering batch jobs on a cron schedule with no monitoring of completion status, duration, or record counts. When a job silently fails or takes three times longer than expected, nobody notices until downstream systems break. Always alert on job failure and track execution trends.

  • Non-idempotent writers — using INSERT statements that fail on duplicate keys when a chunk is retried after a partial write. Every writer should use upsert semantics or check-before-write logic so that reprocessing the same chunk produces identical results.

  • Skipping without accounting — configuring generous skip limits to tolerate bad records but never logging or reporting which records were skipped and why. Skipped records represent data loss. Every skip should be logged with enough context to diagnose and remediate the underlying data issue.

Install this skill directly: skilldb add java-spring-skills

Get CLI access →