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.

flowchart TD A["application.yml, profile files, environment: spring.quartz.*"] --> B["QuartzProperties and QuartzJdbcProperties"] B --> C["QuartzAutoConfiguration builds ONE SchedulerFactoryBean: name, auto-startup, startup-delay, shutdown wait, overwrite flag, SpringBeanJobFactory, JobDetail, Trigger and Calendar beans"] A2["spring.quartz.properties.org.quartz.*, raw keys"] --> C C --> D["SchedulerFactoryBeanCustomizer beans in Order: Boot's own JDBC customizer at order 0, yours after it"] D --> E["SchedulerFactoryBean merges the keys: Spring defaults first, then the configLocation file if one was set, then quartzProperties on top"] E --> F["StdSchedulerFactory initialize with the merged Properties"] F --> G["Scheduler: job store, thread pool, plugins, global listeners"]
Figure 1. How the configuration reaches Quartz (QuartzAutoConfiguration and SchedulerFactoryBean, read from the 4.1.1 and 7.0.9 sources)

The layers, from the weakest to the strongest, are these. Each row was checked in SchedulerFactoryBean.initSchedulerFactory.

Table 1. Who wins when two layers set the same key
Rank Layer What it contributes

1

Quartz’s code defaults

Used only for keys nobody set: a RAMJobStore, instance id NON_CLUSTERED, idleWaitTime 30000 ms and so on. The quartz.properties file inside the Quartz jar is not a layer: it is read only by new StdSchedulerFactory() without arguments, and Spring always passes properties.

2

Spring’s defaults

org.quartz.threadPool.class=org.quartz.simpl.SimpleThreadPool and org.quartz.threadPool.threadCount=10, set by SchedulerFactoryBean itself (or LocalTaskExecutorThreadPool when a task executor is set). ResourceLoaderClassLoadHelper is added as class loading helper.

3

A properties file given to setConfigLocation

Only when a customizer sets it. Boot never does, so a quartz.properties on the classpath is ignored (see below).

4

spring.quartz.properties.*

Every entry overrides the same key from layers 1 to 3. Passed through only when the map is not empty.

5

Typed spring.quartz.* properties and setSchedulerName

scheduler-name replaces org.quartz.scheduler.instanceName even when both are set (measured: both set gives the Boot name).

6

Customizers

Run last, in @Order, on the SchedulerFactoryBean itself, so they can overwrite anything above (setQuartzProperties, setConfigLocation, setTaskExecutor, setJobFactory, …​).

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.

QuartzDefaultsTest.java: the defaults, bound from nothing (Boot 4.1.1)
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();
    }
}
Output of the test
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.

application-reference.yml: every spring.quartz property at its default
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:

Table 2. What auto-startup and startup-delay do (Boot 4.1.1, memory store)
Setting Right after startup Later

startup-delay: 3s

isStarted() false, in standby

3.5 seconds later: started, not in standby

startup-delay: 1500ms

isStarted() false (the delay is 1 second)

1.5 seconds later: started

startup-delay: 500ms

isStarted() true: the delay is 0 seconds

auto-startup: false

isStarted() false, in standby, 4 jobs stored

after scheduler.start(): started

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:

Table 3. Closing the context with a 4-second job running (memory store, default SimpleThreadPool)
wait-for-jobs-to-complete-on-shutdown close() took Job after close() returned

false

3 ms

still running, abandoned

true

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.

The keys most often set (application.yml fragment)
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.

Table 4. Raw Quartz keys and their defaults (Quartz 2.5.2 sources; Spring and Boot changes in the last column)
Key Default Notes

org.quartz.scheduler.instanceName

QuartzScheduler

Boot replaces it with scheduler-name or the bean name (quartzScheduler, measured).

org.quartz.scheduler.instanceId

NON_CLUSTERED

AUTO generates host name + epoch milliseconds. Required for a cluster, see Clustering.

