Spring Boot Configuration
|
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. |
Spring Boot turns Quartz into a handful of spring.quartz.* properties, a hook for code (SchedulerFactoryBeanCustomizer) and a
pass-through for every raw Quartz key. This page is the reference for all three: which property does what and what it defaults to,
which layer wins when two of them say different things, and what Boot does not do for you (read quartz.properties, register
listener beans, use your Executor bean, run two schedulers). The first steps with the starter are on
Getting Started with Spring Boot, the store-specific properties are
treated in depth on Job Stores & Persistence, and the general Spring Boot
mechanics (property sources, profiles, relaxed binding) are on Configuration and Profiles.
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
default below was read from the 4.1.1 sources and every behaviour was run in a test project on exactly those versions (macOS, JDK 25
running Java 21 bytecode, Maven 3.9, PostgreSQL 17 in Docker), and the outputs are quoted, not assumed. Spring Boot 3.5.16 manages
the same Quartz 2.5.2 and uses the same property names, with these differences: the auto-configuration classes live in
org.springframework.boot.autoconfigure.quartz instead of org.springframework.boot.quartz.autoconfigure, the jdbc.* settings are
a nested class of QuartzProperties that has no continue-on-error property, and Spring Framework is 6.2.19. They are collected in
What changed.
Where a setting comes from
A Quartz scheduler under Boot is configured in layers, and knowing the order explains most surprises. Boot reads spring.quartz.*
into QuartzProperties and QuartzJdbcProperties, creates one SchedulerFactoryBean from them, lets every
SchedulerFactoryBeanCustomizer bean change it, and Spring’s SchedulerFactoryBean finally merges all Quartz keys into one
java.util.Properties object that it hands to Quartz’s StdSchedulerFactory.
The layers, from the weakest to the strongest, are these. Each row was checked in SchedulerFactoryBean.initSchedulerFactory.
| Rank | Layer | What it contributes |
|---|---|---|
1 |
Quartz’s code defaults |
Used only for keys nobody set: a |
2 |
Spring’s defaults |
|
3 |
A properties file given to |
Only when a customizer sets it. Boot never does, so a |
4 |
|
Every entry overrides the same key from layers 1 to 3. Passed through only when the map is not empty. |
5 |
Typed |
|
6 |
Customizers |
Run last, in |
One special case follows from putIfAbsent: with a DataSource set, org.quartz.jobStore.class defaults to
LocalDataSourceJobStore unless a raw property already names a class, so a raw jobStore.class replaces the
Spring-aware store (measured in Overriding org.quartz.jobStore.class). Note also that a
customizer’s setQuartzProperties replaces Boot’s whole map; it does not add to it.
Every spring.quartz property
Reading the defaults from the source is one thing; asking the running Boot is better, because it is the code that decides. This
test binds both property classes from an empty Environment, which is exactly what Boot does when nothing is configured.
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.boot.quartz.autoconfigure.QuartzJdbcProperties;
import org.springframework.boot.quartz.autoconfigure.QuartzProperties;
import org.springframework.core.env.StandardEnvironment;
class QuartzDefaultsTest {
static String dump() {
Binder binder = new Binder(ConfigurationPropertySources.get(new StandardEnvironment()));
QuartzProperties q = binder.bindOrCreate("spring.quartz", QuartzProperties.class);
QuartzJdbcProperties j = binder.bindOrCreate("spring.quartz.jdbc", QuartzJdbcProperties.class);
return "job-store-type=" + q.getJobStoreType() + "\nscheduler-name=" + q.getSchedulerName()
+ "\nauto-startup=" + q.isAutoStartup() + "\nstartup-delay=" + q.getStartupDelay()
+ "\nwait-for-jobs-to-complete-on-shutdown=" + q.isWaitForJobsToCompleteOnShutdown()
+ "\noverwrite-existing-jobs=" + q.isOverwriteExistingJobs() + "\nproperties=" + q.getProperties()
+ "\njdbc.initialize-schema=" + j.getInitializeSchema() + "\njdbc.schema=" + j.getSchema()
+ "\njdbc.platform=" + j.getPlatform() + "\njdbc.continue-on-error=" + j.isContinueOnError()
+ "\njdbc.comment-prefix=" + j.getCommentPrefix();
}
}
job-store-type=MEMORY
scheduler-name=null
auto-startup=true
startup-delay=PT0S
wait-for-jobs-to-complete-on-shutdown=false
overwrite-existing-jobs=false
properties={}
jdbc.initialize-schema=EMBEDDED
jdbc.schema=classpath:org/quartz/impl/jdbcjobstore/tables_@@platform@@.sql
jdbc.platform=null
jdbc.continue-on-error=true
jdbc.comment-prefix=[#, --]
Source: output of the test above, run on Boot 4.1.1.
The same defaults written as an application.yml are below. The file was used as an extra configuration file of the Bookshelf
service (with the dev profile) and the service started and served /actuator/quartz, so every key is spelled correctly.
spring:
quartz:
job-store-type: memory # memory or jdbc
# scheduler-name: # no default: the bean name quartzScheduler is used
auto-startup: true
startup-delay: 0s
wait-for-jobs-to-complete-on-shutdown: false
overwrite-existing-jobs: false
properties: {} # raw org.quartz.* keys, passed through unchanged
jdbc:
initialize-schema: embedded # always, embedded or never
schema: classpath:org/quartz/impl/jdbcjobstore/tables_@@platform@@.sql
# platform: # auto-detected from the DataSource
continue-on-error: true
comment-prefix:
- "#"
- "--"
management:
endpoint:
quartz:
show-values: never # never, always or when-authorized
# roles: # empty: every authenticated user
What each property does, with the measurements that are not obvious from its name:
job-store-type
memory (the default) leaves Quartz on its RAMJobStore; jdbc makes Boot pass the DataSource to Spring’s LocalDataSourceJobStore
and create the schema if asked to. The enum is JobStoreType with exactly these two values. The conditions under which jdbc
silently turns back into memory are the second diagram of this page, in Which job store Boot selects.
A store from a library (a MongoDB or Redis store) is not a third value: it is plugged in with job-store-type left at memory and
spring.quartz.properties.org.quartz.jobStore.class (measured with a RAMJobStore subclass, which Boot honoured).
scheduler-name
No default. When it is not set the scheduler is named after the SchedulerFactoryBean bean, quartzScheduler (measured). The name is
part of every key in a JDBC job store (SCHED_NAME is the first column of every primary key), so all nodes of one cluster must share
it and two applications that share tables must not. When both spring.quartz.scheduler-name and
spring.quartz.properties.org.quartz.scheduler.instanceName are set, the first wins (measured: the scheduler was named FromBoot).
Setting only the raw instanceName is the third route in SchedulerFactoryBean (read from the source; the cluster and Actuator
pages use scheduler-name).
auto-startup, startup-delay and the lifecycle
auto-startup is a SchedulerFactoryBean SmartLifecycle flag. With false the scheduler is created, jobs and triggers are
stored, and nothing fires until somebody calls scheduler.start(): this is how tests and "migrate then start" deployments hold
the scheduler back. startup-delay is a Duration, but Boot passes it as (int) getSeconds(), so the sub-second part is cut
off. All of this was measured by reading isStarted() and isInStandbyMode() right after the context was ready:
| Setting | Right after startup | Later |
|---|---|---|
|
|
3.5 seconds later: started, not in standby |
|
|
1.5 seconds later: started |
|
|
|
|
|
after |
Source: QuartzAutoConfiguration.java (v4.1.1)
(setStartupDelayint) properties.getStartupDelay().getSeconds(); the times are the output of the run.
wait-for-jobs-to-complete-on-shutdown
With true, closing the context calls scheduler.shutdown(true) and the thread pool waits for running jobs. With the default
false it does not. Measured with a job that sleeps 4 seconds, closing the context while it ran:
wait-for-jobs-to-complete-on-shutdown |
close() took |
Job after close() returned |
|---|---|---|
|
3 ms |
still running, abandoned |
|
3902 ms |
finished |
Source: output of the run; the mechanism is QuartzScheduler.shutdown(boolean) in the Quartz 2.5.2 sources, which ends in
getThreadPool().shutdown(waitForJobsToComplete).
That last call is why the setting has a catch with a Spring TaskExecutor as thread pool, described in
A Spring TaskExecutor instead of the thread pool.
overwrite-existing-jobs, properties and jdbc
overwrite-existing-jobs decides who wins when a JobDetail or Trigger bean meets the same key in a persistent store; the
decision tree and its measurement are in the "job-registration decision" section of
Getting Started with Spring Boot. properties is the raw pass-through,
the next section. The five jdbc.* properties (initialize-schema, schema, platform, continue-on-error, comment-prefix) are
explained with their failure modes on Job Stores & Persistence; only their
defaults are repeated in the reference table at the end of this page.
Raw Quartz properties: spring.quartz.properties
Everything under spring.quartz.properties is copied unchanged into the Quartz configuration, so any key from the Quartz
configuration reference is available. The map has string values, so numbers and booleans are quoted in YAML.
spring:
quartz:
properties:
org.quartz.scheduler.instanceId: AUTO # cluster nodes: a generated id
org.quartz.scheduler.idleWaitTime: "30000" # maximum sleep of the scheduler thread, ms
org.quartz.threadPool.threadCount: "10" # SimpleThreadPool size (Spring's default is 10)
org.quartz.jobStore.misfireThreshold: "60000" # how late a trigger may be before it is a misfire
org.quartz.jobStore.isClustered: "true" # JDBC store only
org.quartz.jobStore.clusterCheckinInterval: "5000" # JDBC store only
org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate # JDBC store only
The keys, their defaults and where they apply
The defaults were read from StdSchedulerFactory, SimpleThreadPool, RAMJobStore and JobStoreSupport in the 2.5.2 sources; "measured"
means the value was also read back from a running Boot scheduler.
| Key | Default | Notes |
|---|---|---|
|
|
Boot replaces it with |
|
|
|
|
30000 ms |
How long the scheduler thread waits when it has nothing to fire. |
|
15000 ms |
Pause after a database failure in the scheduler thread. |
|
1 |
Triggers acquired per round. More than 1 only helps with a JDBC store and enough threads. |
|
0 ms |
How far ahead a batch may reach. |
|
|
Calls |
|
|
Whether the scheduler thread is a daemon thread. |
|
|
Registers the scheduler as an MBean. |
|
set by Spring |
|
|
ineffective |
Spring sets its own |
|
|
Spring sets it; |
|
10 (Spring’s default; measured 10) |
Quartz itself has no default (-1 means "invalid"); Spring supplies 10. Rejected when the pool is |
|
5 ( |
|
|
|
Measured worker names: |
|
|
|
|
5000 ms for |
Measured 5000 in RAM mode under Boot and 60000 with |
|
20 |
JDBC store only. |
|
|
JDBC store only. The key is |
|
7500 ms |
JDBC store only. |
|
none, so |
JDBC store only. |
|
|
JDBC store only. |
|
|
JDBC store only: |
|
|
JDBC store only. See Clustering. |
|
15000 ms |
JDBC store only, and setting it has no effect: |
|
|
Override it only for a store from a library, and then only with |
Source: Quartz documentation: Main Configuration, Thread Pool, RAMJobStore and JDBC JobStoreTX; the defaults and key names are those of StdSchedulerFactory.java (v2.5.2).
Plugins (org.quartz.plugin.) and listeners (org.quartz.jobListener., org.quartz.triggerListener.*) can be declared the same
way; they are the subject of Listeners & Plugins.
Overriding org.quartz.jobStore.class
SchedulerFactoryBean sets the store class only when no raw key names one. That is the door for a store from a library, and a trap for
everyone else. All five cases were run (results taken from the verification run of this section):
| Configuration | Result |
|---|---|
|
works, |
|
honoured: the subclass ran (its |
|
fails at startup with |
|
fails with the same message: the override replaced |
|
|
Source: SchedulerFactoryBean.java (v7.0.9)
(mergedProps.putIfAbsent(PROP_JOB_STORE_CLASS, LocalDataSourceJobStore…)).
A key for the wrong store fails at startup
The same property map goes to whichever store is active, and a store rejects keys it has no setter for. That is why a prod profile
copied onto a memory store does not run quietly: it does not start. Measured with RAMJobStore, one key at a time:
| Key | Result at startup |
|---|---|
|
accepted |
|
|
|
the same failure, naming the key |
Source: output of the run (NoSuchMethodException: No setter for property …).
The same happens with profiles: activating prod,dev in that order applied dev’s `job-store-type: memory on top of prod’s cluster
keys, and the service failed with exactly the `isClustered message above. Profile order decides, the last one wins.
Environment variables lose the key’s case
Quartz keys are case sensitive and mixed case (org.quartz.threadPool.threadCount). Spring Boot maps an environment variable
such as SPRING_QUARTZ_PROPERTIES_ORG_QUARTZ_THREADPOOL_THREADCOUNT to the lower-case key org.quartz.threadpool.threadcount, which
Quartz does not know. It is not an error; the setting just does nothing. Measured:
| How the value 4 was supplied | Pool size | Effective key seen by Boot |
|---|---|---|
nothing |
10 |
|
environment variable |
10, silently ignored |
|
environment variable |
4 |
|
Source: Spring Boot: Externalized Configuration
(environment variable binding and SPRING_APPLICATION_JSON); the pool sizes are read back from the running scheduler.
SPRING_APPLICATION_JSON='{"spring.quartz.properties.org.quartz.threadPool.threadCount":"4"}' java -jar bookshelf.jar
Source: Spring Boot: Externalized Configuration (JSON application properties).
In Kubernetes, put raw Quartz keys in a mounted application.yml or in SPRING_APPLICATION_JSON, never in individual environment
variables. Typed properties such as SPRING_QUARTZ_JOB_STORE_TYPE are fine.
quartz.properties files
Plain Quartz reads quartz.properties from the classpath, and the Spring Framework reference says that SchedulerFactoryBean "also
recognizes a quartz.properties file in the classpath". Under Boot it does not: SchedulerFactoryBean only reads a file it was told about through
setConfigLocation, and Boot never calls it. This was measured with a quartz.properties on the classpath that set
threadCount=3, misfireThreshold=12345 and instanceName=FromFile (results taken from the verification run of this section):
| Setup | Scheduler name | Threads | misfireThreshold |
|---|---|---|---|
Boot default, file on the classpath |
|
10 |
5000: the file is ignored |
file on the classpath, plus |
|
7 |
5000 (file still ignored) |
a customizer calls |
|
3 |
12345: loaded |
the same customizer, plus |
|
7 |
12345: |
Source: Spring Framework: Task Execution and Scheduling (the sentence quoted above) and
SchedulerFactoryBean Javadoc
(setConfigLocation, setQuartzProperties); the table is the measured result.
Loading the file explicitly takes one bean:
import org.springframework.boot.quartz.autoconfigure.SchedulerFactoryBeanCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
@Configuration(proxyBeanMethods = false)
class QuartzFileConfiguration {
@Bean
SchedulerFactoryBeanCustomizer quartzPropertiesFile() {
return factory -> factory.setConfigLocation(new ClassPathResource("quartz.properties"));
}
}
Source: SchedulerFactoryBean Javadoc
(setConfigLocation(Resource)); run in the verification test project.
Prefer spring.quartz.properties: it works with profiles, environment layers and @ConfigurationProperties tooling, and a second
file next to application.yml is a second place for the same settings to disagree.
SchedulerFactoryBeanCustomizer
SchedulerFactoryBeanCustomizer is a one-method functional interface (void customize(SchedulerFactoryBean)). Boot collects every bean
of that type, orders them with @Order (an unordered bean sorts last) and applies them to the factory bean just before it is
returned. Boot’s own JDBC customizer, which sets the DataSource and the transaction manager, is registered at order 0, so yours run
after it and can override it. It is the one supported extension point; the properties cover about half of what
SchedulerFactoryBean can do, and the customizer covers the rest:
| Setter | What it is for |
|---|---|
|
Register listeners. Boot does not pick up listener beans (measured below). |
|
Use a Spring |
|
Replace the |
|
Global data every job can read from the |
|
Load a file or add keys in code. |
|
What Boot’s own JDBC customizer does; change it only when |
|
Lifecycle: the |
Listeners are not registered for you
A JobListener or TriggerListener that is a Spring bean is not attached to the scheduler by Boot (QuartzAutoConfiguration
collects JobDetail, Trigger, Calendar and customizer beans, nothing else). Measured: a context with one JobListener bean and
no customizer had the bean and 0 listeners on the scheduler. One customizer closes the gap and adds global data:
package bookshelf;
import java.util.Map;
import org.quartz.JobListener;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.quartz.autoconfigure.SchedulerFactoryBeanCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
@Configuration(proxyBeanMethods = false)
class BookshelfQuartzConfiguration {
@Bean
@Order(10)
SchedulerFactoryBeanCustomizer bookshelfCustomizer(ObjectProvider<JobListener> jobListeners,
ScheduleProperties schedule) {
return factory -> {
factory.setGlobalJobListeners(jobListeners.orderedStream().toArray(JobListener[]::new));
factory.setSchedulerContextAsMap(Map.of("bookshelfZone", schedule.zone().getID()));
factory.setApplicationContextSchedulerContextKey("applicationContext");
};
}
}
Source: Spring Boot: Quartz Scheduler (customizer bean) and
SchedulerFactoryBean Javadoc;
ScheduleProperties and the rest of the class are in the complete configuration.
With this bean the scheduler had the listener bookshelf-audit, the context entry bookshelfZone=Europe/Madrid and the
ApplicationContext under applicationContext (measured). What the listeners are for, matchers and the other kinds are on
Listeners & Plugins; turning them into metrics is on
Observability.
Replacing the job factory
setJobFactory swaps the factory that creates job instances. Extend SpringBeanJobFactory (which is what Boot installs, with the
ApplicationContext set) and call super, so that constructor injection keeps working:
import org.quartz.spi.TriggerFiredBundle;
import org.springframework.boot.quartz.autoconfigure.SchedulerFactoryBeanCustomizer;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.SpringBeanJobFactory;
import java.util.concurrent.atomic.AtomicInteger;
@Configuration(proxyBeanMethods = false)
class JobFactoryConfiguration {
static final AtomicInteger CREATED = new AtomicInteger();
static class CountingJobFactory extends SpringBeanJobFactory {
@Override
protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
CREATED.incrementAndGet();
return super.createJobInstance(bundle);
}
}
@Bean
SchedulerFactoryBeanCustomizer countingJobFactory(ApplicationContext context) {
CountingJobFactory jobFactory = new CountingJobFactory();
jobFactory.setApplicationContext(context);
return factory -> factory.setJobFactory(jobFactory);
}
}
Source: SpringBeanJobFactory Javadoc. Run with the Bookshelf jobs firing every second: the factory created 3 job instances and the overdue-loan job fired 2 times, so autowiring still worked.
The thread pool and why an Executor bean is not used
Boot’s Quartz auto-configuration never looks for an Executor, TaskExecutor or TaskScheduler bean. The only way a Spring executor
reaches Quartz is SchedulerFactoryBean.setTaskExecutor, and Boot does not call it. The scheduler’s threads are the
SimpleThreadPool that Quartz creates itself (<scheduler name>_Worker-N), ten of them unless configured. A common mistake is to
declare a tuned ThreadPoolTaskExecutor and expect Quartz jobs to run on it.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration(proxyBeanMethods = false)
class ExecutorBeanConfiguration {
@Bean(name = "myExecutor")
ThreadPoolTaskExecutor myExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setThreadNamePrefix("my-exec-");
executor.setCorePoolSize(2);
executor.setMaxPoolSize(2);
return executor;
}
}
Measured with that bean and spring.threads.virtual.enabled=true, three jobs fired: the thread pool was SimpleThreadPool with 10 threads
and the jobs ran on platform threads named BookshelfScheduler_Worker-N, none on my-exec-N. A side effect worth knowing: declaring
any Executor bean made Boot’s own applicationTaskExecutor back off (applicationTaskExecutor present=false), because
Boot creates it only @ConditionalOnMissingBean(Executor.class). That affects @Async and the web layer, not just Quartz.
A Spring TaskExecutor instead of the thread pool
To run jobs on a Spring executor, hand it over explicitly. The class that makes it work is
org.springframework.scheduling.quartz.LocalTaskExecutorThreadPool (it exists in Spring Framework 7.0.9; SchedulerFactoryBean selects it
by itself when a task executor is set). It adapts the executor to Quartz’s ThreadPool interface, and the adaptation is thin:
getPoolSize() returns -1, blockForAvailableThreads() always returns 1, shutdown does nothing and runInThread calls
executor.execute, returning false if the executor rejects the task. Those four facts are the rows of the table, each measured with
six jobs of 1.5 seconds triggered at the same moment:
| Thread pool | Result | Why it matters |
|---|---|---|
|
max 2 concurrent, three waves at 0, 1.5 and 3.0 s, |
The scheduler thread waits for a free worker, so the pool size is a real cap. |
virtual |
max 6 concurrent, all done after 1.55 s, virtual threads |
No cap at all: |
|
max 2 concurrent, all six done after 4.6 s, platform threads |
Jobs queue inside the executor. Quartz thinks they have started, and the queue grows without limit if jobs are slow. |
|
only 2 of 6 ran; the other four triggers ended in state ERROR |
A rejected task makes |
|
max 2 concurrent, six done after 4.6 s |
A real cap: the executor blocks the scheduler thread when the limit is reached. |
same, |
only 2 of 6 ran; four triggers in state ERROR |
The rejection case again. |
Source: LocalTaskExecutorThreadPool.java (v7.0.9); the durations and states are measured.
Three more consequences, two measured and one read from the source:
-
threadCountis not valid any more. With a task executor set andspring.quartz.properties.org.quartz.threadPool.threadCountstill present, startup fails withThreadPool class 'org.springframework.scheduling.quartz.LocalTaskExecutorThreadPool' props could not be configured, caused byNoSuchMethodException: No setter for property 'threadCount'. Remove everyorg.quartz.threadPool.*key (Spring’s default of 10 is aSimpleThreadPoolsetting). -
Quartz no longer waits for running jobs.
shutdown(true)waits throughgetThreadPool().shutdown(true), which is a no-op for this pool. With a virtualSimpleAsyncTaskExecutorandwait-for-jobs-to-complete-on-shutdown=true,close()took 1 ms and the 4-second job was still running afterwards. The executor can keep the JVM alive until the job ends (next section), but Quartz has already shut its job store down by then and does not record the job’s completion. -
A rejection is a one-way street. When
runInThreadreturnsfalse,QuartzSchedulerThreadlogs "ThreadPool.runInThread() return false!" and callstriggeredJobComplete(…, SET_ALL_JOB_TRIGGERS_ERROR): every trigger of that job goes to ERROR (the four rows above), and the application keeps running without that job (Quartz 2.5.2 source and the measured states).
Virtual threads
Quartz does not use virtual threads on its own: spring.threads.virtual.enabled=true changes Boot’s applicationTaskExecutor, @Async, the
web server and @Scheduled, but the Quartz SimpleThreadPool keeps its platform threads (measured above). To run jobs on virtual threads,
give Quartz Boot’s executor. The configuration below is switched on by a property and added by the virtual profile in
the complete configuration.
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.quartz.autoconfigure.SchedulerFactoryBeanCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.core.task.AsyncTaskExecutor;
// inside BookshelfQuartzConfiguration
@Bean
@Order(20)
@ConditionalOnProperty(name = "bookshelf.quartz.use-application-task-executor", havingValue = "true")
SchedulerFactoryBeanCustomizer applicationTaskExecutorForJobs(
@Qualifier("applicationTaskExecutor") AsyncTaskExecutor executor) {
return factory -> factory.setTaskExecutor(executor);
}
spring:
threads:
virtual:
enabled: true # applicationTaskExecutor becomes a virtual-thread SimpleAsyncTaskExecutor
task:
execution:
simple:
concurrency-limit: 20 # the cap SimpleThreadPool's threadCount used to give; the scheduler thread blocks at 20
shutdown:
await-termination: true # keeps the JVM alive until the job ends; Quartz still does not record its completion
await-termination-period: 30s
bookshelf:
quartz:
use-application-task-executor: true
The Bookshelf service ran with the profiles prod,virtual against PostgreSQL: the scheduler reported "Using thread pool
LocalTaskExecutorThreadPool - with -1 threads", jobs ran on threads task-1, task-2, … (virtual), and the cluster still worked. The
shutdown behaviour of the shared executor, with a 4-second job running and wait-for-jobs-to-complete-on-shutdown=true:
| Executor | close() took |
Job after close() |
Completion recorded by Quartz |
|---|---|---|---|
virtual |
3907 ms |
finished |
no: Quartz had already logged "shutdown complete" and shut its job store down |
virtual |
2 ms |
still running |
no |
platform |
3901 ms |
finished |
no: same as the first row |
platform |
3909 ms |
finished (the pool took part in the context’s lifecycle stop; not investigated further) |
no: same as the first row |
Quartz’s own |
3902 ms (Quartz waits; measured above) |
finished |
yes (measured on PostgreSQL, see the Production & Best Practices link below) |
Source: output of the run; TaskExecutorConfigurations.java (v4.1.1) for the property to executor mapping.
When to bother: Quartz jobs that mostly wait (HTTP calls, a database) gain from virtual threads, and the concurrency limit gives the same protection
as threadCount. CPU-bound jobs gain nothing. The simple SimpleThreadPool remains the right default for a service whose
jobs are few and short, and it is the only configuration where Quartz itself does the waiting and records completion; with an executor,
await-termination only keeps the JVM alive, the fired row stays EXECUTING and a surviving node re-runs a requestsRecovery job. The measured
shutdown matrix and the recommended pattern (wait-for-jobs-to-complete-on-shutdown=true, org.quartz.scheduler.interruptJobsOnShutdownWithWait=true
and a chunked InterruptableJob) are on Production & Best Practices: Graceful shutdown; the virtual-thread and executor questions of the whole service are on the same page.
Profiles per environment
The usual shape is a memory store for development and tests and a JDBC store, clustered, for production. Profile files make that
a matter of three small YAML files; application.yml holds what is shared. The complete files are in
the next section, and the mechanics (precedence of profile files, spring.profiles.active,
@Profile) are on Configuration and Profiles. Measured, one run per profile:
| Profile | What it sets | What was observed |
|---|---|---|
|
memory store, |
"Will start Quartz Scheduler [BookshelfScheduler] in 3 seconds", then jobs fired on |
|
memory store, |
"Using thread pool |
|
JDBC, |
Two nodes against one database both listed in |
|
as |
thread pool |
Source: Spring Boot: Profiles; output of the runs.
Three rules keep the split honest. First, profile-specific keys must be valid for the store they sit with (the isClustered failure above). Second,
application.yml should contain only keys that are valid for both stores (scheduler-name, the shutdown flag, misfireThreshold is
accepted by both). Third, the production tables are created by a migration tool, never by initialize-schema
(see PostgreSQL), which is why prod says never and the other profiles do not need to say anything: the memory store has no tables. The
test profile leaves the scheduler stopped so that a test starts it, or triggers a job by hand, when it wants to
(see Testing).
Which job store Boot selects
job-store-type: jdbc is a request, not a command. Whether Boot honours it depends on the DataSource beans in the context, and the
failure is silent. The decision, with each branch run:
The branches were run with H2 DataSource beans:
| DataSource beans | Job store used | Persistent |
|---|---|---|
two beans, one marked |
|
no |
two beans, one |
|
yes |
one bean, which is the |
|
yes |
no |
|
no |
nothing configured (H2 on the classpath gives one embedded |
|
yes |
|
|
no |
Source: QuartzAutoConfiguration.java (v4.1.1)
(@ConditionalOnSingleCandidate(DataSource.class) on the JDBC configuration); the stores are read from getMetaData() of the running scheduler.
The first row is the one that surprises: @QuartzDataSource alone does not make Boot use that data source, because the jdbc configuration
is conditional on a single DataSource candidate, and two beans without @Primary are not one. The application’s own data source needs
@Primary, as in the two-data-source example on the PostgreSQL page. The
transaction manager follows the same rule (@QuartzTransactionManager, else the unique PlatformTransactionManager).
Several schedulers in one application
Boot auto-configures exactly one scheduler, as one SchedulerFactoryBean created @ConditionalOnMissingBean. A second scheduler is
therefore possible, but Boot’s convenience is not shared with it, and the first thing that goes is Boot’s own scheduler. All variants were run with
the Bookshelf application and the Actuator quartz endpoint exposed:
| Configuration | Result |
|---|---|
your own |
Boot’s scheduler is not created ( |
two own |
Startup fails with |
two own |
Both started; the endpoint and injection by type use the primary one ( |
Boot’s scheduler plus a plain |
Fails with |
the same, with |
Works: injection by type and the endpoint get Boot’s scheduler, |
Source: QuartzAutoConfiguration.java (v4.1.1) and SchedulerFactoryBean Javadoc; output of the run.
The two shapes that work are below. Keep Boot’s scheduler as the main one and add a hidden plain scheduler when the second one is rare and
simple (for example a RAM scheduler for a single reporting job); take over both with @Primary when each needs a data source or
listeners.
import java.util.Properties;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.impl.StdSchedulerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
class ReportsSchedulerConfiguration {
/** Not an autowire candidate: injecting Scheduler keeps getting Boot's scheduler. Look this one up by name. */
@Bean(destroyMethod = "shutdown", autowireCandidate = false)
Scheduler reportsScheduler() throws SchedulerException {
Properties properties = new Properties();
properties.setProperty("org.quartz.scheduler.instanceName", "ReportsScheduler");
properties.setProperty("org.quartz.threadPool.threadCount", "2");
Scheduler scheduler = new StdSchedulerFactory(properties).getScheduler();
scheduler.start();
return scheduler;
}
}
import java.util.List;
import org.quartz.JobDetail;
import org.quartz.Trigger;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
@Configuration(proxyBeanMethods = false)
class TwoFactoryBeans {
@Bean
@Primary
SchedulerFactoryBean bookshelfScheduler(List<JobDetail> jobs, List<Trigger> triggers) {
SchedulerFactoryBean factory = new SchedulerFactoryBean(); // Boot's customizers do not run for this one
factory.setJobDetails(jobs.toArray(new JobDetail[0]));
factory.setTriggers(triggers.toArray(new Trigger[0]));
return factory;
}
@Bean
SchedulerFactoryBean reportsScheduler() {
return new SchedulerFactoryBean();
}
}
Source: SchedulerFactoryBean Javadoc.
Both beans started, and GET /actuator/quartz returned the Bookshelf group of the primary scheduler.
When both schedulers use a JDBC store, give each its own SchedulerFactoryBean.setSchedulerName: every Quartz table is keyed by SCHED_NAME, so two
names on one set of tables are two independent schedulers. With your own factory beans you also set the DataSource, the transaction
manager and org.quartz.jobStore.* keys yourself. For work that needs different queues rather than different stores, one scheduler with different
job groups is usually enough, and clustering is the better answer to "more capacity" (Clustering).
The Boot 4 package moves
Spring Boot 4.0 split the auto-configuration jar into modules and renamed the packages. For Quartz the types are the same, in new
packages and a new module (spring-boot-quartz, brought in by spring-boot-starter-quartz). The table was built from the class listings of
the 3.5.16 and 4.1.1 jars, and the 4.1.1 import was used for every snippet on this page.
| Type | Spring Boot 3.5.16 | Spring Boot 4.1.1 |
|---|---|---|
|
|
|
|
same package; |
new package; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Auto-configuration registration |
|
|
Source: Spring Boot 4.0 Migration Guide;
the packages were read from spring-boot-autoconfigure-3.5.16.jar and spring-boot-quartz-4.1.1.jar.
A migration is a find-and-replace of the import lines; the spring.quartz.* property names did not change, except that continue-on-error
exists only on 4.x (the 3.5.16 initializer hard-codes true). Application code that injects Scheduler or extends QuartzJobBean is unaffected.
Property reference
Every property that Boot defines for Quartz, with its default and what Boot does with it.
| Property | Default | What Boot does with it |
|---|---|---|
|
|
|
|
none (bean name |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
empty map |
|
|
|
|
|
|
The script to run |
|
auto-detected |
Replaces |
|
|
Skip failing statements of the script |
|
|
Line comment prefixes of the script |
|
|
|
|
empty |
Roles allowed to see unmasked values; empty means every authenticated user |
|
|
Must name |
Source: Spring Boot: Common Application Properties; defaults confirmed by the defaults test above.
Properties that Boot does not define but that decide how Quartz behaves under Boot: spring.datasource. (the store), spring.threads.virtual.enabled
and spring.task.execution. (only if you hand the executor over), and spring.lifecycle.timeout-per-shutdown-phase
(how long a graceful shutdown may take in total).
The complete Bookshelf configuration
The Bookshelf service, configured for development, tests and production, as it was run. The five jobs are the Bookshelf jobs of the other pages (QuartzJobBean
subclasses; here they only log). What this page adds is the configuration around them, so that the schedule is
data and the environment is a profile.
package bookshelf;
import java.time.Duration;
import java.util.TimeZone;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("bookshelf.schedule")
public record ScheduleProperties(TimeZone zone, Duration cacheRefreshInterval, String overdueCron,
String weeklyCron, String retentionCron) {
}
package bookshelf;
import org.quartz.CronScheduleBuilder;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ScheduleProperties.class)
class BookshelfSchedule {
@Bean JobDetail cacheRefreshJob() {
return JobBuilder.newJob(CacheRefreshJob.class).withIdentity("cacheRefresh", "bookshelf").storeDurably().build();
}
@Bean Trigger cacheRefreshTrigger(JobDetail cacheRefreshJob, ScheduleProperties p) {
return TriggerBuilder.newTrigger().forJob(cacheRefreshJob).withIdentity("cacheRefreshTrigger", "bookshelf")
.withSchedule(SimpleScheduleBuilder.simpleSchedule()
.withIntervalInMilliseconds(p.cacheRefreshInterval().toMillis()).repeatForever()
.withMisfireHandlingInstructionNextWithRemainingCount()).build();
}
@Bean JobDetail overdueLoanRemindersJob() {
return JobBuilder.newJob(OverdueLoanRemindersJob.class).withIdentity("overdueLoanReminders", "bookshelf")
.usingJobData("runs", 0).storeDurably().build();
}
@Bean Trigger overdueLoanRemindersTrigger(JobDetail overdueLoanRemindersJob, ScheduleProperties p) {
return cron(overdueLoanRemindersJob, "overdueLoanRemindersTrigger", p.overdueCron(), p);
}
@Bean JobDetail weeklyStatisticsJob() {
return JobBuilder.newJob(WeeklyStatisticsJob.class).withIdentity("weeklyStatistics", "bookshelf").storeDurably().build();
}
@Bean Trigger weeklyStatisticsTrigger(JobDetail weeklyStatisticsJob, ScheduleProperties p) {
return cron(weeklyStatisticsJob, "weeklyStatisticsTrigger", p.weeklyCron(), p);
}
@Bean JobDetail retentionCleanupJob() {
return JobBuilder.newJob(RetentionCleanupJob.class).withIdentity("retentionCleanup", "bookshelf").storeDurably().build();
}
@Bean Trigger retentionCleanupTrigger(JobDetail retentionCleanupJob, ScheduleProperties p) {
return cron(retentionCleanupJob, "retentionCleanupTrigger", p.retentionCron(), p);
}
private static Trigger cron(JobDetail job, String name, String expression, ScheduleProperties p) {
return TriggerBuilder.newTrigger().forJob(job).withIdentity(name, "bookshelf")
.withSchedule(CronScheduleBuilder.cronSchedule(expression).inTimeZone(p.zone())).build();
}
}
Source: Quartz Tutorial: Lesson 6, CronTrigger and
Spring Boot: Externalized Configuration (@ConfigurationProperties records).
The listener and the customizer are the BookshelfQuartzConfiguration shown above, plus the virtual-thread customizer; the listener is a plain component:
package bookshelf;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.quartz.JobListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component
class JobAuditListener implements JobListener {
private static final Logger log = LoggerFactory.getLogger(JobAuditListener.class);
@Override public String getName() { return "bookshelf-audit"; }
@Override public void jobToBeExecuted(JobExecutionContext context) {
log.info("starting {} on {}", context.getJobDetail().getKey(), Thread.currentThread().getName());
}
@Override public void jobExecutionVetoed(JobExecutionContext context) { }
@Override public void jobWasExecuted(JobExecutionContext context, JobExecutionException error) {
log.info("finished {} in {} ms{}", context.getJobDetail().getKey(), context.getJobRunTime(),
error == null ? "" : " with " + error.getMessage());
}
}
The shared configuration and the four profile files:
spring:
application:
name: bookshelf-service
quartz:
scheduler-name: BookshelfScheduler
wait-for-jobs-to-complete-on-shutdown: true
overwrite-existing-jobs: false
bookshelf:
schedule:
zone: Europe/Madrid
cache-refresh-interval: 5m
overdue-cron: "0 0 2 ? * MON-FRI"
weekly-cron: "0 0 6 ? * MON"
retention-cron: "0 30 3 LW * ?" # last weekday of the month
management:
endpoints:
web:
exposure:
include: health,quartz
spring:
quartz:
job-store-type: memory
startup-delay: 3s
properties:
org.quartz.threadPool.threadCount: "3"
bookshelf:
schedule:
cache-refresh-interval: 10s
overdue-cron: "0/15 * * * * ?"
management:
endpoint:
quartz:
show-values: always # job data is visible in /actuator/quartz: development only
spring:
quartz:
job-store-type: memory
auto-startup: false
properties:
org.quartz.threadPool.threadCount: "2"
Source: Spring Boot: Quartz Scheduler.
spring:
datasource:
url: ${DB_URL}
username: ${DB_USER}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 16
quartz:
job-store-type: jdbc
jdbc:
initialize-schema: never # the migration tool owns the QRTZ_ tables
properties:
org.quartz.scheduler.instanceId: AUTO
org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
org.quartz.jobStore.isClustered: "true"
org.quartz.jobStore.clusterCheckinInterval: "5000"
org.quartz.jobStore.misfireThreshold: "60000"
# threadCount is left at Spring's default of 10; do not set it when the virtual profile is active
Source: Spring Boot: Quartz Scheduler and
Quartz documentation: Configure Clustering with JDBC-JobStore.
The database password is a placeholder resolved from the environment (${DB_PASSWORD}), never a literal value.
The virtual profile is the application-virtual.yml shown in the virtual-thread section. All five files and the three classes were compiled and run as
one application: dev on the memory store, prod on two nodes against one PostgreSQL 17 (tables created beforehand from tables_postgres.sql),
prod,virtual on one node and test with the scheduler stopped. The results are in the profile table above. Run it like this:
java -jar bookshelf.jar --spring.profiles.active=dev
DB_URL=jdbc:postgresql://db:5432/bookshelf DB_USER=bookshelf DB_PASSWORD=... \
java -jar bookshelf.jar --spring.profiles.active=prod,virtual
Source: Spring Boot: Profiles. (The runs used the compiled classes with a classpath instead of a jar.)
Worked example: asking the running scheduler what is configured
With this many layers, the useful habit is to ask the running scheduler, not the files. The Actuator endpoint shows jobs and triggers,
SchedulerMetaData shows the engine. This is the check used throughout this page:
import org.quartz.Scheduler;
import org.quartz.SchedulerMetaData;
static String describe(Scheduler scheduler) throws Exception {
SchedulerMetaData md = scheduler.getMetaData();
return "schedulerName=" + md.getSchedulerName()
+ "\ninstanceId=" + md.getSchedulerInstanceId()
+ "\njobStoreClass=" + md.getJobStoreClass().getName()
+ "\nthreadPoolClass=" + md.getThreadPoolClass().getName()
+ "\nthreadPoolSize=" + md.getThreadPoolSize()
+ "\nsupportsPersistence=" + md.isJobStoreSupportsPersistence()
+ "\nclustered=" + md.isJobStoreClustered();
}
Source: SchedulerMetaData Javadoc.
default (memory) schedulerName=BookshelfScheduler, instanceId=NON_CLUSTERED, jobStoreClass=org.quartz.simpl.RAMJobStore,
threadPoolClass=org.quartz.simpl.SimpleThreadPool, threadPoolSize=10, supportsPersistence=false, clustered=false
task executor set ... threadPoolClass=org.springframework.scheduling.quartz.LocalTaskExecutorThreadPool, threadPoolSize=-1
prod (JDBC, clustered) ... jobStoreClass=org.springframework.scheduling.quartz.LocalDataSourceJobStore, supportsPersistence=true, clustered=true
Source: output of the runs above (getMetaData() of the running scheduler).
When a setting "does nothing", compare this output with the file: a thread pool size of 10 after you set 4 is the environment-variable case, a
RAMJobStore after you set jdbc is the DataSource case, a class name that is not the one you set is a customizer or a back-off.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
A |
|
Use |
|
The environment variable binds to the lower-case key, which Quartz ignores |
Put raw keys in |
|
A JDBC-store key reached |
Keep store keys in the profile that selects the store; order profiles so the store’s profile is last |
|
Zero |
One |
|
The |
Add |
|
|
Remove every |
Jobs do not run on the |
Boot never passes an |
|
Four of six triggers in state ERROR after a burst |
A bounded executor rejected the tasks; |
Unbounded queue with care, or a blocking |
A running job is abandoned at shutdown, or finishes but stays |
With a Spring executor |
|
A second |
Boot’s is |
Define both yourself with one |
|
Two |
|
Listener beans never run |
Boot does not register listener beans |
Customizer with |
A cron change in |
JDBC store plus |
Set it to |
A sub-second |
|
Use whole seconds |
Source: output of the runs on this page; the failure messages are quoted.
What changed
| Topic | Before | Now (Quartz 2.5.2, Boot 4.1.1, Spring Framework 7.0.9) |
|---|---|---|
Packages of the Quartz types |
|
|
|
A nested class of |
|
|
The Spring Javadoc says the factory recognizes the file |
Not under Boot, measured: use |
|
60000 for every store, in the Quartz documentation |
5000 for |
Virtual threads |
|
|
Listeners |
Registered through Quartz properties or code |
Same: Boot registers no listener beans |
Spring Framework 6.2 (Boot 3.5) |
|
Unchanged API in 7.0.9 |
The wider list of version changes and documentation discrepancies found while writing this section is on Migration & What’s Changed.
Source: Spring Boot 4.0 Migration Guide; the 3.5.16 column was read from
spring-boot-autoconfigure-3.5.16.jar, the rest was run on the versions above.
Where to go next
-
Running several nodes against one store and the cluster properties: Clustering.
-
Creating, pausing and removing triggers at runtime with the injected
Scheduler: Dynamic Scheduling & Management. -
Metrics and health for the scheduler, which Boot does not provide: Observability.
-
Tests that start the scheduler, trigger a job and wait for it: Testing.
-
Thread budgets, connection pools, shutdown and the rest of the production checklist: Production & Best Practices.
-
The one-page summary of every key: Cheat Sheet and the section overview on Scheduling.