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.
| 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 |
Build moved to Gradle; examples run with |
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 |
2.4.0 |
Add c3p0 or HikariCP yourself when Quartz creates its own pool ( |
|
2.4.0 |
Clustering is JDBC only. Move a Terracotta setup to |
|
2.4.0 |
Write an own job, or run a process from a job after validating the command. The jar of |
|
2.5.1 |
Expressions that were rejected before are now valid; nothing breaks. See Cron Expressions. |
|
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 |
|
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 |
SQL Server script: the four |
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: |
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 |
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 ( |
2.3.2 to 2.4.1 to 2.5.2 |
Identical: the diff of each script across the three releases is empty. |
|
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:
-
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. Overridingquartz.versionto an older release is possible because the Boot parent exposes it as a property, and is what the community stores force (Quartz with Redis). -
Replace the
javax.imports in your jobs and listeners withjakarta.if you come from Boot 2.x. -
Remove
NativeJobandTerracottaJobStoreusages. -
Grep for
isConcurrentExectionDisallowed: any library that calls it breaks at run time. -
Recompile; replace
StatefulJob(next section). -
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.
-
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.
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.
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.
| Topic | Spring Boot 3.5.16 | Spring Boot 4.1.1 |
|---|---|---|
Module and starters |
|
New module |
Package of |
|
|
|
|
|
|
Does not exist: the initializer hard-codes |
Exists, default |
Endpoint switch |
|
|
|
Added in 3.5 (release notes: "Triggering Quartz Jobs From the Actuator") |
Same; body |
Spring Framework |
6.2.19 |
7.0.9 |
Web and test starters used by the section’s REST examples |
|
|
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.
// 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.
// 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.
@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.
@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:
@Scheduled / ShedLock |
Quartz | Notes |
|---|---|---|
|
|
Quartz needs |
Day-of-week numbers |
|
Spring |
|
|
Without it Quartz uses the JVM’s default zone, as |
|
|
|
|
|
No lock table of your own. There is no |
|
Not needed for Quartz jobs; the two can coexist |
Keep |
Lost schedule at restart |
Persisted in the |
A |
Checklist for the cut-over:
-
Create the Quartz tables with Flyway or Liquibase before the first start (
initialize-schemastaysnever), as described on Job Stores & Persistence. -
Deploy the Quartz job with the
@Scheduledannotation 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 inexecuteInternal). -
Remember that an existing trigger keeps its old cron: change it with
rescheduleJob, or setspring.quartz.overwrite-existing-jobs=trueso that the beans replace the stored definitions at each start (Dynamic Scheduling & Management). -
Drop the
shedlocktable and dependency when no other job uses them. -
Spring Batch jobs launched from the old
@Scheduledmethod keep working: call theJobOperator(Spring Batch 6;JobLauncheron 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:
| Way | How | Good for |
|---|---|---|
Rebuild from code |
Create the schema in the target database, point the application at it, and let the |
Applications whose schedule is defined in code or can be rebuilt from business data. Loses paused states, |
Copy through the |
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 |
Copy the tables |
Dump the eleven tables with the database’s tools and load them, after emptying |
Same database engine only (for example a PostgreSQL major-version move or a new host); a blob type mismatch breaks it across engines. |
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.
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.
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
getJobDetaildeserializes theJobDataMap(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)andscheduleJob(detail, triggers, true)replace an existing definition, which makes the copy re-runnable; butaddJob(replace=true)replaces the wholeJobDataMap(no merge). -
Trigger state other than paused (a trigger in
ERROR, a completed one-shot) is not carried over: onlyPAUSEDis 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-mongodborquartz-redis-jobstoreto 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
VARCHARcolumns (2.4.1 and earlier, and 2.5.0, 2.5.1) to theNVARCHARcolumns 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.
| Topic | The documentation says | The code does | Tested |
|---|---|---|---|
Terracotta |
Lesson 9, Lesson 11, the introduction and the FAQ still describe |
Removed in 2.4.0. Clustering is JDBC only |
|
JDBC delegates |
|
There is no Cloudscape delegate in the 2.5.2 jar. The delegates that exist are |
Jar listing |
Update check |
|
No occurrence of the property remains in the 2.5.2 sources |
Source |
|
|
|
Tested (RAM 5000, JDBC 60000) |
DataSources page |
Example uses |
The property that selects the pool is |
Tested |
Quick Start |
Download the zip and copy the |
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 |
|
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 |
|
Tested (flags equal) |
Spring Framework reference, Quartz |
|
Not under Boot: with a |
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 |
Tested |
|
JCenter repository and Gradle |
JCenter is gone and the project is archived; the artifact survives only as the |
Tested |
Source: Quartz 2.5.x documentation; the measurements are quoted on Misfires, Spring Boot Configuration and Quartz with SQL Server.
| Claim | What was measured or read |
|---|---|
"A community store for MongoDB/Redis works with current Quartz" |
Neither does on 2.5.x. |
"The 2.5.3 change makes SQL Server `NVARCHAR`" |
The |
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 |
It is not exposed over HTTP by default (only |
|
With two |
|
The default |
"The default delegate works on PostgreSQL" |
|
Oracle: |
With ojdbc 23.x it fails with |
MariaDB has its own script |
There is none; Boot maps MariaDB to |
"Quartz |
They differ: Quartz needs |
|
7500 ms in the code. |
Batch trigger acquisition needs |
In 2.5.2 the |
|
The generator is called only when the store is clustered; otherwise the id is |
The Spring Boot reference sample |
An unannotated setter on the sample job is not injected (see Getting Started with Spring Boot for what Boot’s job factory does inject). |
|
It keeps one |
|
It ignores it. |
|
The parser accepts 1-12 ( |
|
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.
| Claim | What was measured or read |
|---|---|
Tutorial Lesson 11 lists |
Removed in 2.4.0; |
Cookbook |
Throws "Jobs added with no trigger must be durable": the job needs |
Cookbook |
|
Cookbook |
Builds |
|
It replaces the whole |
|
It does not (the new trigger carries it); it returns |
|
They return |
|
It does not reach Quartz. Use |
An |
Any |
A second |
Declaring one makes Boot’s disappear; |
Raw Quartz keys can be set as environment variables |
They are silently ignored; use |
Profile order does not matter ( |
A later profile can leave a memory store with |
|
Without |
|
In Spring Framework 7.0.9 |
|
It stores the pseudo group |
|
Both are node-local. |
Plain-Java and Spring documentation outside this section |
|
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.