The job repository & metadata schema

This section documents the current Spring Batch line — 6.0.x, on Spring Framework 7 and Spring Boot 4.1.x, with a Java 17+ baseline — as published at the Spring Batch reference documentation. No specific patch version is pinned. Some surfaces (Spring Cloud Task and Spring Cloud Data Flow orchestration, the deployer-based partition handler, and JSR-352) are linked, not documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The JobRepository is what makes Spring Batch more than a loop: it records every instance, every execution, every step and every commit point, and it is the sole reason restart works. This page covers where that data lives, how the schema is created, and how to read it back.

The metadata tables

Entity-relationship diagram of the six Spring Batch metadata tables and their sequences
Table Contents

BATCH_JOB_INSTANCE

One row per JobInstance: JOB_INSTANCE_ID, JOB_NAME, and JOB_KEY — a hash of the identifying parameters. The unique constraint on (JOB_NAME, JOB_KEY) is what enforces "one instance per parameter set".

BATCH_JOB_EXECUTION

One row per attempt: JOB_EXECUTION_ID, the owning JOB_INSTANCE_ID, CREATE_TIME, START_TIME, END_TIME, STATUS (BatchStatus), EXIT_CODE/EXIT_MESSAGE (ExitStatus), LAST_UPDATED.

BATCH_JOB_EXECUTION_PARAMS

The parameters of one execution: PARAMETER_NAME, PARAMETER_TYPE, PARAMETER_VALUE and the IDENTIFYING flag — which is why a parameter’s type survives a round-trip.

BATCH_JOB_EXECUTION_CONTEXT

The serialized job-scoped ExecutionContext, one row per job execution.

BATCH_STEP_EXECUTION

One row per step attempt: STEP_NAME, STATUS, the timings, and the counters — READ_COUNT, WRITE_COUNT, COMMIT_COUNT, ROLLBACK_COUNT, FILTER_COUNT, READ_SKIP_COUNT, PROCESS_SKIP_COUNT, WRITE_SKIP_COUNT.

BATCH_STEP_EXECUTION_CONTEXT

The serialized step-scoped ExecutionContext, one row per step execution — rewritten at every commit point, which is what a restart reads back.

Three sequences supply the primary keys on databases that use them: BATCH_JOB_SEQ, BATCH_JOB_EXECUTION_SEQ and BATCH_STEP_EXECUTION_SEQ.

An excerpt of the shape (PostgreSQL flavour):

CREATE TABLE BATCH_JOB_INSTANCE (
    JOB_INSTANCE_ID BIGINT      NOT NULL PRIMARY KEY,
    VERSION         BIGINT,
    JOB_NAME        VARCHAR(100) NOT NULL,
    JOB_KEY         VARCHAR(32)  NOT NULL,
    CONSTRAINT JOB_INST_UN UNIQUE (JOB_NAME, JOB_KEY)
);

CREATE TABLE BATCH_JOB_EXECUTION (
    JOB_EXECUTION_ID BIGINT      NOT NULL PRIMARY KEY,
    VERSION          BIGINT,
    JOB_INSTANCE_ID  BIGINT      NOT NULL,
    CREATE_TIME      TIMESTAMP   NOT NULL,
    START_TIME       TIMESTAMP   DEFAULT NULL,
    END_TIME         TIMESTAMP   DEFAULT NULL,
    STATUS           VARCHAR(10),
    EXIT_CODE        VARCHAR(2500),
    EXIT_MESSAGE     VARCHAR(2500),
    LAST_UPDATED     TIMESTAMP,
    CONSTRAINT JOB_INST_EXEC_FK FOREIGN KEY (JOB_INSTANCE_ID)
        REFERENCES BATCH_JOB_INSTANCE (JOB_INSTANCE_ID)
);

CREATE TABLE BATCH_STEP_EXECUTION (
    STEP_EXECUTION_ID  BIGINT      NOT NULL PRIMARY KEY,
    VERSION            BIGINT      NOT NULL,
    STEP_NAME          VARCHAR(100) NOT NULL,
    JOB_EXECUTION_ID   BIGINT      NOT NULL,
    STATUS             VARCHAR(10),
    COMMIT_COUNT       BIGINT,
    READ_COUNT         BIGINT,
    FILTER_COUNT       BIGINT,
    WRITE_COUNT        BIGINT,
    READ_SKIP_COUNT    BIGINT,
    WRITE_SKIP_COUNT   BIGINT,
    PROCESS_SKIP_COUNT BIGINT,
    ROLLBACK_COUNT     BIGINT,
    EXIT_CODE          VARCHAR(2500),
    EXIT_MESSAGE       VARCHAR(2500),
    CONSTRAINT JOB_EXEC_STEP_FK FOREIGN KEY (JOB_EXECUTION_ID)
        REFERENCES BATCH_JOB_EXECUTION (JOB_EXECUTION_ID)
);

The complete, authoritative DDL for every supported database is listed in Appendix A: meta-data schema. General DDL technique is covered in SQL DDL.

Creating the schema

