Migration and What’s Changed

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.

Scheduling code outlives the frameworks around it. This page collects every migration that the rest of the section mentions in passing: upgrading Quartz itself (2.3 to 2.4 to 2.5), upgrading Spring Boot (3.5 to 4.x), replacing the deprecated StatefulJob, moving from @Scheduled and ShedLock to Quartz, and moving a job store from one database to another. The last section is the one place where the places in which the official documentation, older articles and the code disagree are gathered in tables, together with how each was settled. Each topic points to the page that explains the mechanism; this page is the checklist.

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 or read from the sources (the results are quoted, not assumed). The DDL differences between Quartz releases were produced by running diff on the scripts inside the quartz-2.3.2.jar, quartz-2.4.1.jar and quartz-2.5.2.jar files from Maven Central. On Spring Boot 3.5.16 the Quartz side is identical (Boot 3.5 also manages Quartz 2.5.2); the differences of the Boot side are listed in Spring Boot 3.5 to 4.x. The running scenario is Bookshelf.

Quartz 2.3 to 2.4 to 2.5

Quartz never reached 3.0: there are two parallel release lines. The 2.4 line (2.4.0 and 2.4.1) keeps javax.* and Java 8; the 2.5 line (2.5.0 to 2.5.2) moved to Jakarta and Java 11. The table is the union of the release notes of the four releases and what was measured in the jars.

Table 1. What changed between Quartz 2.3, 2.4 and 2.5
Change Release What you do

Minimum Java version is 8 (2.4) and then 11 (2.5)

2.4.0, 2.5.0

Run 2.5 on Java 11 or later (the section’s examples target Java 21). Staying on Java 8 means staying on 2.4.1.

Move to the Jakarta namespace (breaking)

2.5.0

Quartz 2.5.2 itself uses jakarta.transaction (in JobStoreCMT and the JTA job shells, measured in the sources) and jakarta.xml.bind for the XML scheduling-data plugin. Job classes that import javax. types from Quartz callbacks (JTA UserTransaction, servlet context) need the jakarta. imports. Spring Boot 3.x and 4.x are already Jakarta, so applications on Spring never see the difference; the old javax line only matters for Spring Boot 2.x.

Build moved to Gradle; examples run with ../gradlew :examples:runExampleN

2.4.0

Nothing for users of the artifacts. The documented "13 examples" are 15 in the repository.

Third-party libraries (c3p0, HikariCP, slf4j, log4j) are declared with provided scope in the published POMs

2.4.0

Add c3p0 or HikariCP yourself when Quartz creates its own pool (org.quartz.dataSource.<name>.provider). Spring Boot users are unaffected: Boot supplies the DataSource (see below).

TerracottaJobStore removed

2.4.0

Clustering is JDBC only. Move a Terracotta setup to JobStoreTX or LocalDataSourceJobStore with isClustered=true (Clustering). The class name still appears in StdSchedulerFactory as a dead branch (a check on org.terracotta.quartz), which is harmless.

NativeJob removed from the quartz-jobs artifact (code-execution risk)

2.4.0

Write an own job, or run a process from a job after validating the command. The jar of quartz-jobs 2.5.2 still contains DirectoryScanJob, FileScanJob, NoOpJob, SendMailJob, JMXInvokerJob, the JMS jobs and the EJB jobs (read from the sources); no NativeJob.

L can be combined with other values in the day-of-month field (for example 5,15,L)

2.5.1

Expressions that were rejected before are now valid; nothing breaks. See Cron Expressions.

JobDetail.isConcurrentExectionDisallowed() (misspelled) removed; only isConcurrentExecutionDisallowed() remains

2.5.0 (measured: absent in 2.5.2)

Code compiled against 2.4 or earlier that calls the misspelled method fails at run time with NoSuchMethodError. This is exactly how the archived quartz-mongodb store dies, silently, on 2.5.x (Quartz with MongoDB).

tables_oracle23.sql added (BOOLEAN columns for Oracle Database 23ai)

2.5.0 (present in the 2.5.0, 2.5.1 and 2.5.2 jars, absent in 2.4.1)

Use it with the ojdbc 23.x driver; the VARCHAR2 script tables_oracle.sql fails with ORA-12899 there (Quartz with Oracle).

SQL Server script: the four IMAGE columns (CALENDAR, BLOB_DATA and JOB_DATA twice) replaced by VARBINARY(max), and four CREATE NONCLUSTERED INDEX statements added

2.4.0 (diff of the 2.3.2 and 2.4.1 scripts: 75 changed lines; the 2.3.2 script has no such index, the 2.4.1 and 2.5.2 scripts have four)

Existing SQL Server schemas keep working; new schemas get the new types and indexes.

SQL Server script: VARCHAR name columns replaced by NVARCHAR

2.5.2 (diff 2.4.1 to 2.5.2: 110 changed lines; the 2.5.0 and 2.5.1 jars still use VARCHAR)

Migrating an existing SQL Server schema is a user task (see below); the unreleased delegate fix #1515 is separate (Quartz with SQL Server).

All other scripts (tables_postgres.sql, tables_mysql_innodb.sql, tables_h2.sql, tables_hsqldb.sql, tables_derby.sql, tables_db2_v95.sql)

2.3.2 to 2.4.1 to 2.5.2

Identical: the diff of each script across the three releases is empty. tables_oracle.sql differs by one corrected word in a comment. So a PostgreSQL, MySQL or H2 schema created by Quartz 2.3 needs no change for 2.5 (measured with diff).

liquibase.quartz.init.xml

Present in the 2.3.2, 2.4.1 and 2.5.2 jars, identical in 2.3.2 and 2.5.2

Not new. Boot’s reference points at it; see Job Stores & Persistence for using it.

Source: Quartz 2.4.0 release notes, Quartz 2.5.0 release notes; the DDL rows were measured by diffing the scripts of the three jars.

In the diff output of the SQL Server row, [CALENDAR] [IMAGE] NOT NULL becomes [CALENDAR] [VARBINARY] (max) NOT NULL, and [JOB_DATA] [IMAGE] NULL and [BLOB_DATA] [IMAGE] NULL change the same way.

Upgrading the application

For an application on Spring Boot the upgrade is a version bump, because Boot manages Quartz. The checklist, in order:

  1. Check which Quartz your Boot version manages (mvn dependency:tree -Dincludes=org.quartz-scheduler): Boot 3.5.16 and Boot 4.1.1 both manage 2.5.2. Overriding quartz.version to an older release is possible because the Boot parent exposes it as a property, and is what the community stores force (Quartz with Redis).

  2. Replace the javax. imports in your jobs and listeners with jakarta. if you come from Boot 2.x.

  3. Remove NativeJob and TerracottaJobStore usages.

  4. Grep for isConcurrentExectionDisallowed: any library that calls it breaks at run time.

  5. Recompile; replace StatefulJob (next section).

  6. Compare your schema with the 2.5.2 script of your database (the table above); only Oracle 23ai with ojdbc 23.x and a new SQL Server schema need a different script.

  7. Restart one node against a copy of the production database first. For a clustered deployment, graceful shutdown and rolling deployments are on Production & Best Practices.

For a plain-Java (non-Spring) application the one new item is the pool: with Quartz 2.4 or later, c3p0 and HikariCP are not transitive dependencies any more. The run below reproduces the failure with Quartz 2.5.2 and H2 and no pool library on the classpath, and then the fix.

A Quartz-managed pool without the pool library on the classpath (Quartz 2.5.2)
Could not initialize DataSource: ds: ConnectionProvider class 'org.quartz.utils.C3p0PoolingConnectionProvider'
could not be instantiated. InvocationTargetException: com/mchange/v2/c3p0/ComboPooledDataSource

Source: Quartz documentation: Configuring DataSources. Measured with a plain StdSchedulerFactory and no pool on the classpath: the default provider is c3p0, which is not a transitive dependency.

The fix: add the library and name the provider (the documentation page says driver = hikaricp; the property is provider)
org.quartz.dataSource.bookshelf.provider = hikaricp
org.quartz.dataSource.bookshelf.driver = org.postgresql.Driver
org.quartz.dataSource.bookshelf.URL = jdbc:postgresql://${DB_HOST}:5432/bookshelf
org.quartz.dataSource.bookshelf.user = ${DB_USER}
org.quartz.dataSource.bookshelf.password = ${DB_PASSWORD}

Source: Quartz documentation: Configuring DataSources. Run with HikariCP 7.0.2 and an H2 URL: the scheduler started. The ${…​} values are placeholders for your own environment.

One more trap came out of that run. The data source name (bookshelf above) is global to the JVM: Quartz keeps its connection providers in a static registry, so two schedulers in one JVM that both call their data source ds end up sharing whichever was registered last. In the test, the first scheduler suddenly saw an empty database after the second was created. Give every scheduler its own data source name (under Spring Boot this does not arise, because Boot passes the DataSource object itself).

Spring Boot 3.5 to 4.x

Boot 3.5.16 and 4.1.1 manage the same Quartz 2.5.2, so a Boot upgrade does not change scheduling behaviour. What changes is packaging and a few properties. The package of SchedulerFactoryBeanCustomizer, @QuartzDataSource and @QuartzTransactionManager is the thing you will notice (a compile error), and the rest is on the table.

Table 2. Spring Boot 3.5.16 versus 4.1.1 for Quartz
Topic Spring Boot 3.5.16 Spring Boot 4.1.1

Module and starters

spring-boot-starter-quartz, auto-configuration inside spring-boot-autoconfigure

New module spring-boot-quartz; starters spring-boot-starter-quartz and spring-boot-starter-quartz-test (listed in the 4.0 migration guide)

Package of QuartzAutoConfiguration, @QuartzDataSource, @QuartzTransactionManager, SchedulerFactoryBeanCustomizer, QuartzProperties, JobStoreType

org.springframework.boot.autoconfigure.quartz

org.springframework.boot.quartz.autoconfigure

QuartzEndpoint

org.springframework.boot.actuate.quartz

org.springframework.boot.quartz.actuate.endpoint; its auto-configuration is in org.springframework.boot.quartz.autoconfigure

spring.quartz.jdbc.continue-on-error

Does not exist: the initializer hard-codes true, so script errors are always skipped

Exists, default true; set false to make script errors fail the start

Endpoint switch

management.endpoint.quartz.access (not run on 3.5.16)

management.endpoint.quartz.access; measured: access=none removes the endpoint bean and access=read-only keeps it, and the old enabled=false still removed it in 4.1.1, so it is tolerated but is not the supported form

POST /actuator/quartz/jobs/{group}/{name} (trigger now)

Added in 3.5 (release notes: "Triggering Quartz Jobs From the Actuator")

Same; body {"state":"running"} is the only accepted state

Spring Framework

6.2.19

7.0.9

Web and test starters used by the section’s REST examples

spring-boot-starter-web, spring-boot-starter-test

spring-boot-starter-webmvc, spring-boot-starter-webmvc-test; AutoConfigureMockMvc in org.springframework.boot.webmvc.test.autoconfigure

Spring Security (for the secured management API)

6.5.11

7.1.1

Managed client libraries used on the database pages

Jedis 6.0.0, MongoDB driver 5.5.2

Jedis 7.4.1, MongoDB driver 5.8.1

Source: Spring Boot 4.0 Migration Guide, Spring Boot 3.5 Release Notes; the packages were read from spring-boot-autoconfigure-3.5.16.jar and spring-boot-quartz-4.1.1.jar, the property facts from the 3.5.16 and 4.1.1 sources.

A migration is a find-and-replace on the imports of the customizer and the two annotations, then a run of the test suite. The spring.quartz.* names did not change. If your 3.5 configuration contains spring.quartz.jdbc.continue-on-error: false, it has had no effect so far (Boot 3.5 does not bind it, so the line is silently ignored); on 4.x it starts working, so a script error that was hidden for years now fails the start. That is the behaviour you wanted, but run the first 4.x start in a test environment. The full property reference is on Spring Boot Configuration.

The import changes, the whole of the code migration
// Spring Boot 3.5.16
import org.springframework.boot.autoconfigure.quartz.QuartzDataSource;
import org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer;

// Spring Boot 4.1.1
import org.springframework.boot.quartz.autoconfigure.QuartzDataSource;
import org.springframework.boot.quartz.autoconfigure.SchedulerFactoryBeanCustomizer;

Source: Spring Boot: Quartz Scheduler. The two packages were confirmed by listing the jars of 3.5.16 and 4.1.1.

StatefulJob to annotations

org.quartz.StatefulJob is deprecated in 2.5.2 (its Javadoc says to use @DisallowConcurrentExecution and/or @PersistJobDataAfterExecution). The interface is itself annotated with both, so the replacement is mechanical: remove implements StatefulJob, add the two annotations. The test below builds a job detail from each form under Spring Boot 4.1.1 and compares the flags that the scheduler reads.

OverdueLoanRemindersJob before and after
// before: deprecated marker interface
public class OverdueLoanRemindersJob extends QuartzJobBean implements StatefulJob {
    @Override
    protected void executeInternal(JobExecutionContext context) { /* ... */ }
}

// after: the two annotations that the interface stands for
@PersistJobDataAfterExecution
@DisallowConcurrentExecution
public class OverdueLoanRemindersJob extends QuartzJobBean {
    @Override
    protected void executeInternal(JobExecutionContext context) { /* ... */ }
}

Source: Quartz API: StatefulJob. Run: isConcurrentExecutionDisallowed()=true and isPersistJobDataAfterExecution()=true for both forms.

The two annotations are independent, and the old interface forced you to take both. Keep both for a job that counts or keeps a cursor in its JobDataMap (as OverdueLoanRemindersJob does with its runs counter); use only @DisallowConcurrentExecution for a job that must not overlap but keeps no state; and note that @PersistJobDataAfterExecution alone lets two executions run in parallel and overwrite each other’s data. The Spring Framework reference still tells you to "implement the Stateful interface" to prevent overlap; follow the annotations. The mechanics are on Jobs & Job Data.

From @Scheduled and ShedLock to Quartz

Move to Quartz when you need what @Scheduled cannot give: schedules and jobs that survive a restart and can be changed at run time, misfire policies, calendars, and one execution per tick across a cluster without a lock library. Do not move for a single instance with a fixed cron (the decision is on Standard Scheduling vs Quartz). The existing material for the starting point is Scheduling and ShedLock.

The migration of the nightly reminders job is shown below in full. The old side is a @Scheduled method guarded by ShedLock’s @SchedulerLock; the new side is a QuartzJobBean, a JobDetail and a Trigger. A test project ran both side by side: in a few seconds the @Scheduled method and the Quartz job each ran more than twice.

Before: standard scheduling with ShedLock (outline)
@Component
class OverdueLoanReminders {

    @Scheduled(cron = "0 0 2 * * MON-FRI", zone = "Europe/Madrid")
    @SchedulerLock(name = "overdueLoanReminders", lockAtMostFor = "PT10M", lockAtLeastFor = "PT1M")
    void remind() {
        // find overdue loans, send reminders
    }
}

Source: ShedLock README; the @Scheduled attributes are described on Spring Framework: Task Execution and Scheduling.

After: a job, a durable job detail and a cron trigger
@DisallowConcurrentExecution
public class OverdueLoanRemindersJob extends QuartzJobBean {

    private final ReminderService reminders;

    OverdueLoanRemindersJob(ReminderService reminders) {   // constructor injection: Boot's job factory
        this.reminders = reminders;
    }

    @Override
    protected void executeInternal(JobExecutionContext context) {
        reminders.sendOverdue();
    }
}

@Configuration(proxyBeanMethods = false)
class BookshelfSchedule {

    @Bean
    JobDetail overdueLoanRemindersJobDetail() {
        return JobBuilder.newJob(OverdueLoanRemindersJob.class)
                .withIdentity("overdueLoanReminders", "bookshelf")
                .storeDurably().build();
    }

    @Bean
    Trigger overdueLoanRemindersTrigger(JobDetail overdueLoanRemindersJobDetail) {
        return TriggerBuilder.newTrigger().forJob(overdueLoanRemindersJobDetail)
                .withIdentity("overdueLoanRemindersTrigger", "bookshelf")
                .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 ? * MON-FRI")
                        .inTimeZone(TimeZone.getTimeZone("Europe/Madrid"))
                        .withMisfireHandlingInstructionFireAndProceed())
                .build();
    }
}

Source: Spring Boot: Quartz Scheduler. The version verified in a test project (field-less job, one-second cron to keep the run short) ran under Boot 4.1.1; constructor injection into jobs is covered on Getting Started with Spring Boot.

The mapping, line by line:

Table 3. Translating the pieces
@Scheduled / ShedLock Quartz Notes

cron = "0 0 2 * * MON-FRI"

cronSchedule("0 0 2 ? * MON-FRI")

Quartz needs ? in exactly one of day-of-month and day-of-week, so the restricted day-of-week keeps MON-FRI and day-of-month becomes ?. Both fire at 02:00 Madrid on weekdays; compare the next fire times of both forms before the cut-over, especially around the DST change (02:00 does not exist on the spring-forward day), as shown on Cron Expressions.

Day-of-week numbers

0 0 8 ? * 1 is Sunday

Spring 0 (or 7) is Sunday, Quartz 1 is Sunday and 7 is Saturday. Both returned Sunday 2026-10-11 08:00 for the respective expressions. Use names (SUN, MON-FRI) to avoid the trap; see Cron Expressions.

zone = "Europe/Madrid"

.inTimeZone(…​)

Without it Quartz uses the JVM’s default zone, as @Scheduled does.

fixedRate, fixedDelay, initialDelay

simpleSchedule().withIntervalInMilliseconds(…​).repeatForever(), startAt(…​)

fixedDelay has no equivalent: Quartz triggers are fixed-rate. Combine @DisallowConcurrentExecution with a rate for "not overlapping".

@SchedulerLock(lockAtMostFor, lockAtLeastFor)

org.quartz.jobStore.isClustered=true on a JDBC store, plus @DisallowConcurrentExecution

No lock table of your own. There is no lockAtLeastFor: with clock-synchronized nodes a trigger fires once per scheduled time. ShedLock skips a missed tick silently; Quartz applies the misfire policy (Misfires). See Clustering.

@EnableScheduling, TaskScheduler

Not needed for Quartz jobs; the two can coexist

Keep @Scheduled for the cheap in-process chores (a cache refresh) and move only what needs persistence. Quartz jobs do not show up in /actuator/scheduledtasks (Observability).

Lost schedule at restart

Persisted in the QRTZ_ tables

A JobDetail or Trigger bean is stored at startup only if its key does not exist yet (overwrite-existing-jobs defaults to false), so a changed cron in code is not applied to an existing trigger: see the checklist.

Checklist for the cut-over:

  • Create the Quartz tables with Flyway or Liquibase before the first start (initialize-schema stays never), as described on Job Stores & Persistence.

  • Deploy the Quartz job with the @Scheduled annotation removed in the same release; running both double-sends. If a rolling deployment needs overlap, keep the ShedLock lock name in the Quartz job as a guard for the transition (the guard is a few lines in executeInternal).

  • Remember that an existing trigger keeps its old cron: change it with rescheduleJob, or set spring.quartz.overwrite-existing-jobs=true so that the beans replace the stored definitions at each start (Dynamic Scheduling & Management).

  • Drop the shedlock table and dependency when no other job uses them.

  • Spring Batch jobs launched from the old @Scheduled method keep working: call the JobOperator (Spring Batch 6; JobLauncher on Boot 3.5) from the Quartz job (Running a Job).

Migrating a job store between databases

The QRTZ_ tables are Quartz’s private format, so the dependable migration paths work through the Scheduler API, not through INSERT …​ SELECT. Raw table copies hide three traps: every row carries SCHED_NAME, the blob columns differ between databases (BYTEA, BLOB, IMAGE or VARBINARY), and the QRTZ_FIRED_TRIGGERS and QRTZ_SCHEDULER_STATE rows of the old cluster must not be copied at all. The three options, from safest to riskiest:

Table 4. Three ways to move the schedule
Way How Good for

Rebuild from code

Create the schema in the target database, point the application at it, and let the JobDetail and Trigger beans register themselves on startup. Re-create the runtime-created jobs from your own source of truth (for Bookshelf, the loans table: a reminder per open loan).

Applications whose schedule is defined in code or can be rebuilt from business data. Loses paused states, JobDataMap counters and trigger history.

Copy through the Scheduler API

Read every job and trigger from the old scheduler and write them to a scheduler on the new database (below).

Runtime-created jobs that exist nowhere else; works across any two databases and from quartz-mongodb (run on Quartz 2.4.1 for the read side).

Copy the tables

Dump the eleven tables with the database’s tools and load them, after emptying QRTZ_FIRED_TRIGGERS and QRTZ_SCHEDULER_STATE.

Same database engine only (for example a PostgreSQL major-version move or a new host); a blob type mismatch breaks it across engines.

flowchart TD A["Create the QRTZ_ schema in the target database"] --> B["Stop the old scheduler: standby, or stop every node"] B --> C{"Same engine?"} C -->|yes| D["Dump and load the tables, minus FIRED_TRIGGERS and SCHEDULER_STATE"] C -->|no| E["Copy through the Scheduler API: getJobKeys, getJobDetail, getTriggersOfJob, scheduleJob with replace"] E --> F["Re-apply paused triggers"] D --> G["Start one node on the target"] F --> G G --> H["Compare job and trigger counts and next fire times"] H --> I["Start the remaining nodes; retire the old database"]

The copy program below was run against two H2 databases with Quartz 2.5.2 (job store JobStoreTX, HikariCP). The source holds the nightly reminders job with its cron trigger and a per-member reminder with a serialized record in its JobDataMap, whose trigger is paused. The program prints what the target contains.

CopySchedule.java: copy jobs, triggers, job data and paused state between two schedulers
static int copy(Scheduler source, Scheduler target) throws SchedulerException {
    int jobs = 0;
    for (String group : source.getJobGroupNames()) {
        for (JobKey key : source.getJobKeys(GroupMatcher.jobGroupEquals(group))) {
            JobDetail detail = source.getJobDetail(key);
            List<Trigger> triggers = new ArrayList<>(source.getTriggersOfJob(key));
            if (triggers.isEmpty()) {
                target.addJob(detail, true);                       // durable job without triggers
            } else {
                target.scheduleJob(detail, new HashSet<>(triggers), true);   // replace = true: safe to re-run
            }
            for (Trigger t : triggers) {
                if (source.getTriggerState(t.getKey()) == Trigger.TriggerState.PAUSED) {
                    target.pauseTrigger(t.getKey());               // paused state is not part of the Trigger
                }
            }
            jobs++;
        }
    }
    return jobs;
}

Source: Quartz API: Scheduler. Run with the output below.

Output of the run (Quartz 2.5.2, two H2 databases)
copied jobs = 2
reminders.loan-42 -> reminders.loan-42 state=PAUSED misfire=0 next=Mon Oct 12 13:17:08 CEST 2026 data=Reminder[member=m-7, loan=42]
bookshelf.overdueLoanReminders -> bookshelf.overdueLoanRemindersTrigger state=NORMAL misfire=1 next=Tue Oct 06 08:00:00 CEST 2026 data=null
idempotent second run = 2

Source: Quartz documentation: Cookbook. Measured; the cron trigger kept its misfire instruction (1, fire once and proceed) and the paused state and the record survived the copy.

The details that matter:

  • The copy must run with the job classes on the classpath, because getJobDetail deserializes the JobDataMap (and a target store that uses serialization needs the classes too). Run it inside the new application, not from a database tool.

  • addJob(detail, true) and scheduleJob(detail, triggers, true) replace an existing definition, which makes the copy re-runnable; but addJob(replace=true) replaces the whole JobDataMap (no merge).

  • Trigger state other than paused (a trigger in ERROR, a completed one-shot) is not carried over: only PAUSED is re-applied above. A one-shot trigger whose time has passed is a misfire on the target and follows its instruction (Misfires).

  • Do not start the old and the new scheduler as active schedulers at the same time on the same jobs: both would fire them. Put the old one in standby (or stop it) before the first start on the target.

  • Never run a non-clustered scheduler with the production scheduler name against the production tables: its start-up wipes QRTZ_FIRED_TRIGGERS.

  • From H2 (development) to PostgreSQL the move is usually the "rebuild from code" row; see Embedded & Other SQL Databases for the profile split that avoids the problem.

  • From quartz-mongodb or quartz-redis-jobstore to a JDBC database: the stores are broken on Quartz 2.5.x, so the read side must run on Quartz 2.4.1 (with Jedis 3.3.0 for Redis). Read the jobs with the program above in a throwaway process, write them to the JDBC store, and then start the application on Quartz 2.5.2. The target data then lives in PostgreSQL (or another database) and the application data stays in MongoDB or Redis as described on Quartz with MongoDB and Quartz with Redis.

  • Moving an existing SQL Server schema from the VARCHAR columns (2.4.1 and earlier, and 2.5.0, 2.5.1) to the NVARCHAR columns of 2.5.2 is not a documented procedure; the safe way is a new schema created from the 2.5.2 script and the API copy above.

The stale-documentation table

The Quartz website is partly carried over from 1.x and 2.x, and the Spring documentation and older articles repeat some of it. The section follows the Quartz 2.5.2 source and Javadoc, the Spring Boot 4.1.1 source and the Spring Framework 7.0.9 source. Four of these claims needed a test to settle; they are tagged Tested below with the result and where it is explained in detail. The first table collects the best-known discrepancies; the next three collect what was found while writing and running the pages.

Table 5. Known discrepancies between the official documentation and 2.5.2
Topic The documentation says The code does Tested

Terracotta

Lesson 9, Lesson 11, the introduction and the FAQ still describe TerracottaJobStore; ConfigTerracottaJobStore.html is a 404 for 2.5.x but still linked

Removed in 2.4.0. Clustering is JDBC only

JDBC delegates

CloudscapeDelegate and DB2v6/v7/v8Delegate are listed

There is no Cloudscape delegate in the 2.5.2 jar. The delegates that exist are StdJDBCDelegate, PostgreSQLDelegate, MSSQLDelegate, HSQLDBDelegate, SybaseDelegate, PointbaseDelegate, WebLogicDelegate, CUBRIDDelegate, GaussDBDelegate, CacheDelegate, DB2v6Delegate, DB2v7Delegate, DB2v8Delegate, oracle.OracleDelegate and oracle.weblogic.WebLogicOracleDelegate (jar listing: the DB2 delegates are still there, so only the Cloudscape part of the claim holds)

Jar listing

Update check

org.quartz.scheduler.skipUpdateCheck is documented

No occurrence of the property remains in the 2.5.2 sources

Source

misfireThreshold default

ConfigRAMJobStore: 60000

RAMJobStore defaults to 5000 ms. Under Spring Boot with job-store-type=memory the scheduler reports 5000; a JDBC store reports 60000; only a scheduler built with no configuration at all (the jar’s bundled quartz.properties) reports 60000. Setting spring.quartz.properties.org.quartz.jobStore.misfireThreshold=60000 takes effect

Tested (RAM 5000, JDBC 60000)

DataSources page

Example uses org.quartz.dataSource.myDS.driver = hikaricp

The property that selects the pool is provider; c3p0 and HikariCP are provided dependencies (measured above: c3p0 class not found)

Tested

Quick Start

Download the zip and copy the lib/ jars; the log shows version 2.0.0

Use Maven/Gradle coordinates or Boot’s dependency management

Plugins configuration

The trigger-history plugin logs via "Jakarta Commons-Logging"

SLF4J

Source

Examples page

"13 examples", including a Terracotta one

15 examples in quartz/examples

Downloads page

2.5.1 and 2.4.1 are current

2.5.2 is the latest (released 2025-12-01)

Maven Central

Spring Framework reference, Quartz

Jobs should "implement the Stateful interface" to avoid overlap

StatefulJob is deprecated; use @DisallowConcurrentExecution (and @PersistJobDataAfterExecution for state)

Tested (flags equal)

Spring Framework reference, Quartz

SchedulerFactoryBean "recognizes a quartz.properties file in the classpath"

Not under Boot: with a quartz.properties on the classpath (threadCount=3, misfireThreshold=12345) the scheduler still had 10 threads and 5000 ms. A file loads only after setConfigLocation(new ClassPathResource("quartz.properties")) in a SchedulerFactoryBeanCustomizer, and spring.quartz.properties.* then override it

Tested

Spring Boot reference, Quartz

The SQL Server, Azure SQL and Sybase scripts "may need" editing

The SQL Server script cannot run through Boot’s initializer as shipped: it starts with USE [enter_db_name_here] and uses GO lines. Deleting the USE line and replacing every GO by ; works, and MSSQLDelegate is mandatory

Tested

quartz-mongodb README

JCenter repository and Gradle compile

JCenter is gone and the project is archived; the artifact survives only as the io.fluidsonic.mirror mirror, and it does not work on Quartz 2.5.x

Tested

Table 6. Where the documentation and the code disagree: found while writing the section (Quartz and Spring Boot behaviour)
Claim What was measured or read

"A community store for MongoDB/Redis works with current Quartz"

Neither does on 2.5.x. quartz-mongodb 2.2.0-rc2 starts, reports "started" and never fires (the scheduler thread dies with NoSuchMethodError: isConcurrentExectionDisallowed). quartz-redis-jobstore 1.2.0 needs Jedis 3.3.0 (any later Jedis fails with NoSuchMethodError on JedisCommands.del) and Quartz 2.4.1 or earlier (2.5.2 fails with a Jackson mixin error). Tested

"The 2.5.3 change makes SQL Server `NVARCHAR`"

The NVARCHAR DDL change is in 2.5.2 (the jar’s script already uses it). What is unreleased on main is the MSSQLDelegate.setNString fix (#1515). Tested

The Spring Boot health and metrics support covers Quartz

There is no Quartz health indicator and no Quartz meter in Spring Boot 4.1.1 or in Micrometer; Boot registers no Quartz listener. Build them from listeners (Observability).

The Actuator quartz endpoint is visible by default

It is not exposed over HTTP by default (only health is); it masks job and trigger data () unless show-values is changed; the only write operation is trigger-now with state running.

job-store-type=jdbc always gives a JDBC store

With two DataSource beans and no @Primary, Boot silently builds a RAMJobStore: no error, nothing persisted. @QuartzDataSource does not rescue that; it only picks the Quartz one once one candidate exists (@ConditionalOnSingleCandidate).

initialize-schema is harmless

The default embedded creates nothing on a real database; always runs scripts that DROP the tables first (every restart deletes the schedule); continue-on-error (4.x) defaults to true, so script errors are swallowed.

"The default delegate works on PostgreSQL"

PostgreSQLDelegate is mandatory (BYTEA versus getBlob: Bad value for type long).

Oracle: tables_oracle.sql

With ojdbc 23.x it fails with ORA-12899 (the driver binds text true); use tables_oracle23.sql on 23ai, or ojdbc 21.x or older with tables_oracle.sql.

MariaDB has its own script

There is none; Boot maps MariaDB to tables_mysql_innodb.sql.

"Quartz CronExpression and Spring CronExpression are interchangeable"

They differ: Quartz needs ? in one day field, has an optional year, no macros and counts Sunday as 1; Spring takes exactly six fields, has macros (@daily), and counts Sunday as 0 or 7. On a DST fall-back day Spring fires twice and Quartz once (Cron Expressions).

clusterCheckinInterval is 15000 ms (JobStoreTX page)

7500 ms in the code.

Batch trigger acquisition needs acquireTriggersWithinLock=true

In 2.5.2 the TRIGGER_ACCESS lock is taken whenever batchTriggerAcquisitionMaxCount is greater than 1.

instanceId=AUTO always generates an id

The generator is called only when the store is clustered; otherwise the id is NON_CLUSTERED.

The Spring Boot reference sample MySampleJob

An unannotated setter on the sample job is not injected (see Getting Started with Spring Boot for what Boot’s job factory does inject).

JobInterruptMonitorPlugin times out concurrent jobs

It keeps one future field, so the timeout of a second concurrent job replaces the first.

FileScanJob honours MINIMUM_UPDATE_AGE

It ignores it.

CronExpression Javadoc says months are 0-11

The parser accepts 1-12 (JAN-DEC).

new WeeklyCalendar(TimeZone) excludes the weekend (Javadoc)

It excludes nothing; the default no-argument calendar excludes Saturday and Sunday.

Source: Quartz 2.5.x documentation and Spring Boot: Quartz Scheduler; each row was measured in a test run or read from the 2.5.2 and 4.1.1 sources and is explained on the page named in the "what changed" parts of that topic.

Table 7. Where the documentation and the code disagree: cookbook, API and Spring Framework
Claim What was measured or read

Tutorial Lesson 11 lists TerracottaJobStore

Removed in 2.4.0; StdSchedulerFactory 2.5.2 still has a dead org.terracotta.quartz branch.

Cookbook UpdateJob snippet

Throws "Jobs added with no trigger must be durable": the job needs storeDurably().

Cookbook UpdateTrigger snippet

getTriggerBuilder() does not compile as written.

Cookbook StoreJob snippet

Builds job1 but passes job to scheduleJob.

addJob(job, true) merges job data

It replaces the whole JobDataMap; there is no merge.

rescheduleJob needs the job key

It does not (the new trigger carries it); it returns null for an unknown trigger key.

unscheduleJobs and deleteJobs are all-or-nothing

They return false if any key is missing.

spring.threads.virtual.enabled makes Quartz use virtual threads

It does not reach Quartz. Use SchedulerFactoryBean.setTaskExecutor (and drop the org.quartz.threadPool.* keys, or Quartz fails with "No setter for property").

An Executor bean is how Quartz gets threads

Any Executor bean removes Boot’s applicationTaskExecutor; Quartz does not use it.

A second SchedulerFactoryBean shares Boot’s configuration

Declaring one makes Boot’s disappear; spring.quartz.*, JobDetail and Trigger beans do not apply to the second.

Raw Quartz keys can be set as environment variables

They are silently ignored; use SPRING_APPLICATION_JSON or the property map.

Profile order does not matter (prod,dev)

A later profile can leave a memory store with isClustered set: "No setter for property 'isClustered'".

@PreAuthorize secures the reminders API

Without @EnableMethodSecurity it is silently ignored.

TaskScheduler.schedule(Runnable, Date) is the primary overload

In Spring Framework 7.0.9 schedule(Runnable, Instant) is the abstract method; the Date overload is a default method.

pauseAll only affects existing groups

It stores the pseudo group $_ALL_GROUPS_PAUSED$_, and a trigger added afterwards starts PAUSED (measured, Dynamic Scheduling & Management).

getCurrentlyExecutingJobs and interrupt cover the cluster

Both are node-local.

Plain-Java and Spring documentation outside this section

backend/spring-batch/running-a-job.adoc has a Quartz example whose plain setters are never autowired, and backend/spring-batch/getting-started.adoc imports a wrong RepeatStatus package for Spring Batch 6.0.5. Both predate this section and were left unchanged.

Source: Quartz documentation: Cookbook and Quartz API: Scheduler; each row was run in a test project or read from the 2.5.2 and 7.0.9 sources.