org.quartz.scheduler.idleWaitTime

30000 ms

How long the scheduler thread waits when it has nothing to fire.

org.quartz.scheduler.dbFailureRetryInterval

15000 ms

Pause after a database failure in the scheduler thread.

org.quartz.scheduler.batchTriggerAcquisitionMaxCount

1

Triggers acquired per round. More than 1 only helps with a JDBC store and enough threads.

org.quartz.scheduler.batchTriggerAcquisitionFireAheadTimeWindow

0 ms

How far ahead a batch may reach.

org.quartz.scheduler.interruptJobsOnShutdown and …​WithWait

false

Calls interrupt() on running InterruptableJob instances during shutdown.

org.quartz.scheduler.makeSchedulerThreadDaemon

false

Whether the scheduler thread is a daemon thread.

org.quartz.scheduler.jmx.export

false

Registers the scheduler as an MBean.

org.quartz.scheduler.classLoadHelper.class

set by Spring

ResourceLoaderClassLoadHelper; leave it alone.

org.quartz.scheduler.jobFactory.class

ineffective

Spring sets its own JobFactory on the scheduler after Quartz created it (SpringBeanJobFactory under Boot), which replaces this key. Use a customizer.

org.quartz.threadPool.class

SimpleThreadPool

Spring sets it; LocalTaskExecutorThreadPool when a task executor is set.

org.quartz.threadPool.threadCount

10 (Spring’s default; measured 10)

Quartz itself has no default (-1 means "invalid"); Spring supplies 10. Rejected when the pool is LocalTaskExecutorThreadPool (measured, below).

org.quartz.threadPool.threadPriority

5 (Thread.NORM_PRIORITY)

org.quartz.threadPool.threadNamePrefix

<scheduler name>_Worker

Measured worker names: BookshelfScheduler_Worker-1.

org.quartz.threadPool.makeThreadsDaemons

false

org.quartz.jobStore.misfireThreshold

5000 ms for RAMJobStore, 60000 ms for the JDBC store

Measured 5000 in RAM mode under Boot and 60000 with LocalDataSourceJobStore. See Misfires. The only job store key that RAMJobStore accepts.

org.quartz.jobStore.maxMisfiresToHandleAtATime

20

JDBC store only.

org.quartz.jobStore.isClustered

false

JDBC store only. The key is isClustered, not clustered.

org.quartz.jobStore.clusterCheckinInterval

7500 ms

JDBC store only.

org.quartz.jobStore.driverDelegateClass

none, so StdJDBCDelegate

JDBC store only. PostgreSQLDelegate and MSSQLDelegate are mandatory on those databases.

org.quartz.jobStore.tablePrefix

QRTZ_

JDBC store only.

org.quartz.jobStore.useProperties

false

JDBC store only: true stores the JobDataMap as strings instead of a serialized BLOB.

org.quartz.jobStore.acquireTriggersWithinLock

false

JDBC store only. See Clustering.

org.quartz.jobStore.dbRetryInterval

15000 ms

JDBC store only, and setting it has no effect: StdSchedulerFactory overwrites the job store’s value with org.quartz.scheduler.dbFailureRetryInterval (default 15000 ms). Set that scheduler key instead.

org.quartz.jobStore.class

RAMJobStore; LocalDataSourceJobStore under jdbc

Override it only for a store from a library, and then only with job-store-type: memory.

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):

Table 5. org.quartz.jobStore.class under Boot (measured)
Configuration Result

memory and org.quartz.simpl.RAMJobStore

works, misfireThreshold 5000

memory and a subclass of RAMJobStore

honoured: the subclass ran (its storeTrigger was called), so a third-party store plugs in this way

memory and org.quartz.impl.jdbcjobstore.JobStoreTX without a data source

fails at startup with SchedulerConfigException: DataSource name not set.

jdbc and JobStoreTX as the class

fails with the same message: the override replaced LocalDataSourceJobStore, the only store that receives Boot’s DataSource

jdbc, no class override

LocalDataSourceJobStore, delegate unset so StdJDBCDelegate, misfireThreshold 60000

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:

Table 6. Setting a JDBC-store key while the store is RAMJobStore (measured)
Key Result at startup

org.quartz.jobStore.misfireThreshold

accepted

org.quartz.jobStore.isClustered

SchedulerException: JobStore class 'org.quartz.simpl.RAMJobStore' props could not be configured, caused by NoSuchMethodException: No setter for property 'isClustered'

org.quartz.jobStore.driverDelegateClass, maxMisfiresToHandleAtATime, tablePrefix

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:

Table 7. Setting the thread count through the environment (Boot 4.1.1, default 10 threads)
How the value 4 was supplied Pool size Effective key seen by Boot

nothing

10

environment variable SPRING_QUARTZ_PROPERTIES_ORG_QUARTZ_THREADPOOL_THREADCOUNT=4

10, silently ignored

org.quartz.threadpool.threadcount

environment variable SPRING_APPLICATION_JSON with the full key (below)

4

org.quartz.threadPool.threadCount

Source: Spring Boot: Externalized Configuration (environment variable binding and SPRING_APPLICATION_JSON); the pool sizes are read back from the running scheduler.

The variant that works: the key travels inside a JSON value, case intact
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):

Table 8. Where the classpath quartz.properties counts (Boot 4.1.1)
Setup Scheduler name Threads misfireThreshold

Boot default, file on the classpath

quartzScheduler

10

5000: the file is ignored

file on the classpath, plus spring.quartz.properties…​threadCount=7

quartzScheduler

7

5000 (file still ignored)

a customizer calls setConfigLocation for the file

FromFile

3

12345: loaded

the same customizer, plus spring.quartz.properties…​threadCount=7

FromFile

7

12345: spring.quartz.properties override the file

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:

A customizer that loads quartz.properties
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:

Table 9. SchedulerFactoryBean settings that only a customizer can change (verified setters, Spring Framework 7.0.9)
Setter What it is for

setGlobalJobListeners, setGlobalTriggerListeners, setSchedulerListeners

Register listeners. Boot does not pick up listener beans (measured below).

setTaskExecutor

Use a Spring Executor as the thread pool instead of SimpleThreadPool.

setJobFactory

Replace the SpringBeanJobFactory that Boot installed, for example to add MDC context or counters.

setSchedulerContextAsMap, setApplicationContextSchedulerContextKey

Global data every job can read from the SchedulerContext, and the ApplicationContext itself.

setConfigLocation, setQuartzProperties

Load a file or add keys in code.

setDataSource, setNonTransactionalDataSource, setTransactionManager

What Boot’s own JDBC customizer does; change it only when @QuartzDataSource and @QuartzTransactionManager are not enough.

setPhase, setAutoStartup, setStartupDelay, setExposeSchedulerInRepository, setWaitForJobsToCompleteOnShutdown

Lifecycle: the SmartLifecycle phase, and the properties that have a typed spring.quartz twin.

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:

BookshelfQuartzConfiguration.java: register every JobListener bean and set 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:

A JobFactory that counts the jobs it creates
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.

ExecutorBeanConfiguration.java: an Executor bean that Quartz ignores
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:

Table 10. SimpleThreadPool against Spring executors (measured, Boot 4.1.1, six simultaneous 1.5-second jobs)
Thread pool Result Why it matters

SimpleThreadPool, threadCount=2

max 2 concurrent, three waves at 0, 1.5 and 3.0 s, Worker-N platform threads

The scheduler thread waits for a free worker, so the pool size is a real cap.

virtual SimpleAsyncTaskExecutor

max 6 concurrent, all done after 1.55 s, virtual threads

No cap at all: blockForAvailableThreads() always says one thread is free.

ThreadPoolTaskExecutor, core = max = 2, unbounded queue

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.

ThreadPoolTaskExecutor, core = max = 2, queueCapacity=0

only 2 of 6 ran; the other four triggers ended in state ERROR

A rejected task makes runInThread return false; Quartz then sets the job’s triggers to ERROR and does not fire them again. With a recurring trigger, one burst takes the job out of service until Scheduler.resetTriggerFromErrorState(TriggerKey) is called for it.

SimpleAsyncTaskExecutor, virtual threads, concurrency-limit=2, blocking

max 2 concurrent, six done after 4.6 s

A real cap: the executor blocks the scheduler thread when the limit is reached.

same, reject-tasks-when-limit-reached=true

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:

  • threadCount is not valid any more. With a task executor set and spring.quartz.properties.org.quartz.threadPool.threadCount still present, startup fails with ThreadPool class 'org.springframework.scheduling.quartz.LocalTaskExecutorThreadPool' props could not be configured, caused by NoSuchMethodException: No setter for property 'threadCount'. Remove every org.quartz.threadPool.* key (Spring’s default of 10 is a SimpleThreadPool setting).

  • Quartz no longer waits for running jobs. shutdown(true) waits through getThreadPool().shutdown(true), which is a no-op for this pool. With a virtual SimpleAsyncTaskExecutor and wait-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 runInThread returns false, QuartzSchedulerThread logs "ThreadPool.runInThread() return false!" and calls triggeredJobComplete(…​, 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.

A customizer that runs jobs on Boot’s applicationTaskExecutor
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);
}
application-virtual.yml: virtual threads, a cap and a graceful shutdown
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:

Table 11. close() with a running job when Quartz uses applicationTaskExecutor (measured)
Executor close() took Job after close() Completion recorded by Quartz

virtual SimpleAsyncTaskExecutor, await-termination=true

3907 ms

finished

no: Quartz had already logged "shutdown complete" and shut its job store down

virtual SimpleAsyncTaskExecutor, await-termination=false

2 ms

still running

no

platform ThreadPoolTaskExecutor, await-termination=true

3901 ms

finished

no: same as the first row

platform ThreadPoolTaskExecutor, await-termination=false

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 SimpleThreadPool (no executor), for comparison

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:

Table 12. The Bookshelf service under each profile (memory store: Boot 4.1.1; prod: PostgreSQL 17 in Docker)
Profile What it sets What was observed

dev

memory store, startup-delay: 3s, three threads, jobs every 10 or 15 seconds, show-values: always

"Will start Quartz Scheduler [BookshelfScheduler] in 3 seconds", then jobs fired on BookshelfScheduler_Worker-1 to -3; /actuator/quartz showed the four jobs with unmasked job data

test

memory store, auto-startup: false, two threads

"Using thread pool SimpleThreadPool - with 2 threads", the four jobs registered, nothing fired

prod

JDBC, initialize-schema: never, instanceId: AUTO, clustered, 5-second check-in, delegate for PostgreSQL

Two nodes against one database both listed in QRTZ_SCHEDULER_STATE with CHECKIN_INTERVAL 5000, both fired jobs, job store LocalDataSourceJobStore …​ is clustered

prod,virtual

as prod, plus the virtual-thread executor

thread pool LocalTaskExecutorThreadPool, jobs on virtual threads task-N

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:

flowchart TD S["spring.quartz.job-store-type"] -->|"memory, the default"| R1["RAMJobStore: threadCount 10, misfireThreshold 5000, nothing persisted"] S -->|jdbc| Q{"Exactly one DataSource candidate: one bean, or one of them marked Primary?"} Q -->|"no: none, or several and none Primary"| R2["Silent fall-back to RAMJobStore: no error, no warning, nothing persisted"] Q -->|yes| W{"A bean annotated QuartzDataSource?"} W -->|yes| D1["Quartz uses the QuartzDataSource bean"] W -->|no| D2["Quartz uses the single or Primary DataSource"] D1 --> J["LocalDataSourceJobStore, persistent"] D2 --> J J --> I{"jdbc.initialize-schema"} I -->|"embedded, the default"| I1["Creates the tables only on an embedded database; on PostgreSQL nothing happens"] I -->|always| I2["Runs the script at every start: it drops the tables first"] I -->|never| I3["Nothing runs: a migration tool owns the schema"]
Figure 2. How Boot selects the job store and the DataSource (QuartzAutoConfiguration, measured)

The branches were run with H2 DataSource beans:

Table 13. job-store-type=jdbc against the DataSource beans in the context (measured)
DataSource beans Job store used Persistent

two beans, one marked @QuartzDataSource, no @Primary

RAMJobStore, silently

no

two beans, one @QuartzDataSource, the other @Primary

LocalDataSourceJobStore, on the @QuartzDataSource bean

yes

one bean, which is the @QuartzDataSource

LocalDataSourceJobStore

yes

no DataSource at all (DataSourceAutoConfiguration excluded)

RAMJobStore, silently

no

nothing configured (H2 on the classpath gives one embedded DataSource)

LocalDataSourceJobStore

yes

job-store-type left at memory, a DataSource exists

RAMJobStore

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:

Table 14. What Boot does when there is more than one scheduler (measured)
Configuration Result

your own SchedulerFactoryBean reportsScheduler bean, nothing else

Boot’s scheduler is not created (@ConditionalOnMissingBean backs off for any SchedulerFactoryBean). The only scheduler is yours, named reportsScheduler, with 0 jobs: the JobDetail and Trigger beans, spring.quartz.* and Boot’s DataSource customizer are applied only inside Boot’s factory method, so none of them reaches your bean.

two own SchedulerFactoryBean beans, none @Primary

Startup fails with NoUniqueBeanDefinitionException: …​ expected single matching bean but found 2 for any bean that injects Scheduler, and for the Actuator’s quartzEndpoint even when nobody injects it.

two own SchedulerFactoryBean beans, the first @Primary

Both started; the endpoint and injection by type use the primary one (GET /actuator/quartz returned the primary’s four jobs); the second had 0 jobs, and 10 threads each.

Boot’s scheduler plus a plain Scheduler bean from StdSchedulerFactory

Fails with NoUniqueBeanDefinitionException (reportsScheduler,quartzScheduler) wherever Scheduler is injected.

the same, with @Bean(autowireCandidate = false) on the second

Works: injection by type and the endpoint get Boot’s scheduler, ctx.getBean("reportsScheduler", Scheduler.class) gets the other (2 threads).

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.

TwoSchedulers.java: the first shape, Boot’s scheduler plus a hidden RAM scheduler
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;
    }
}
TwoFactoryBeans.java: the second shape, two SchedulerFactoryBeans and a primary one
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.

Table 15. Where the Quartz types live (class listings of the jars)
Type Spring Boot 3.5.16 Spring Boot 4.1.1

QuartzAutoConfiguration

org.springframework.boot.autoconfigure.quartz

org.springframework.boot.quartz.autoconfigure

QuartzProperties

same package; Jdbc is a nested class (QuartzProperties$Jdbc)

new package; jdbc is a separate class QuartzJdbcProperties, registered for spring.quartz.jdbc

SchedulerFactoryBeanCustomizer

org.springframework.boot.autoconfigure.quartz

org.springframework.boot.quartz.autoconfigure

@QuartzDataSource, @QuartzTransactionManager

org.springframework.boot.autoconfigure.quartz

org.springframework.boot.quartz.autoconfigure

JobStoreType

org.springframework.boot.autoconfigure.quartz

org.springframework.boot.quartz.autoconfigure

QuartzEndpoint, QuartzEndpointWebExtension

org.springframework.boot.actuate.quartz (spring-boot-actuator)

org.springframework.boot.quartz.actuate.endpoint (spring-boot-quartz)

QuartzEndpointAutoConfiguration, QuartzEndpointProperties

org.springframework.boot.actuate.autoconfigure.quartz (spring-boot-actuator-autoconfigure)

org.springframework.boot.quartz.autoconfigure (spring-boot-quartz)

Auto-configuration registration

QuartzAutoConfiguration in the Boot auto-configure imports file

QuartzAutoConfiguration and QuartzEndpointAutoConfiguration in spring-boot-quartz

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.

Table 16. Spring Boot Quartz properties (QuartzProperties, QuartzJdbcProperties, QuartzEndpointProperties; Boot 4.1.1)
Property Default What Boot does with it

spring.quartz.job-store-type

memory

jdbc adds Boot’s JDBC customizer, if a single DataSource candidate exists

spring.quartz.scheduler-name

none (bean name quartzScheduler)

SchedulerFactoryBean.setSchedulerName

spring.quartz.auto-startup

true

setAutoStartup

spring.quartz.startup-delay

0s

setStartupDelay((int) seconds): whole seconds, cut off

spring.quartz.wait-for-jobs-to-complete-on-shutdown

false

setWaitForJobsToCompleteOnShutdown

spring.quartz.overwrite-existing-jobs

false

setOverwriteExistingJobs

spring.quartz.properties.*

empty map

setQuartzProperties, only when not empty

spring.quartz.jdbc.initialize-schema

embedded

always, embedded or never; creates the QuartzDataSourceScriptDatabaseInitializer unless never

spring.quartz.jdbc.schema

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

The script to run

spring.quartz.jdbc.platform

auto-detected

Replaces @@platform@@; DB2 to db2_v95, MySQL and MariaDB to mysql_innodb, PostgreSQL to postgres, SQL Server to sqlServer, others unchanged

spring.quartz.jdbc.continue-on-error

true (4.x only)

Skip failing statements of the script

spring.quartz.jdbc.comment-prefix

# and --

Line comment prefixes of the script

management.endpoint.quartz.show-values

never

never, always or when-authorized: masks job and trigger data as

management.endpoint.quartz.roles

empty

Roles allowed to see unmasked values; empty means every authenticated user

management.endpoints.web.exposure.include

health only

Must name quartz for GET /actuator/quartz (404 otherwise)

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.

ScheduleProperties.java: the schedule’s settings as a record
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) {
}
BookshelfSchedule.java: the five jobs and their triggers, driven by ScheduleProperties
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();
    }
}

The listener and the customizer are the BookshelfQuartzConfiguration shown above, plus the virtual-thread customizer; the listener is a plain component:

JobAuditListener.java: a global JobListener bean
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:

application.yml: what every environment shares
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
application-dev.yml: memory store, a delayed start and fast jobs
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
application-test.yml: memory store, scheduler stopped until a test starts it
spring:
  quartz:
    job-store-type: memory
    auto-startup: false
    properties:
      org.quartz.threadPool.threadCount: "2"
application-prod.yml: PostgreSQL, clustered, schema owned by a migration tool
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:

Starting the service (one command per environment)
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:

EffectiveConfiguration.java: what the scheduler is really running
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();
}
Output under the three configurations that matter (measured with the same getters)
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

Table 17. Spring Boot configuration of Quartz: what goes wrong, why, and the fix
Symptom Cause Fix

A quartz.properties on the classpath has no effect

SchedulerFactoryBean reads a file only through setConfigLocation; Boot never sets it

Use spring.quartz.properties.*, or a customizer with setConfigLocation

org.quartz.threadPool.threadCount set through an environment variable stays at 10

The environment variable binds to the lower-case key, which Quartz ignores

Put raw keys in application.yml or in SPRING_APPLICATION_JSON

NoSuchMethodException: No setter for property 'isClustered' at startup

A JDBC-store key reached RAMJobStore: job-store-type: memory, or profiles prod,dev in the wrong order

Keep store keys in the profile that selects the store; order profiles so the store’s profile is last

job-store-type: jdbc and the jobs vanish at every restart, no error

Zero DataSource beans, or several without @Primary: Boot falls back to RAMJobStore

One DataSource, or @Primary on the application’s and @QuartzDataSource on Quartz’s; check getMetaData().getJobStoreClass()

@QuartzDataSource is ignored and the store is RAM

The jdbc configuration needs a single DataSource candidate; two beans without @Primary are not one

Add @Primary to the application’s data source

ThreadPool class …​ LocalTaskExecutorThreadPool props could not be configured

org.quartz.threadPool.threadCount (or another pool key) with a task executor set

Remove every org.quartz.threadPool.* key

Jobs do not run on the Executor bean

Boot never passes an Executor to Quartz

setTaskExecutor in a customizer; remember it also removes applicationTaskExecutor when you declare any Executor bean

Four of six triggers in state ERROR after a burst

A bounded executor rejected the tasks; LocalTaskExecutorThreadPool returns false and Quartz errors the trigger

Unbounded queue with care, or a blocking concurrency-limit; never queueCapacity=0 or reject-tasks-when-limit-reached

A running job is abandoned at shutdown, or finishes but stays EXECUTING in QRTZ_FIRED_TRIGGERS, although wait-for-jobs-to-complete-on-shutdown is true

With a Spring executor LocalTaskExecutorThreadPool.shutdown is a no-op, so Quartz shuts its job store down without waiting

spring.task.execution.shutdown.await-termination=true only keeps the JVM alive; the completion is still not recorded. Use Quartz’s own SimpleThreadPool with the wait flag, interruptJobsOnShutdownWithWait=true and a chunked InterruptableJob (Production & Best Practices: Graceful shutdown), or make requestsRecovery jobs idempotent

A second SchedulerFactoryBean makes Boot’s scheduler disappear

Boot’s is @ConditionalOnMissingBean(SchedulerFactoryBean)

Define both yourself with one @Primary, or add a plain Scheduler with autowireCandidate = false

NoUniqueBeanDefinitionException for Scheduler, also from the Actuator

Two Scheduler beans, none primary

@Primary, or hide the second one

Listener beans never run

Boot does not register listener beans

Customizer with setGlobalJobListeners / setGlobalTriggerListeners

A cron change in application.yml does nothing after the redeploy

JDBC store plus overwrite-existing-jobs: false

Set it to true, or change the trigger through the Scheduler API

A sub-second startup-delay starts the scheduler at once

(int) getSeconds() cuts the fraction off

Use whole seconds

Source: output of the runs on this page; the failure messages are quoted.

What changed

Table 18. Facts about configuring Quartz under Boot that changed, or that the documentation gets wrong
Topic Before Now (Quartz 2.5.2, Boot 4.1.1, Spring Framework 7.0.9)

Packages of the Quartz types

org.springframework.boot.autoconfigure.quartz (3.5.16)

org.springframework.boot.quartz.autoconfigure, module spring-boot-quartz

jdbc properties

A nested class of QuartzProperties with four settings; continue-on-error hard-coded

QuartzJdbcProperties with continue-on-error as a property (default true)

quartz.properties under Spring

The Spring Javadoc says the factory recognizes the file

Not under Boot, measured: use spring.quartz.properties or setConfigLocation

misfireThreshold default

60000 for every store, in the Quartz documentation

5000 for RAMJobStore (measured under Boot), 60000 for the JDBC store

Virtual threads

SimpleThreadPool only

spring.threads.virtual.enabled does not reach Quartz; pass applicationTaskExecutor to setTaskExecutor, drop threadCount, add concurrency-limit

Listeners

Registered through Quartz properties or code

Same: Boot registers no listener beans

Spring Framework 6.2 (Boot 3.5)

SchedulerFactoryBean and LocalTaskExecutorThreadPool

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.