The DDL ships inside spring-batch-core as classpath resources named org/springframework/batch/core/schema-<platform>.sql — schema-postgresql.sql, schema-mysql.sql, schema-oracle.sql, schema-h2.sql, schema-sqlserver.sql, and so on, each with a matching schema-drop-<platform>.sql.

Spring Boot can run it for you:

spring:
  batch:
    jdbc:
      initialize-schema: embedded   # always | embedded | never
      table-prefix: BATCH_
      platform: postgresql          # override the detected platform
      schema: classpath:org/springframework/batch/core/schema-postgresql.sql
initialize-schema Behaviour

embedded (default)

Create the tables only when the datasource is an embedded database (H2, HSQL, Derby).

always

Always run the DDL at startup — convenient in development, and safe because the scripts are idempotent-ish, but not what a controlled production deployment wants.

never

Never touch the schema. The correct production setting: create the tables through your migration tool (Flyway, Liquibase) so they are versioned like every other table.

table-prefix renames all six tables at once — useful when several applications share a schema, or when a DBA requires a naming convention. Whatever prefix is chosen must also be given to the repository configuration (@EnableJdbcJobRepository(tablePrefix = "…​")), see Infrastructure configuration.

Choosing a repository implementation

Implementation When to use it

JDBC (JdbcJobRepositoryFactoryBean, the Boot default when a DataSource exists)

Production default. Durable, queryable with plain SQL, restartable, and works with the transaction manager the business steps already use — so metadata and business writes can commit together.

MongoDB (MongoJobRepositoryFactoryBean, @EnableMongoJobRepository)

For applications whose only datastore is MongoDB. Collections mirror the six tables. Requires a MongoTransactionManager and a replica set for transactions.

Resourceless (ResourcelessJobRepository)

Samples, prototypes and unit tests. Nothing is persisted, so there is no restart and no history.

Why creating a JobExecution is SERIALIZABLE

Creating a JobExecution runs at ISOLATION_SERIALIZABLE by default. The repository must check "does an execution of this instance already exist, and is it still running?" and then insert — and two launchers starting the same job at the same moment must not both pass that check. Serializable isolation makes the check-then-insert atomic, so the second launcher fails fast rather than producing a duplicate concurrent execution.

The level can be relaxed (Isolation.READ_COMMITTED) when the database’s serializable implementation is expensive and duplicate launches are prevented some other way — a deliberate trade-off, not a default to change casually. Isolation levels themselves are covered in SQL Transactions and Transaction Isolation & Locking.

@Configuration
@EnableBatchProcessing
@EnableJdbcJobRepository(isolationLevelForCreate = Isolation.READ_COMMITTED)
public class RelaxedIsolationInfrastructure {
}

Querying run history

Since 6.0, JobRepository extends JobExplorer, so the read methods are on the repository itself — no separate JobExplorer bean is needed. JobRegistry (optional, auto-populated) maps job names to Job beans when a name must be resolved at runtime.

@Service
public class BatchHistoryService {

    private final JobRepository jobRepository;

    public BatchHistoryService(JobRepository jobRepository) {
        this.jobRepository = jobRepository;
    }

    /** Prints the last 10 instances of a job and how every execution of each one ended. */
    public void printHistory(String jobName) {
        List<JobInstance> instances = jobRepository.getJobInstances(jobName, 0, 10);
        for (JobInstance instance : instances) {
            System.out.printf("instance %d%n", instance.getInstanceId());
            for (JobExecution execution : jobRepository.getJobExecutions(instance)) {
                System.out.printf("  execution %d: %s (%s) %s -> %s%n",
                        execution.getId(),
                        execution.getStatus(),
                        execution.getExitStatus().getExitCode(),
                        execution.getStartTime(),
                        execution.getEndTime());
                for (StepExecution step : execution.getStepExecutions()) {
                    System.out.printf("    step %-20s read=%d write=%d skip=%d%n",
                            step.getStepName(),
                            step.getReadCount(),
                            step.getWriteCount(),
                            step.getSkipCount());
                }
            }
        }
    }

    public Set<String> runningExecutions(String jobName) {
        return jobRepository.findRunningJobExecutions(jobName).stream()
                .map(execution -> execution.getId().toString())
                .collect(Collectors.toSet());
    }
}

The same data is readable with plain SQL, which is often the quickest production diagnosis — see SQL Queries:

SELECT i.JOB_NAME, e.JOB_EXECUTION_ID, e.STATUS, e.EXIT_CODE, e.START_TIME, e.END_TIME
FROM   BATCH_JOB_EXECUTION e
JOIN   BATCH_JOB_INSTANCE  i ON i.JOB_INSTANCE_ID = e.JOB_INSTANCE_ID
WHERE  i.JOB_NAME = 'endOfDayJob'
ORDER  BY e.JOB_EXECUTION_ID DESC
FETCH FIRST 20 ROWS ONLY;

The metadata tables grow with every run; indexing and pruning them is discussed in Profiling & tuning.

Further reading

For the full detail behind this page: