Job Stores & Persistence

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

The reference material consulted while preparing these pages is listed in the section bibliography.

A scheduler that forgets everything when the process stops is fine for a cache refresh and useless for a reminder that must fire next Friday. What decides the difference is the job store: the Quartz component that keeps every JobDetail, Trigger and Calendar, answers the scheduler thread’s question "what is due next?", and, when it is backed by a database, lets several application instances share one schedule. This page is the generic part of that story, the one every database page of the section builds on: what a job store has to provide, the built-in stores and how Spring Boot picks one, what the eleven QRTZ_ tables contain, how the schema gets into the database (and why one of the three Boot modes deletes your schedule), how to give Quartz its own database, how a code-defined JobDetail bean is reconciled with what is already stored, how a non-JDBC store plugs in, and a matrix of which database works with which delegate and script. The database-specific details are on PostgreSQL, MySQL and MariaDB, Oracle, SQL Server, embedded and other SQL databases, MongoDB and Redis; running several nodes is on Clustering.

It was written against Quartz 2.5.2, Spring Boot 4.1.1, Spring Framework 7.0.9 and Java 21 and verified on 5 October 2026: every behaviour below was run in a test project and every default, property and class was read from the 2.5.2 and 4.1.1 sources (the results are quoted, not assumed). The new runs for this page used postgres:17 (PostgreSQL 17.11, pgJDBC 42.7.13), H2 2.4.240, Flyway 12.4.0, Liquibase 5.0.3 and Testcontainers 2.0.5 through Testcontainers modules, on a Mac with Docker 29.8. The support matrix reuses the runs of the other database images made for this section (mysql:9 = 9.7.2, mariadb:11 = 11.8.9, gvenzl/oracle-free:slim-faststart, mcr.microsoft.com/mssql/server:2022-latest under Rosetta emulation, mongo:8 = 8.3.11 and redis:8 = 8.10.2); each row of the matrix says which. Spring Boot 3.5.16 manages the same Quartz 2.5.2, so everything about Quartz is identical there; only the package of the Boot types differs (org.springframework.boot.autoconfigure.quartz instead of org.springframework.boot.quartz.autoconfigure) and Spring Framework is 6.2.19.

The running scenario is Bookshelf: the cache refresh, the nightly overdue-loan reminders, the weekly statistics report, the monthly retention cleanup and the per-member reminders created at runtime. All of them have to survive a restart, which is the reason this page exists.

What a job store is

Quartz separates running a job from remembering it. The scheduler thread, the worker pool and the listeners are the same whatever you choose; everything that has to be remembered goes through one interface, org.quartz.spi.JobStore, and the scheduler is created with exactly one implementation of it. Reading the 2.5.2 interface, its methods fall into five groups.

Table 1. The groups of methods in the JobStore SPI (Quartz 2.5.2)
Group Methods (abbreviated) What the store must guarantee

Lifecycle

initialize(ClassLoadHelper, SchedulerSignaler), schedulerStarted(), schedulerPaused(), schedulerResumed(), shutdown(), setInstanceId, setInstanceName, setThreadPoolSize

It is told the scheduler’s name and instance id before initialize, and gets a SchedulerSignaler to wake the scheduler thread when something changes (a new trigger that fires earlier than the one it sleeps on). schedulerStarted() is where a JDBC store recovers jobs after a crash (non-clustered) or starts the cluster manager thread (clustered).

Capabilities

supportsPersistence(), isClustered(), getEstimatedTimeToReleaseAndAcquireTrigger(), getAcquireRetryDelay(int)

What the scheduler may assume. getAcquireRetryDelay is how long the scheduler thread sleeps after repeated failures to talk to the store; the JDBC store returns dbRetryInterval (15000 ms by default).

Definitions

storeJob, storeJobAndTrigger, storeJobsAndTriggers, storeTrigger, storeCalendar, removeJob(s), removeTrigger(s), removeCalendar, replaceTrigger, retrieveJob, retrieveTrigger, retrieveCalendar, checkExists, clearAllSchedulingData, the get…​Keys, get…​GroupNames and getTriggersFor…​ queries

Jobs and triggers are keyed by name and group; storing an existing key fails with ObjectAlreadyExistsException unless the caller asked to replace it; removing a job removes its triggers.

State changes

getTriggerState, pauseTrigger(s), pauseJob(s), pauseAll, resumeTrigger(s), resumeJob(s), resumeAll, getPausedTriggerGroups, resetTriggerFromErrorState

Pause and resume survive a restart in a persistent store; resuming evaluates misfires.

The firing protocol

acquireNextTriggers(noLaterThan, maxCount, timeWindow), releaseAcquiredTrigger, triggersFired, triggeredJobComplete

The three obligations described next.

The three obligations

Whatever the technology behind it, a store that is correct for Quartz does three things.

Atomic trigger acquisition. The scheduler thread asks acquireNextTriggers for the triggers whose next fire time is within the next moments, in order of fire time and priority. The store must hand each trigger to exactly one caller and mark it as reserved. The RAMJobStore does this under a synchronized block around an in-memory sorted set of triggers (TreeSet<TriggerWrapper> in the source). The JDBC store has no shared memory to lock, so it uses the database: it selects the due trigger keys (SELECT_NEXT_TRIGGER_TO_ACQUIRE), then flips the trigger row from WAITING to ACQUIRED with an UPDATE …​ WHERE TRIGGER_STATE = 'WAITING' (updateTriggerStateFromOtherState) and inserts a row in QRTZ_FIRED_TRIGGERS. If the update touches no row, another node got there first and the store moves on to the next candidate. By default this runs without any lock (the code takes the TRIGGER_ACCESS lock only when org.quartz.jobStore.acquireTriggersWithinLock is true or the scheduler acquires more than one trigger per call, org.quartz.scheduler.batchTriggerAcquisitionMaxCount greater than 1), and a validator re-reads QRTZ_FIRED_TRIGGERS to confirm that the acquisition really was committed.

Misfire scan. A trigger whose fire time is far enough in the past must be handled according to its misfire instruction rather than fired blindly. The RAM store applies this while it hands out triggers. The JDBC store runs a dedicated MisfireHandler thread that wakes up every misfireThreshold milliseconds, selects waiting triggers older than the threshold (hasMisfiredTriggersInState), handles at most maxMisfiresToHandleAtATime (20 by default) per pass, and signals the scheduler thread. The policies themselves are on Misfires.

Cluster locking. A store that several scheduler instances share needs a mutual exclusion primitive and a way to notice that an instance died. The JDBC store provides both with ordinary rows: the locks are rows of QRTZ_LOCKS taken with SELECT …​ FOR UPDATE (TRIGGER_ACCESS and STATE_ACCESS), and each instance writes a heartbeat row into QRTZ_SCHEDULER_STATE; an instance whose heartbeat is older than its check-in interval plus a grace period is declared dead and its QRTZ_FIRED_TRIGGERS rows are recovered by a surviving node. The detail of that protocol, with the measured fail-over time, is on Clustering. The RAM store is never clustered (RAMJobStore.isClustered() returns false).

The diagram shows where those calls sit in the life of one firing and which tables a JDBC store touches at each step.

sequenceDiagram autonumber participant T as QuartzSchedulerThread participant S as JobStore participant D as Database tables participant W as Worker thread T->>S: acquireNextTriggers (due within the window) S->>D: select due WAITING triggers, update one to ACQUIRED, insert QRTZ_FIRED_TRIGGERS row (ACQUIRED) S-->>T: the acquired triggers T->>S: triggersFired (at the fire time) S->>D: load calendar, compute next fire time, fired row to EXECUTING, trigger to WAITING or BLOCKED or COMPLETE S-->>T: job detail with merged job data T->>W: hand over, the job runs W->>S: triggeredJobComplete (instruction) S->>D: store job data if PersistJobDataAfterExecution, unblock triggers, delete the fired row
Figure 1. One firing as the job store sees it (read from `JobStoreSupport` in Quartz 2.5.2)

Source: 2.5.2 JobStoreSupport source (acquireNextTrigger, triggerFired, triggeredJobComplete, ClusterManager, MisfireHandler).

Table 2. How the two built-in families meet the obligations
Obligation RAMJobStore JDBC stores (JobStoreTX, JobStoreCMT, LocalDataSourceJobStore)

Atomic acquisition

synchronized on one lock object around an in-memory TreeSet of triggers

Optimistic UPDATE …​ WHERE TRIGGER_STATE = 'WAITING', optionally under the TRIGGER_ACCESS row lock

Misfire detection

Evaluated lazily when a trigger comes up for acquisition (applyMisfire)

MisfireHandler thread every misfireThreshold ms, plus recoverMisfiredJobs on startup

Persistence

None: supportsPersistence() is false, the schedule is the beans (or API calls) of each start

Everything in the tables, survives restarts and deployments

Clustering

Never

With org.quartz.jobStore.isClustered=true and instanceId=AUTO, through QRTZ_LOCKS and QRTZ_SCHEDULER_STATE

Default misfireThreshold

5000 ms (field default RAMJobStore; measured 5000 under Spring Boot)

60000 ms (JobStoreSupport; measured 60000 with LocalDataSourceJobStore on every database tried)

Source: Quartz documentation: Configure RAMJobStore (which still says 60000 for the RAM store; the source and the measurement above say 5000) and Configure JDBCJobStoreTX.

The built-in job stores

Quartz 2.5.2 ships org.quartz.simpl.RAMJobStore and three JDBC-based stores in org.quartz.impl.jdbcjobstore, of which one is Spring’s. (TerracottaJobStore was removed in 2.4.0, the 2.5.2 jar contains no Terracotta class, and what the Quartz website still says about it is obsolete.)

Table 3. The job stores you can choose between on Spring Boot 4.1
Store Who manages the connection and the transaction Locking and clustering Typical use

RAMJobStore

Nobody: there is no database

synchronized; never clustered

Tests, caches, anything that is rebuilt from code on every start. Boot’s default (spring.quartz.job-store-type=memory).

JobStoreTX (org.quartz.impl.jdbcjobstore)

Quartz itself: it obtains a connection from a Quartz-managed org.quartz.dataSource.NAME.* pool, sets auto-commit to false and commits or rolls back each operation

SimpleSemaphore (a JVM monitor) unless clustered or useDBLocks is true, otherwise row locks in QRTZ_LOCKS

Plain Quartz outside Spring, with its own pool

JobStoreCMT

The container: API calls join an existing (JTA) transaction; the scheduler’s own housekeeping uses a second, non-XA data source (nonManagedTXDataSource) with auto-commit off

Always database row locks (initialize forces useDBLocks)

Application servers with JTA

LocalDataSourceJobStore (org.springframework.scheduling.quartz)

Spring: it extends JobStoreCMT and borrows connections from the Spring-managed DataSource through DataSourceUtils, so API calls take part in Spring transactions

Database row locks, except that it switches to the in-JVM SimpleSemaphore when the product name contains hsql

What Spring Boot installs when job-store-type=jdbc

Two details of LocalDataSourceJobStore explain behaviour you will otherwise find puzzling. First, it registers two connection providers with Quartz’s DBConnectionManager, named springTxDataSource.<scheduler instance name> and springNonTxDataSource.<scheduler instance name>: the transactional one hands out the connection bound to the current Spring transaction (so scheduler.scheduleJob(…​) inside a @Transactional method commits or rolls back with your business data), the non-transactional one always opens a fresh connection (used by the scheduler thread, the misfire handler and the cluster manager, which manage their own commits). With an XA DataSource you pass a second, non-XA one through SchedulerFactoryBean.setNonTransactionalDataSource. Second, it calls setDontSetAutoCommitFalse(true), so Quartz does not touch the auto-commit flag of the connections it borrows from your pool.

Choosing the store from Spring Boot

Spring Boot exposes the choice as one property, spring.quartz.job-store-type, an enum with two values, memory (the default) and jdbc.

Selecting the JDBC store
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/bookshelf
    username: ${DB_USER}
    password: ${DB_PASSWORD}
  quartz:
    job-store-type: jdbc
    properties:
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate

What job-store-type=jdbc does, read from QuartzAutoConfiguration: the JDBC configuration class is active only when the property is jdbc and the context has a single candidate DataSource (@ConditionalOnSingleCandidate, so one bean, or one marked @Primary); it then calls schedulerFactoryBean.setDataSource(…​), and Spring’s SchedulerFactoryBean responds by putting org.quartz.jobStore.class=org.springframework.scheduling.quartz.LocalDataSourceJobStore into the Quartz properties unless one is already there. It also creates the schema initializer described below and imports DatabaseInitializationDependencyConfigurer. Three measured consequences:

Table 4. What the job-store-type property really does (Boot 4.1.1, Quartz 2.5.2)
Configuration Result Evidence

Nothing set (memory)

RAMJobStore, supportsPersistence=false, misfireThreshold=5000, 10 threads, scheduler name quartzScheduler

Measured in a test run

jdbc with one DataSource

LocalDataSourceJobStore, driverDelegateClass unset (so Quartz uses StdJDBCDelegate), misfireThreshold=60000

Measured on H2 and on every database of the support matrix

jdbc with two DataSource beans and none @Primary (a @QuartzDataSource among them makes no difference)

No error: Boot silently builds a RAMJobStore (supportsPersistence=false), and the schedule is lost at every restart

Measured in a test run

jdbc and org.quartz.jobStore.class=…​JobStoreTX in spring.quartz.properties

Startup fails with SchedulerConfigException: DataSource name not set. Do not override the class when Boot supplies the data source

Measured in a test run

memory and a custom org.quartz.jobStore.class

The custom class is used (third-party stores are plugged in this way, see Plugging in a store that is not JDBC)

Measured in a test run

The delegate is not detected: Spring Boot does not set driverDelegateClass, and JobStoreSupport falls back to StdJDBCDelegate. That is right for MySQL, MariaDB, H2 and Oracle, and wrong for PostgreSQL and SQL Server (see The driver delegates).

The selection of a store, for the Bookshelf services, comes down to the following questions.

flowchart TD A[Which store for this service?] --> B{Must the schedule survive a restart or be edited at runtime?} B -- no --> R[RAMJobStore: job-store-type memory, the default] B -- yes --> C{Several instances must share one schedule, or one job must run once per cluster?} C -- yes --> D[JDBC store with isClustered true and instanceId AUTO] C -- no --> E[JDBC store, not clustered] D --> F{Is there a relational database you can give Quartz tables?} E --> F F -- yes, one DataSource --> G[job-store-type jdbc, Flyway or Liquibase creates the tables] F -- yes, but the business data is elsewhere --> H[Dedicated DataSource with QuartzDataSource and QuartzTransactionManager] F -- no, only MongoDB or Redis --> I[Quartz on a small SQL database anyway, or a community store on Quartz 2.4.1 only, or Scheduled plus ShedLock]
Figure 2. Picking a job store

The comparison with in-process scheduling that needs no store at all is on Standard Scheduling vs Quartz.

The QRTZ_ tables

The JDBC stores use eleven tables. All of them are prefixed with QRTZ_ (configurable), and every primary key starts with SCHED_NAME, the scheduler name (spring.quartz.scheduler-name, bean name quartzScheduler when unset), which is what lets several logical schedulers share one set of tables.

Entity-relationship diagram of the QRTZ_ tables. On the left, QRTZ_JOB_DETAILS holds the job definitions, and below it QRTZ_FIRED_TRIGGERS holds what is running now, linked to the job and trigger by key without a foreign key. In the middle, QRTZ_TRIGGERS has a declared foreign key to the job and a logical reference by name to QRTZ_CALENDARS. On the right, the four trigger-type tables QRTZ_SIMPLE_TRIGGERS, QRTZ_CRON_TRIGGERS, QRTZ_SIMPROP_TRIGGERS and QRTZ_BLOB_TRIGGERS each have a declared foreign key to the trigger. At the bottom, QRTZ_SCHEDULER_STATE, QRTZ_LOCKS and QRTZ_PAUSED_TRIGGER_GRPS coordinate scheduler instances and have no foreign keys. Every primary key starts with SCHED_NAME
Figure 3. The main QRTZ_ tables and how they relate (declared foreign keys solid, logical references dashed)

Source: the DDL scripts in the 2.5.2 jdbcjobstore resources (read from tables_postgres.sql, tables_h2.sql and liquibase.quartz.init.xml).

Table 5. What each table is for
Table Purpose Written when

QRTZ_JOB_DETAILS

One row per JobDetail: key, job class name, description, the durable, non-concurrent, persist-data and requests-recovery flags, and the JobDataMap (JOB_DATA, a serialized object in a BLOB column, or bytea on PostgreSQL)

addJob, scheduleJob, and after every execution of a @PersistJobDataAfterExecution job

QRTZ_TRIGGERS

One row per trigger of any type: key, owning job, type (SIMPLE, CRON, CAL_INT, DAILY_I, BLOB), state (WAITING, ACQUIRED, BLOCKED, PAUSED, PAUSED_BLOCKED, COMPLETE, ERROR), NEXT_FIRE_TIME and PREV_FIRE_TIME (epoch milliseconds), priority, misfire instruction, calendar name, trigger JobDataMap

Whenever a trigger is scheduled, fires or changes state. This is the table the acquisition query reads.

QRTZ_SIMPLE_TRIGGERS

Repeat count, repeat interval and times triggered for `SimpleTrigger`s

With the trigger row

QRTZ_CRON_TRIGGERS

Cron expression and time zone id for `CronTrigger`s

With the trigger row

QRTZ_SIMPROP_TRIGGERS

A generic bag of string, int, long, decimal and boolean columns used by the calendar-interval and daily-time-interval triggers (and by custom trigger types that use the property-based persistence delegates)

With the trigger row

QRTZ_BLOB_TRIGGERS

A serialized trigger of a type Quartz has no table for

Only for custom trigger classes

QRTZ_CALENDARS

One row per named Calendar, serialized

addCalendar

QRTZ_PAUSED_TRIGGER_GRPS

The names of trigger groups that are paused (so that a trigger added later to a paused group starts paused)

pauseTriggers, pauseAll

QRTZ_FIRED_TRIGGERS

One row per trigger that is currently acquired or executing, with the instance that holds it and its REQUESTS_RECOVERY flag. This is how a surviving node knows what a crashed node was running

Inserted at acquisition, updated to EXECUTING at firing, deleted at completion

QRTZ_SCHEDULER_STATE

One heartbeat row per scheduler instance of a clustered scheduler: instance name, last check-in time and check-in interval

Every clusterCheckinInterval (7500 ms by default) when clustered

QRTZ_LOCKS

The rows that act as locks: TRIGGER_ACCESS and STATE_ACCESS per scheduler name. Quartz inserts the rows itself the first time it needs a lock (INSERT_LOCK in StdRowLockSemaphore), the DDL scripts contain none

Locked with SELECT …​ FOR UPDATE (or the vendor equivalent) whenever a lock is needed

Source: Quartz Tutorial: Lesson 9, Job Stores and the 2.5.2 DDL scripts and StdJDBCConstants.

Seeing them is the best way to understand them. The query below was run on the PostgreSQL database of a Bookshelf service started with Flyway, a JDBC store, the five Bookshelf triggers and one runtime reminder (reminders.loan-501, created with the reminder convention of the section).

Looking at what Quartz stored (PostgreSQL)
SELECT trigger_group, trigger_name, job_name, trigger_type, trigger_state,
       to_char(to_timestamp(next_fire_time/1000) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI') AS next_fire_utc,
       misfire_instr
FROM qrtz_triggers ORDER BY 1, 2;

SELECT trigger_name, cron_expression, time_zone_id FROM qrtz_cron_triggers ORDER BY 1;
SELECT trigger_name, repeat_count, repeat_interval, times_triggered FROM qrtz_simple_triggers ORDER BY 1;
SELECT sched_name, lock_name FROM qrtz_locks ORDER BY 2;
Table 6. Measured result: the stored triggers
Trigger Job Type State Next fire (UTC, run on 5 October 2026) Misfire instruction

bookshelf.cacheRefreshTrigger

cacheRefresh

SIMPLE

WAITING

2026-10-05 10:11 (every 300000 ms, times_triggered=1 after its first fire)

4

bookshelf.overdueLoanRemindersTrigger

overdueLoanReminders

CRON

WAITING

2026-10-06 00:00 (0 0 2 * * ?, time zone Europe/Madrid)

1

bookshelf.retentionCleanupTrigger

retentionCleanup

CRON

WAITING

2026-11-01 02:30 (0 30 3 1 * ?, Europe/Madrid)

0

bookshelf.weeklyStatisticsTrigger

weeklyStatistics

CRON

WAITING

2026-10-12 04:00 (0 0 6 ? * MON, Europe/Madrid)

0

reminders.loan-501

loan-501

SIMPLE

WAITING

2026-10-12 10:06 (repeat_count=0, one shot a week ahead)

0

Source: measured with the Bookshelf test project (QRTZ_TRIGGERS, QRTZ_CRON_TRIGGERS, QRTZ_SIMPLE_TRIGGERS).

Besides these rows the run showed QRTZ_LOCKS with exactly STATE_ACCESS and TRIGGER_ACCESS for scheduler name quartzScheduler (created on demand, clustering was on), one QRTZ_SCHEDULER_STATE row with checkin_interval=7500 and an instance name of the form \{host name}\{epoch milliseconds}, no QRTZ_FIRED_TRIGGERS rows (nothing was executing at that instant), five QRTZ_JOB_DETAILS rows and zero calendars. The JOB_DATA column held between 273 and 385 bytes per job: a serialized JobDataMap, which is why the map must contain Serializable values (see Jobs & Job Data).

tablePrefix and SCHED_NAME

org.quartz.jobStore.tablePrefix (default QRTZ_) lets several applications keep separate Quartz tables in one database without separate schemas. The prefix is a Quartz property, so under Boot it goes through spring.quartz.properties, and the DDL has to be edited to match, because the scripts and the Boot initializer know only QRTZ_. Both directions were run on PostgreSQL 17:

Table 7. tablePrefix and the schema script must agree (measured)
Configuration Result

tablePrefix=BKS_QRTZ_ and spring.quartz.jdbc.schema=classpath:custom/tables_bookshelf_prefix.sql (the PostgreSQL script with QRTZ_ replaced by BKS_QRTZ_)

Works: eleven tables named bks_qrtz_* are created and the jobs fire

tablePrefix=BKS_QRTZ_ with the stock script

Startup fails with PSQLException: ERROR: relation "bks_qrtz_job_details" does not exist

A prefixed schema (properties)
spring:
  quartz:
    job-store-type: jdbc
    jdbc:
      schema: classpath:custom/tables_bookshelf_prefix.sql
    properties:
      org.quartz.jobStore.tablePrefix: BKS_QRTZ_
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate

In practice a separate schema or database per application is cleaner than a prefix (the dedicated data source in A dedicated Quartz database does exactly that), and spring.quartz.scheduler-name already separates the rows of schedulers that share tables.

useProperties

By default the JobDataMap of a job and of a trigger is stored as a serialized Java object. org.quartz.jobStore.useProperties=true switches the format: the map is stored as the text of a java.util.Properties file, which makes the column readable and survives class changes, at the price that every key and value must be a String. Quartz enforces it: with useProperties=true and the code-defined Integer value runs, the startup of the Bookshelf service failed with

Measured: the useProperties restriction
IOException: JobDataMap values must be Strings when the 'useProperties' property is set.  Key of offending value: runs

Source: Quartz documentation: Configure JDBCJobStoreTX and StdJDBCDelegate in the 2.5.2 source.

Use it when the map holds only strings and you want to evolve job classes freely; keep the default when jobs carry records or numbers (the Bookshelf MemberReminderJob stores memberId and loanId as Long). The trade-offs of putting data in a JobDataMap at all are on Jobs & Job Data.

The driver delegates

Every statement the JDBC stores issue goes through a driver delegate, an implementation of org.quartz.impl.jdbcjobstore.DriverDelegate. The base class StdJDBCDelegate uses plain JDBC and works wherever JDBC does what it is told; the subclasses exist to handle database-specific BLOB, boolean or locking behaviour. The delegate is chosen by org.quartz.jobStore.driverDelegateClass, which Spring Boot leaves unset (so StdJDBCDelegate).

Table 8. The delegates in the Quartz 2.5.2 jar (org.quartz.impl.jdbcjobstore, plus the oracle sub-package)
Delegate What it is for (from its Javadoc and the measured runs) Used with

StdJDBCDelegate

The default. Measured to work on MySQL, MariaDB, H2 and (with the right script and driver) Oracle; measured to fail on PostgreSQL and SQL Server

MySQL, MariaDB, H2, Oracle, Informix (the Informix script says to use it)

PostgreSQLDelegate

PostgreSQL JDBC driver: reads and writes bytea columns correctly. Mandatory on PostgreSQL: with StdJDBCDelegate the first trigger store fails with Bad value for type long : \xaced0005…​ (measured)

PostgreSQL

MSSQLDelegate

SQL Server. With it, JobStoreSupport also defaults selectWithLockSQL to a statement with WITH (UPDLOCK,ROWLOCK). Mandatory on SQL Server: with StdJDBCDelegate the lock statement fails with FOR UPDATE clause allowed only for DECLARE CURSOR (measured)

SQL Server

oracle.OracleDelegate

Oracle 10 and 11 BLOB handling (note the oracle sub-package). Measured on Oracle Free with ojdbc 23.x: works with tables_oracle23.sql, and does not cure the flag-column error of tables_oracle.sql; StdJDBCDelegate behaved identically in those runs

Oracle

oracle.weblogic.WebLogicOracleDelegate, WebLogicDelegate

BLOB handling for Oracle inside WebLogic 8.1, and the WebLogic JDBC driver

WebLogic only

HSQLDBDelegate

HSQLDB

HSQLDB

DB2v6Delegate, DB2v7Delegate, DB2v8Delegate

DB2: v6 replaces select count(name) with count(*); v7 stores booleans in varchar(1) and writes BLOBs with setObject; v8 stores booleans in an integer column

DB2 (not run)

SybaseDelegate, PointbaseDelegate

Sybase ASE and Pointbase

Sybase, Pointbase (not run)

CUBRIDDelegate, GaussDBDelegate, CacheDelegate

CUBRID, GaussDB (derived from PostgreSQLDelegate) and InterSystems Caché (works with the Oracle scripts)

CUBRID, GaussDB, Caché (not run)

Source: Quartz documentation: Configure JDBCJobStoreTX and the 2.5.2 delegate sources. The list was read from the jar and the sources.

A discrepancy worth knowing about: the Quartz configuration page and some write-ups list CloudscapeDelegate, which does not exist in 2.5.2 (the comment at the top of the shipped tables_derby_previous.sql and the tables_cloudscape.sql script still name it). The three DB2 delegates are in the 2.5.2 jar and sources.

The DDL scripts in the jar

The Quartz jar carries the DDL, under org/quartz/impl/jdbcjobstore/. Reading the 2.5.2 jar gives 25 scripts: tables_postgres, tables_mysql, tables_mysql_innodb, tables_oracle, tables_oracle23, tables_sqlServer, tables_h2, tables_hsqldb, tables_hsqldb_old, tables_derby, tables_derby_previous, tables_db2, tables_db2_v72, tables_db2_v8, tables_db2_v95, tables_sybase, tables_cubrid, tables_gauss_default_compatibility, tables_gauss_m_compatibility, tables_informix, tables_firebird, tables_cloudscape, tables_pointbase, tables_sapdb and tables_solid (all .sql), plus liquibase.quartz.init.xml. The jars of 2.3.2 and 2.4.1 contain 22 scripts; tables_oracle23.sql and the two GaussDB scripts are absent from them and present in 2.5.2, and only the 2.5.2 SQL Server script uses NVARCHAR (0 occurrences in 2.3.2 and 2.4.1, 55 in 2.5.2: measured by reading the jars). Three facts about the scripts matter for everything that follows: the PostgreSQL, MySQL and HSQLDB scripts start with DROP TABLE IF EXISTS for every table (the H2 script has only CREATE statements, and the Derby script’s drops are commented out), the Oracle scripts start with delete from and drop table statements without IF EXISTS, and the SQL Server script starts with USE [enter_db_name_here] and uses GO as a batch separator.

Managing the schema

The tables must exist before the scheduler starts. Spring Boot can create them for you, and a migration tool can create them better.

spring.quartz.jdbc.initialize-schema

spring.quartz.jdbc.initialize-schema takes the Spring DatabaseInitializationMode values embedded (the default), always and never. The initializer is the bean QuartzDataSourceScriptDatabaseInitializer, created only when the property is not never; it runs the script selected by spring.quartz.jdbc.schema before the scheduler is created.

Table 9. The three initialization modes, measured on PostgreSQL 17 and H2 (Boot 4.1.1)
Mode Embedded database (H2) Real database (PostgreSQL)

embedded (default)

The eleven tables are created (qrtz tables=11)

Nothing is created. Startup fails with PSQLException: ERROR: relation "qrtz_job_details" does not exist wrapped in Couldn’t retrieve job

always

Script runs on every start

The script runs on every start. Because it begins with DROP TABLE IF EXISTS, every stored job and trigger is deleted at every restart: a job named survivor scheduled at runtime existed after a restart with never and did not exist after a restart with always

never

Nothing runs; on an empty database Table "QRTZ_JOB_DETAILS" not found

Nothing runs; on an empty database the same relation …​ does not exist failure

Why always destroys triggers. The scripts are installers, not migrations: most of them (PostgreSQL, MySQL, HSQLDB, Oracle) start by dropping every QRTZ_ table so that they can create it afresh. Spring Boot does not know that, it simply runs the script again at each startup. (H2’s script has no drops: with always its CREATE statements fail on "already exists" errors, which the default continue-on-error=true swallows, so the data is kept.) The result is a service that works perfectly in development (where an empty schedule at every start is what you want) and silently loses every runtime-created reminder, every paused trigger and every @PersistJobDataAfterExecution counter in production. The code-defined jobs reappear because the beans re-register them, which hides the problem until someone notices that the per-member reminders are gone. Use always for throwaway tests and demos, embedded (the default) for an in-memory H2, and never with Flyway or Liquibase for anything that has data worth keeping. Note the opposite trap of the default: embedded on a real database is not "create if missing", it is "do nothing", and the failure message is the missing table.

schema, platform, comment-prefix and continue-on-error

The other four properties of the spring.quartz.jdbc group, with the defaults read from QuartzJdbcProperties and DatabaseInitializationProperties:

Table 10. The remaining spring.quartz.jdbc properties
Property Default What it does

spring.quartz.jdbc.schema

classpath:org/quartz/impl/jdbcjobstore/tables_@@platform@@.sql

The script (or comma-separated scripts) to run. @@platform@@ is replaced with the platform below. A location that matches no file fails at startup with IllegalStateException: No schema scripts found at location 'classpath:nope/tables.sql' (measured). Set it to run your own edited copy, as in the prefix example above and the SQL Server edit on its page.

spring.quartz.jdbc.platform

auto-detected from the DataSource

The value that replaces @@platform@@. Boot detects the database and maps it to the Quartz script name: DB2 to db2_v95, MySQL and MariaDB to mysql_innodb, PostgreSQL to postgres, SQL Server to sqlServer; every other detected id is used unchanged (Oracle gives tables_oracle.sql, H2 tables_h2.sql, HSQLDB tables_hsqldb.sql). Set it only to force a different script of the Quartz set.

spring.quartz.jdbc.comment-prefix

["#", "--"]

The prefixes that mark a whole line of the script as a comment. They are handed to Spring’s ResourceDatabasePopulator.setCommentPrefixes; # is there because the MySQL and DB2 scripts use it and -- because the others do. Change it only if you bring a script with a different comment style. (Read from the source, not separately run.)

spring.quartz.jdbc.continue-on-error

true

Whether a failing statement of the script is skipped. The default is the unusual one: errors in the script are swallowed. Spring Boot 4.x only: on 3.5.16 the property does not exist and the initializer hard-codes true.

continue-on-error=true is why a wrong platform or a script that cannot run through Spring does not fail where it happens. All of these were run:

Table 11. What continue-on-error and platform do when the script is wrong (measured, PostgreSQL 17 unless stated)
Situation Result

platform=h2 on PostgreSQL, continue-on-error=true (the default)

Every statement of tables_h2.sql fails and is skipped, nothing is created, the application then fails with relation "qrtz_job_details" does not exist. The real cause is not in the log of the failing start

platform=h2 on PostgreSQL, continue-on-error=false

Startup fails at the first statement with ERROR: type "image" does not exist: the cause is visible

A script whose first statement fails (INSERT INTO this_table_does_not_exist), default

The statement is skipped and the application starts with all eleven tables; with false the start fails with Failed to execute SQL script statement #1

Oracle tables_oracle.sql on an empty schema, true

The leading delete from qrtz_fired_triggers and drop table statements fail on missing tables and are skipped; the script then creates the tables. With false it fails with ORA-00942. (Oracle with ojdbc 23.x still needs tables_oracle23.sql, see the Oracle page.)

SQL Server script as shipped, true

USE [enter_db_name_here] fails, nothing is created, the application fails later with Invalid object name 'QRTZ_JOB_DETAILS'

Source: measured with the Bookshelf test project (the schema-properties test of this page and the per-database runs behind the support matrix).

A good habit while developing a schema script or a new database page is to set continue-on-error: false so every error is visible, and to leave the default for the unavoidable case of the Oracle scripts that Boot runs. In production none of this applies, because Boot should not run the script at all.

On Spring Boot 3.5.16 spring.quartz.jdbc.continue-on-error does not exist: the jdbc settings are a nested class with four properties (initialize-schema, schema, platform, comment-prefix) and the initializer hard-codes continue-on-error=true, so script errors are always skipped there and a continue-on-error: false line is not an error but has no effect. The property was added in Spring Boot 4.0.

The scheduler waits for the schema

Whatever creates the tables, it has to finish before the scheduler asks for a trigger. Spring Boot guarantees that with its database-initialization ordering: QuartzAutoConfiguration imports DatabaseInitializationDependencyConfigurer, and the class SchedulerDependsOnDatabaseInitializationDetector declares that beans of type Scheduler and SchedulerFactoryBean depend on database initialization. The initializers it recognizes include Boot’s own script initializers (Quartz’s, spring.sql.init), Flyway and Liquibase. The effect is visible in the measured runs of the next two sections: with initialize-schema=never and an empty database, the Flyway or Liquibase migration ran first and the scheduler started on the tables it had just created, with no @DependsOn anywhere. Source: 4.1.1 SchedulerDependsOnDatabaseInitializationDetector source.

Flyway

For anything beyond a demo the schema is a versioned migration, like the rest of the database (the reasoning, and how to choose a tool, are on Schema Evolution and Choosing a Migration Tool; Flyway has no page of its own there). Take the Quartz script for your database from the jar, delete the DROP TABLE lines, and keep it as a migration.

Dependencies for Flyway on Spring Boot 4.1 (Maven)
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
</dependency>
Producing the migration from the Quartz jar
unzip -p quartz-2.5.2.jar org/quartz/impl/jdbcjobstore/tables_postgres.sql \
  | sed '/^DROP TABLE/d' > src/main/resources/db/migration/V1__quartz_tables.sql
Letting Flyway own the schema (application.yml)
spring:
  flyway:
    enabled: true            # default; locations default to classpath:db/migration
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: never
    properties:
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate

Measured on postgres:17 with Flyway 12.4.0 and the file above named V1__quartz_tables.sql: on an empty database the start created the eleven qrtz_* tables plus flyway_schema_history (one row, version 1, description quartz tables, success true), the four Bookshelf jobs fired (cacheRefresh three times, overdueLoanReminders twice) and the job store was LocalDataSourceJobStore with PostgreSQLDelegate. A second start on the migrated database found nothing to do (still one history row) and ran the jobs again. The control run, with nothing managing the schema and initialize-schema=never, failed with relation "qrtz_job_details" does not exist.

The Flyway starter appeared in Spring Boot 4.0 (the 4.0.0 milestone releases are the first versions of spring-boot-starter-flyway on Maven Central); on Spring Boot 3.5.16 add flyway-core and the database module directly. The property names under spring.flyway.* are the same.

A Quartz upgrade that changes the DDL (2.5.2 changed the SQL Server script to NVARCHAR columns, see What changed) becomes a new V2__…​ migration written by you, which is the point of owning the schema: the Quartz jar changes whenever Boot does, and you decide when the tables change.

Liquibase

The Quartz jar also contains a Liquibase changelog, org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml. It is inside the Quartz jar (org.quartz-scheduler:quartz, present in the 2.3.2, 2.4.1 and 2.5.2 jars: measured), not in a Spring Boot jar; Boot does not reference it, you include it from your own changelog. It declares one change set (id="quartz-init", author="quartz") that creates the eleven tables with a table-prefix property table_prefix (QRTZ_), their primary keys, indexes and foreign keys, and maps the BLOB columns to BYTEA on PostgreSQL and BLOB elsewhere through a blob_type property. It uses BOOLEAN for the flag columns and needs no DROP TABLE. Two ways to use it were run on postgres:17 with Liquibase 5.0.3:

Liquibase with the bundled changelog (db/changelog/quartz-bundled.yaml)
databaseChangeLog:
  - include:
      file: org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml
Liquibase with the SQL script as a change set (db/changelog/quartz-sqlfile.yaml)
databaseChangeLog:
  - changeSet:
      id: quartz-tables-2.5.2
      author: bookshelf
      changes:
        - sqlFile:
            path: db/changelog/quartz/tables_postgres_nodrop.sql
            splitStatements: true
            endDelimiter: ;
Pointing Spring Boot at the changelog (application.yml)
spring:
  liquibase:
    change-log: classpath:db/changelog/quartz-bundled.yaml
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: never
    properties:
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate

Both variants created the eleven tables and databasechangelog and databasechangeloglock, the changelog table held one row (quartz-init from org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml, or quartz-tables-2.5.2 from the YAML), and the Bookshelf jobs fired with PostgreSQLDelegate. (Without spring.liquibase.change-log Liquibase looks for classpath:/db/changelog/db.changelog-master.yaml and the start fails with …​ does not exist if that file is missing, which is why the measured runs disable Liquibase when Flyway is the tool and the other way round.) Choose the bundled file when you want Quartz’s own definition (it is database-neutral and tracks the Quartz release), and the sqlFile variant when you need the vendor script exactly as Quartz ships it, for example the Oracle or SQL Server script with edits. The Liquibase concepts are on Liquibase: Getting Started and Liquibase: Changelogs and Changesets. As with Flyway, the starter is a Boot 4 name; on 3.5 depend on liquibase-core.

Table 12. Who should create the Quartz tables
Situation Recommendation

Unit tests and demos on an in-memory H2

embedded (the default): nothing to configure

Integration tests with Testcontainers

never plus the same migration as production (the test then proves the migration), or always when the test creates a fresh container anyway

Development on a real database that you drop often

always is acceptable; remember it deletes the schedule at every start

Staging and production

never plus Flyway or Liquibase, owned by the team that owns the database

A dedicated Quartz database

Quartz does not have to share the application’s database. Two reasons are common: the business data lives in a database Quartz cannot use (MongoDB, Redis, Elasticsearch: none of them has a Quartz job store that works on the Quartz 2.5.2 that Boot manages, see Plugging in a store that is not JDBC), or the team wants the high-churn trigger tables, their locking and their vacuuming out of the business database. Spring Boot supports this with two qualifier annotations in org.springframework.boot.quartz.autoconfigure.

  • @QuartzDataSource marks the DataSource bean Quartz must use. It is honoured in two places in QuartzAutoConfiguration: the data source passed to SchedulerFactoryBean.setDataSource, and the data source the schema initializer runs the script against. Without it Boot uses the single (or @Primary) DataSource.

  • @QuartzTransactionManager marks the PlatformTransactionManager bean that Boot sets on the SchedulerFactoryBean with setTransactionManager. It exists in Spring Boot 4.1.1 (the source carries @since 4.0.0 for the new package; the 3.5.16 jar also contains it, in the old package: measured by listing the jar). Its Javadoc says it is meant for a secondary transaction manager when another one is marked @Primary. Without it Boot takes the one transaction manager it can find (ObjectProvider.getIfUnique(), so the @Primary one if there are several), and none at all if there is no unique candidate.

What the transaction manager is used for: SchedulerFactoryBean registers the JobDetail, Calendar and Trigger beans in a single transaction when it starts. The transaction is only meaningful if the transaction manager manages the Quartz data source. This was measured: the registration was made to fail halfway (two durable JobDetail`s and a `Trigger that names a calendar that does not exist, which makes scheduleJob throw SchedulerException: Calendar not found: doesNotExist after both jobs were stored), with the business DataSource (H2) @Primary and Quartz on PostgreSQL 17.

Table 13. Which transaction manager Quartz registers its beans with (measured)
Configuration Transactions begun Rows left in QRTZ_JOB_DETAILS after the failed start

@QuartzDataSource and @QuartzTransactionManager (the application’s manager is @Primary)

Quartz manager 1, application manager 0

0: the registration was rolled back

@QuartzDataSource only; the @Primary manager belongs to the application data source

Application manager 1, Quartz manager 0

2: the manager used is for a different database, so nothing is rolled back

Source: Spring Boot: Quartz Scheduler and the 4.1.1 QuartzTransactionManager and QuartzAutoConfiguration sources.

The second row is the pitfall: a half-registered schedule after a failed start is not catastrophic, but the same mismatch applies to every scheduler.scheduleJob(…​) you call from a @Transactional service: the application transaction does not include the Quartz tables, so a rollback of the business transaction leaves the trigger in place. If you want the two to commit together they must be in the same database (use one DataSource), and if they cannot be, treat the scheduler call as a separate step (see the consistency discussion on the MongoDB page).

Business data in one database, Quartz in another (Bookshelf, compiled and run)
@Configuration(proxyBeanMethods = false)
class BookshelfDataSources {

    // business data
    @Bean @Primary
    @ConfigurationProperties("bookshelf.datasource")
    DataSourceProperties appDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean @Primary
    @ConfigurationProperties("bookshelf.datasource.hikari")
    HikariDataSource appDataSource(@Qualifier("appDataSourceProperties") DataSourceProperties properties) {
        return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
    }

    @Bean @Primary
    PlatformTransactionManager appTransactionManager(DataSource appDataSource) {
        return new DataSourceTransactionManager(appDataSource);
    }

    // Quartz tables
    @Bean
    @ConfigurationProperties("bookshelf.quartz.datasource")
    DataSourceProperties quartzDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean @QuartzDataSource
    @ConfigurationProperties("bookshelf.quartz.datasource.hikari")
    HikariDataSource quartzDataSource(@Qualifier("quartzDataSourceProperties") DataSourceProperties properties) {
        return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
    }

    @Bean @QuartzTransactionManager
    PlatformTransactionManager quartzTransactionManager(@QuartzDataSource DataSource quartzDataSource) {
        return new DataSourceTransactionManager(quartzDataSource);
    }
}

Source: Spring Boot: Quartz Scheduler and Spring Boot: Data Access How-to (configuring two data sources).

The matching properties (application.yml)
bookshelf:
  datasource:
    url: jdbc:postgresql://localhost:5432/bookshelf
    username: ${DB_USER}
    password: ${DB_PASSWORD}
  quartz:
    datasource:
      url: jdbc:postgresql://localhost:5432/bookshelf_quartz
      username: ${QUARTZ_DB_USER}
      password: ${QUARTZ_DB_PASSWORD}
      hikari:
        maximum-pool-size: 13       # threadCount 10 + 3, see the pool sizing section
spring:
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: never
    properties:
      org.quartz.threadPool.threadCount: "10"
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate

Run with an in-memory H2 as the business database and postgres:17 as the Quartz database (with initialize-schema=always to keep the experiment short): the scheduler reported LocalDataSourceJobStore, ten threads, the Quartz pool at 13 and the application pool at the Hikari default of 10, the cache-refresh job fired three times, the application H2 contained 0 QRTZ tables and the Quartz PostgreSQL contained 11 tables with the trigger row in qrtz_triggers. This is also the pattern for keeping business data in a NoSQL database: the Bookshelf catalogue in MongoDB and Quartz on a small PostgreSQL, a full example of which is on the MongoDB page (and on the Redis page); the SQL side of it is exactly the configuration above. On Spring Boot 3.5 the same two annotations live in org.springframework.boot.autoconfigure.quartz.

Sizing the connection pool

Quartz needs more connections than people expect. The scheduler thread, the misfire handler and the cluster manager each use the database, and every worker thread uses a connection when its job completes (triggeredJobComplete updates the trigger and deletes the fired row). The Quartz documentation states the rule on both the data sources page and the best-practices page: configure the data source’s maximum connections to at least the number of worker threads in the pool plus three, more if the application calls the scheduler API often, and at least four for the non-managed data source of a JobStoreCMT. The extra three match the three housekeeping threads; the page you are reading verified the rule against the documentation, not by exhausting a pool. (Some write-ups say threads plus two; the official text says plus three, which is what to use.)

The default makes it matter. Quartz’s thread pool defaults to 10 threads (measured 10 under Boot) and Hikari’s default pool is 10 connections, shared with the application, so a busy 10-thread scheduler on the application’s default pool is already under the recommendation. Raise the pool, or give Quartz its own, as in the previous section:

Pool sizing for the default ten threads (application.yml)
spring:
  datasource:
    hikari:
      maximum-pool-size: 20     # application traffic (about 7) plus Quartz (10 threads + 3)
  quartz:
    properties:
      org.quartz.threadPool.threadCount: "10"

When Quartz manages its own pool (plain Quartz, or a JobStoreTX set up by hand) the pool is configured with org.quartz.dataSource.NAME.*; the documented property that selects HikariCP is provider = hikaricp, not the driver = hikaricp that an example on the same page uses, and since Quartz 2.4.0 HikariCP and c3p0 are provided dependencies you have to add yourself. The production consequences, including the one scheduler per database connection budget of a cluster, are on Production & Best Practices.

Code-defined jobs and stored jobs: overwrite-existing-jobs

With a persistent store there are now two sources of truth for a job: the JobDetail and Trigger beans in the code and the rows the database already has. Spring’s SchedulerFactoryBean reconciles them when it starts, in SchedulerAccessor.registerJobsAndTriggers, controlled by one switch, spring.quartz.overwrite-existing-jobs (default false). The flow, read from the source:

flowchart TD S[Context refresh: SchedulerFactoryBean.afterPropertiesSet creates the scheduler and its job store] --> T{Transaction manager set?} T -- yes --> TB[Begin one transaction] T -- no --> J TB --> J[For each JobDetail bean] J --> J1{overwrite-existing-jobs true or job not stored yet?} J1 -- yes --> J2[addJob with replace true] J1 -- no --> J3[Skip: the stored job and its data stay] J2 --> C J3 --> C[For each Calendar bean: addCalendar with replace and updateTriggers, always] C --> R[For each Trigger bean] R --> R1{Trigger stored already?} R1 -- no --> R2[scheduleJob: store the trigger, with its job if that is not stored] R1 -- yes, overwrite false --> R3[Skip: stored trigger, state and next fire time stay] R1 -- yes, overwrite true --> R4[rescheduleJob: replace the stored trigger by the bean] R2 --> E R3 --> E R4 --> E{Any exception?} E -- no --> OK[Commit; the scheduler is started later by the SmartLifecycle] E -- yes --> KO[Roll back, shut the scheduler down, the context fails to start]
Figure 4. How a code-defined JobDetail bean reaches the job store on startup (`SchedulerAccessor`, Spring Framework 7.0.9)

Registration happens in afterPropertiesSet, before start; a scheduler that registers the same beans on every node (a cluster) is protected by the store: a race on the same trigger key ends in an ObjectAlreadyExistsException that Spring logs at debug level and ignores ("assumably due to cluster race condition"). What the flow does not do matters as much: it never deletes anything. A job whose bean you removed from the code stays in the database and keeps firing; a job created at runtime is never touched.

The effect was measured with the Bookshelf overdue-loan reminders on PostgreSQL 17 and the JDBC store: a first deployment (version 1 of the beans), then the same kind of changes production makes by itself (the job incremented its persisted counter to 7, an operator paused the trigger, a reminder reminders.loan-501 was scheduled at runtime), then a redeployment with version 2 of the beans (a different description, job data, cron expression and calendar, a new newsletter job and the retiredJob bean removed), once with overwrite-existing-jobs=false and once with true.

Table 14. What each deployment leaves in the database (measured)
Item After the changes made in production Redeploy v2, overwrite false (default) Redeploy v2, overwrite true

Job overdueLoanReminders: description and data

v1, runs=7, channel=email

unchanged: v1, runs=7, channel=email

replaced by the bean: v2, runs=100, channel=sms (the production counter 7 is lost)

Trigger overdueLoanRemindersTrigger: cron and state

0 0 2 * * ?, PAUSED

unchanged: 0 0 2 * * ?, PAUSED

replaced: 0 0 3 * * ?, NORMAL (the operator’s pause is lost; next fire 03:00)

Job and trigger newsletter (new in v2)

absent

added (not stored yet)

added

Job retiredJob (bean removed in v2)

stored

still stored

still stored

Job reminders.loan-501 created at runtime

stored

untouched

untouched

Calendar spanishHolidays (1 day in v1, 3 days in v2)

1 excluded day

3 excluded days: calendars are replaced always

3 excluded days

Source: measured with the Bookshelf test project; behaviour read from SchedulerAccessor.registerJobsAndTriggers, addJobToScheduler and addTriggerToScheduler.

The practical rules that follow from the table:

  • With the default (false) the code is only the initial definition: a changed cron expression, misfire instruction or job data in the code does not reach an already deployed environment. This is what you want when operators edit schedules at runtime (pause, reschedule) and a restart must not undo it; it is a trap when you change a cron in a pull request and nothing changes in production. The Boot reference says the same: a restart does not re-create or change jobs that already exist.

  • With true every start resets every code-defined job and trigger to the code’s version, including state: paused triggers resume, and @PersistJobDataAfterExecution counters go back to the bean’s value. It is the right setting for schedules that are code-only and where the code is the truth; it is wrong for any job whose data the job itself maintains.

  • Neither setting removes a job. Remove obsolete jobs explicitly, through a one-off migration or an administrative call (Scheduler.deleteJob), or accept that they keep firing.

  • The calendar is always replaced, even with false, and updateTriggers=true makes the stored triggers that use it recompute their next fire time.

  • A middle road that keeps both properties is to leave overwrite-existing-jobs false and update specific jobs from code at startup (a SchedulerFactoryBeanCustomizer or an ApplicationRunner that calls rescheduleJob for the triggers whose definition you changed, compare first, replace only on difference). The dynamic management of schedules is the subject of Dynamic Scheduling & Management.

Never write to the QRTZ tables yourself

The tables look like an ordinary schema, and a UPDATE QRTZ_TRIGGERS SET TRIGGER_STATE = 'PAUSED' is tempting during an incident. The Quartz best-practices page has a section titled "Never Write Directly To Quartz’s Tables", and warns that doing so can corrupt data, make jobs vanish without running or appear stuck, and cause deadlocks. The reason is visible in the structure above: a trigger’s state, its next fire time, the fired-trigger row of its current execution, the row locks and the in-memory view of the scheduler thread all have to agree, and Quartz maintains that agreement in code under locks, in several tables at once, with the serialized JobDataMap and calendar blobs in binary form. Read the tables freely (the queries above are safe, a read-only reporting user is a good idea), and change them only through the Scheduler API, the Actuator quartz endpoint (trigger-now only) or your own management endpoints. Everything you do through the API is also correct for every other node of a cluster at once, because it goes through the store. Source: Quartz documentation: Best Practices

Plugging in a store that is not JDBC

Because Spring Boot passes spring.quartz.properties.* straight to Quartz’s StdSchedulerFactory, any class implementing org.quartz.spi.JobStore can be installed by name. The behaviour was measured against Boot 4.1.1:

Table 15. Setting org.quartz.jobStore.class (measured)
Configuration Result

job-store-type=memory and org.quartz.jobStore.class=org.quartz.simpl.RAMJobStore

Works, RAMJobStore, threshold 5000

job-store-type=memory and a class CountingRamJobStore extends RAMJobStore

The custom class is honoured; its storeTrigger was called four times. A third-party store therefore plugs in with job-store-type left at memory

job-store-type=jdbc and org.quartz.jobStore.class=…​JobStoreTX

Fails with SchedulerConfigException: DataSource name not set.

Source: Quartz documentation: Configure the scheduler and the 4.1.1 QuartzAutoConfiguration source.

Installing a non-JDBC store (properties; the class and its own properties are the store’s)
spring:
  quartz:
    job-store-type: memory           # leave it: jdbc would add a DataSource and the Spring store
    properties:
      org.quartz.jobStore.class: com.example.quartz.MyJobStore
      org.quartz.jobStore.someSetting: someValue

Whether the existing community stores are usable is a different question, answered by running them. On the baseline of this section, Quartz 2.5.2, neither is: quartz-mongodb 2.2.0-rc2 starts, reports a started scheduler and never fires a job (the scheduler thread dies with a NoSuchMethodError for JobDetail.isConcurrentExectionDisallowed(), a method removed in Quartz 2.5), and quartz-redis-jobstore 1.2.0 fails with a NoSuchMethodError on JedisCommands.del for every Jedis newer than 3.3.0 that was tried (4.4.8 to 7.4.1) and, even with Jedis 3.3.0, does not work on Quartz 2.5.2 (no job fires, and the clustered start fails with a Jackson error). Both work on Quartz 2.4.1 with suitable driver versions (Jedis 3.3.0 for Redis; MongoDB driver 4.0.5 or 5.8.1). The full matrix, with the exact versions, is on MongoDB and Redis; the recommendation there, and the one to follow here, is Quartz on a small SQL database (A dedicated Quartz database) when you need Quartz features, and @Scheduled with ShedLock (see Scheduling & ShedLock) when you only need one execution per cluster.

Database support matrix

Which database works with which delegate and script, with what was actually run. "Measured" means the Bookshelf jobs fired, a runtime job with a serialized JobDataMap fired, and the state survived a restart with initialize-schema=never, and for the clustering column that two schedulers of the same name shared the schedule with no duplicate execution and work spread across them. "Not run" rows are listed from the jar and the sources only.

Table 16. Database support matrix for Quartz 2.5.2 on Spring Boot 4.1.1
Database Status Delegate or store class DDL script (Quartz 2.5.2 jar) Clustering Page

PostgreSQL 17.11 (pgJDBC 42.7.13)

Official (JDBC)

PostgreSQLDelegate, mandatory (measured)

tables_postgres.sql (Boot platform postgres)

Measured: three schedulers in one JVM and a kill -9 fail-over (11.2 s with a 2 s check-in), no duplicates

PostgreSQL

MySQL 9.7.2 (Connector/J 9.7.0)

Official

StdJDBCDelegate (measured)

tables_mysql_innodb.sql (Boot maps MySQL to it). tables_mysql.sql has no ENGINE clause and warns to use InnoDB: do not use it

Measured with two nodes

MySQL and MariaDB

MariaDB 11.8.9 (Connector/J 3.5.10)

Official

StdJDBCDelegate (measured)

tables_mysql_innodb.sql (there is no MariaDB script; Boot maps MariaDB to it)

Measured with two nodes

MySQL and MariaDB

Oracle Free (gvenzl/oracle-free:slim-faststart, ojdbc11 23.26.3.0.0)

Official

StdJDBCDelegate or oracle.OracleDelegate: both worked with tables_oracle23.sql

tables_oracle23.sql (BOOLEAN flags) with the 23.x driver; tables_oracle.sql (VARCHAR2(1) flags) fails with ORA-12899 under ojdbc 23.x and works with ojdbc 21.9. Boot’s default for Oracle is tables_oracle.sql, so set spring.quartz.jdbc.schema

Measured with two nodes (tables_oracle23.sql, StdJDBCDelegate)

Oracle

SQL Server 2022 (mssql/server:2022-latest under Rosetta, mssql-jdbc 13.4.0)

Official

MSSQLDelegate, mandatory (measured)

tables_sqlServer.sql must be edited for Boot (remove the USE […​] line, replace each GO line by ;); Boot’s platform id is sqlServer

Measured with two nodes (edited script, MSSQLDelegate); a short test did not reproduce the clustered check-in deadlock reported upstream (Quartz issue 1514, fixed on main after 2.5.2)

SQL Server

H2 2.4.240

Official, for development and tests

StdJDBCDelegate

tables_h2.sql (created by embedded mode, measured)

Measured for development only (AUTO_SERVER=TRUE, two JVMs); not for production

Embedded and other SQL

HSQLDB

Official

HSQLDBDelegate

tables_hsqldb.sql (tables_hsqldb_old.sql also ships)

Not safe across processes. The store stays clustered (isClustered=true), but Spring’s LocalDataSourceJobStore calls setUseDBLocks(false) for products whose name contains hsql and installs a JVM-local SimpleSemaphore, so there is no cross-process lock and a multi-process cluster is unsafe

Embedded and other SQL

Apache Derby

Official script

Not named by the script; StdJDBCDelegate is the starting point

tables_derby.sql, tables_derby_previous.sql

Not run

Embedded and other SQL

DB2, Sybase, CUBRID, GaussDB, InterSystems Caché

Official scripts and delegates

DB2v8Delegate (or v6, v7), SybaseDelegate, CUBRIDDelegate, GaussDBDelegate, CacheDelegate

tables_db2_v95.sql (Boot’s DB2 choice), tables_sybase.sql, tables_cubrid.sql, tables_gauss_default_compatibility.sql and _m_compatibility, the Oracle scripts for Caché

Not run

Embedded and other SQL

MongoDB 8.3.11

Community, archived (quartz-mongodb 2.2.0-rc2)

com.novemberain.quartz.mongodb.MongoDBJobStore, no DDL

None; collections prefixed qrtz_ are created on demand

Measured: fails on Quartz 2.5.2 (no job ever fires, silently); passes single and clustered on Quartz 2.4.1 and 2.3.2

MongoDB

Redis 8.10.2

Community (quartz-redis-jobstore 1.2.0)

net.joelinn.quartz.jobstore.RedisJobStore, no DDL

None; keys under a configurable prefix

Measured: fails on Quartz 2.5.2 (with Jedis 3.3.0 and with Boot’s 7.4.1); works only on Quartz 2.4.1 with Jedis 3.3.0 (single and clustered)

Redis

Source: the measured runs behind the database pages linked in the last column and the 2.5.2 DDL scripts; community stores: quartz-mongodb (archived) and quartz-redis-jobstore.

The two rows to remember are the ones that surprise people: PostgreSQL and SQL Server need their delegates, Oracle needs the 23 script on a modern driver, and the community NoSQL stores are not a Quartz 2.5 option.

Worked example: the Bookshelf schedule on PostgreSQL

Putting the page together for the Bookshelf service: PostgreSQL as the store, Flyway owning the schema, never for Boot, the PostgreSQL delegate, a pool that satisfies the sizing rule, and overwrite-existing-jobs left false so that operators' pauses survive a deployment.

Where the pieces live
bookshelf-service/
  pom.xml                                   spring-boot-starter-quartz, -jdbc, -flyway, flyway-database-postgresql, postgresql
  src/main/java/bookshelf/
    BookshelfSchedule.java                  the JobDetail and Trigger beans (see Getting Started with Spring Boot)
  src/main/resources/
    application.yml                         the configuration below
    db/migration/V1__quartz_tables.sql      tables_postgres.sql from the Quartz jar, DROP TABLE lines removed
application.yml of the Bookshelf service
spring:
  datasource:
    url: jdbc:postgresql://${DB_HOST:localhost}:5432/bookshelf
    username: ${DB_USER}
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 20
  flyway:
    enabled: true
  quartz:
    job-store-type: jdbc
    overwrite-existing-jobs: false
    jdbc:
      initialize-schema: never
    properties:
      org.quartz.threadPool.threadCount: "10"
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
A Testcontainers test that the schedule really is in PostgreSQL (run)
@Testcontainers
@SpringBootTest(classes = BookshelfApplication.class, properties = {
        "spring.quartz.job-store-type=jdbc",
        "spring.quartz.jdbc.initialize-schema=never",
        "spring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.PostgreSQLDelegate"})
class BookshelfJobStoreIT {

    @Container
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");

    @DynamicPropertySource
    static void datasource(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired Scheduler scheduler;
    @Autowired JdbcTemplate jdbc;

    @Test
    void bookshelfScheduleIsStoredInPostgreSql() throws Exception {
        assertThat(scheduler.getMetaData().isJobStoreSupportsPersistence()).isTrue();
        assertThat(jdbc.queryForObject(
                "SELECT count(*) FROM information_schema.tables WHERE table_name LIKE 'qrtz\\_%'", Integer.class)).isEqualTo(11);
        assertThat(jdbc.queryForObject("SELECT count(*) FROM qrtz_job_details", Integer.class)).isEqualTo(4);
        assertThat(scheduler.checkExists(JobKey.jobKey("overdueLoanReminders", "bookshelf"))).isTrue();
        assertThat(scheduler.getTriggerState(TriggerKey.triggerKey("weeklyStatisticsTrigger", "bookshelf")))
                .isEqualTo(TriggerState.NORMAL);
    }
}

The test passed (1 test, 0 failures, about ten seconds including the container start) with the V1 migration on the test classpath and the Flyway starter’s default locations. It uses the Testcontainers 2.x class org.testcontainers.postgresql.PostgreSQLContainer and the artifacts testcontainers-postgresql and testcontainers-junit-jupiter. More on testing schedulers, including a three-node cluster test, is on Testing and Clustering; the general Testcontainers set-up is on Spring Boot Integration Tests with Testcontainers.

The Flyway migration, @Transactional services that call the scheduler and the isolation level of the lock queries all interact with the data layer of the rest of the service; see Spring Data JPA and Transaction Isolation & Locking.

Pitfalls

Table 17. Job store and persistence pitfalls (all measured unless marked source)
Pitfall What happens What to do

initialize-schema=always against a database you care about

The script drops every QRTZ_ table first, so every runtime job, pause and persisted counter is deleted at each restart

never plus Flyway or Liquibase; keep always for tests

The default embedded on a real database

Nothing is created; startup fails with relation "qrtz_job_details" does not exist

Let a migration tool create the schema, or use always once in development

continue-on-error=true (the default) hides script errors

A wrong platform or an unrunnable script (SQL Server, a mismatched platform) creates nothing and the failure shows later as a missing table

Set it to false while developing the script

No driverDelegateClass on PostgreSQL or SQL Server

Bad value for type long : \xaced…​ (PostgreSQL) or FOR UPDATE clause allowed only for DECLARE CURSOR (SQL Server) at the first trigger store

Set PostgreSQLDelegate or MSSQLDelegate

Two DataSource beans, none @Primary (with or without a @QuartzDataSource)

No error: Boot silently uses RAMJobStore; the schedule is lost at restart

Mark the application’s data source @Primary and the Quartz one @QuartzDataSource; assert isJobStoreSupportsPersistence() in a test

@QuartzDataSource without @QuartzTransactionManager while a @Primary manager exists for another database

Registration runs in the wrong transaction (two job rows left after a failed start); API calls from @Transactional code do not commit with the business data

Add @QuartzTransactionManager, or keep Quartz and business data in one database

Overriding org.quartz.jobStore.class with jdbc

DataSource name not set.

Leave the class to Boot; custom stores need job-store-type: memory

overwrite-existing-jobs=false and a changed cron in the code

The deployed environment keeps the old cron and job data

Redeploy with true once, update the trigger with rescheduleJob, or change it through the management endpoint

overwrite-existing-jobs=true with a counter kept in the JobDataMap

Every restart resets the counter, resumes paused triggers and resets the description

Keep it false for jobs that maintain their own data

Removing a job bean

The stored job and trigger remain and keep firing

Delete it explicitly with Scheduler.deleteJob

Writing to QRTZ_ tables by hand

Corrupt state, vanished or stuck jobs, deadlocks (Quartz best practices)

Use the Scheduler API

Pool smaller than threads plus three

Worker threads and the housekeeping threads wait for connections (the Quartz guidance; not exhausted in a test here)

Size the pool for the thread count, or give Quartz its own pool

useProperties=true with non-String job data

Startup or addJob fails with JobDataMap values must be Strings…​

Keep strings only, or leave the default

A tablePrefix the script does not use

relation "bks_qrtz_job_details" does not exist

Edit the script and set spring.quartz.jdbc.schema with the same prefix

A community NoSQL store on Quartz 2.5

MongoDB: scheduler "started", no job ever fires. Redis: startup failure

Quartz on SQL plus MongoDB or Redis for data, or ShedLock

Source: this page’s measured results and the Quartz Best Practices.

What changed

Table 18. Changes that touch job stores and persistence
Release Change

Quartz 2.4.0

TerracottaJobStore removed (the 2.3.2 jar contains eight Terracotta classes, the 2.4.1 and 2.5.2 jars none); clustering is JDBC-only. HikariCP and c3p0 become provided dependencies, so a Quartz-managed pool needs them added.

Quartz 2.5.0

Jakarta namespace (jakarta.transaction in JobStoreCMT) and Java 11; the misspelled JobDetail.isConcurrentExectionDisallowed() is gone, which breaks community stores compiled against 2.4 and earlier.

Quartz 2.5.x scripts

The jar grows from 22 DDL scripts (2.3.2 and 2.4.1) to 25 in 2.5.2: tables_oracle23.sql (Oracle 23ai, BOOLEAN columns) and two GaussDB scripts. The SQL Server script uses NVARCHAR columns (commit of 29 November 2025, in the released 2.5.2).

After Quartz 2.5.2 (unreleased on main)

Pull request 1515 binds the scheduler-state instance name as NVARCHAR in MSSQLDelegate (the fix for clustered check-in deadlocks on SQL Server); not part of 2.5.2 or of any released version.

Spring Boot 3.5 to 4.x

The Quartz auto-configuration moved to org.springframework.boot.quartz.autoconfigure in the new spring-boot-quartz module: @QuartzDataSource, @QuartzTransactionManager, SchedulerFactoryBeanCustomizer and QuartzProperties change package, the property names (spring.quartz.job-store-type, spring.quartz.jdbc.*, spring.quartz.overwrite-existing-jobs) do not. The Flyway and Liquibase starters appear in 4.0.

Spring Framework 6.2 to 7.0

LocalDataSourceJobStore and SchedulerAccessor keep their behaviour; the 7.0 sources add JSpecify nullness annotations.

Documentation versus code

The Quartz configuration page says the RAM store’s misfireThreshold is 60000 (it is 5000, 60000 is the JDBC store and the bundled quartz.properties); lists CloudscapeDelegate (not in 2.5.2); and uses driver = hikaricp (the property is provider). The Boot reference says the SQL Server script "may need" edits (it cannot run through Boot unedited). The collected list is on Migration & What’s Changed.

Source: Quartz releases and the Spring Boot 4.0 Migration Guide. The jar comparisons were made by listing the 2.3.2, 2.4.1 and 2.5.2 jars.

Where to go next