Getting Started with Spring Boot

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.

This page takes a Spring Boot service from an empty pom.xml to a Quartz job that runs on a cron schedule, can be seen in /actuator/quartz and is covered by a smoke test. It explains what Spring Boot does for you behind the scenes, how a job receives its Spring beans and its settings, and which of Spring Framework’s older factory beans are still worth knowing. The Bookshelf scenario provides the job: a nightly reminder to every member who holds an overdue loan. The vocabulary (Scheduler, JobDetail, Trigger, job store) is introduced in Quartz Fundamentals; the reasons to pick Quartz over @Scheduled are in Standard Scheduling vs Quartz.

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 snippet below was compiled and run in a test project on exactly those versions (macOS, JDK 25 running Java 21 bytecode, Maven 3.9), and the outputs are quoted, not assumed. Spring Boot 3.5.x uses the same Quartz 2.5.2 and the same spring.quartz.* property names; what differs is the package of the auto-configuration classes (org.springframework.boot.autoconfigure.quartz instead of org.springframework.boot.quartz.autoconfigure), Spring Framework 6.2.19 instead of 7.0.9, and the absence of the spring-boot-starter-quartz-test starter. Each difference is called out where it matters and collected in What changed.

Dependencies

Spring Boot manages the Quartz version, so no version number appears in the build file. The starter is all that is needed for in-memory scheduling. A JDBC job store adds the JDBC starter and a driver, and the Actuator endpoint adds the Actuator starter.

Table 1. What each dependency is for
Artifact What it brings Scope

spring-boot-starter-quartz

The spring-boot-quartz module (auto-configuration, the /actuator/quartz endpoint, @QuartzDataSource), Quartz 2.5.2, Spring’s spring-context-support (where SchedulerFactoryBean and QuartzJobBean live) and transaction support.

compile

spring-boot-starter-jdbc

DataSource auto-configuration, a connection pool and a transaction manager. Needed only for spring.quartz.job-store-type=jdbc. Add the driver of your database next to it.

compile, optional

spring-boot-starter-actuator with spring-boot-starter-webmvc

The quartz Actuator endpoint and a web server to serve it.

compile, optional

com.h2database:h2

An embedded database, so the JDBC store can be tried without installing anything. Never the production store.

runtime

spring-boot-starter-quartz-test

spring-boot-starter-test plus spring-boot-starter-quartz. New in Spring Boot 4.0; in 3.5 use spring-boot-starter-test.

test

org.awaitility:awaitility

Polling assertions for "the job fired within five seconds". Version managed by Boot.

test

Maven (Spring Boot 4.1.1 parent, Java 21)
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
    <relativePath/>
</parent>

<properties>
    <java.version>21</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-quartz</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-quartz-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.awaitility</groupId>
        <artifactId>awaitility</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Source: Spring Boot: Quartz Scheduler and the dependency list of spring-boot-smoke-test-quartz at the v4.1.1 tag.

Gradle (Kotlin DSL, Spring Boot 4.1.1, Java 21)
plugins {
    java
    id("org.springframework.boot") version "4.1.1"
    id("io.spring.dependency-management") version "1.1.7"
}

group = "com.example.bookshelf"
version = "0.0.1-SNAPSHOT"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-quartz")
    implementation("org.springframework.boot:spring-boot-starter-jdbc")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.springframework.boot:spring-boot-starter-webmvc")
    runtimeOnly("com.h2database:h2")

    testImplementation("org.springframework.boot:spring-boot-starter-quartz-test")
    testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
    testImplementation("org.awaitility:awaitility")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.withType<Test> {
    useJUnitPlatform()
}

Source: Spring Boot: Quartz Scheduler. The Gradle build was not executed on the verification machine (no Gradle installation); the artifact names and the plugin versions were checked against Maven Central and the Gradle plugin portal, and the Maven build above was run.

Spring Boot 3.5.x. Use the parent version 3.5.16 (or id("org.springframework.boot") version "3.5.16") and replace spring-boot-starter-quartz-test with spring-boot-starter-test, because the Quartz test starter does not exist on the 3.5 line (Maven Central has it from 4.0.0). The web starter is called spring-boot-starter-web on 3.5, and spring-boot-starter-webmvc-test does not exist there either. Everything else, including spring-boot-starter-quartz itself, keeps its name. The starter pulls Quartz 2.5.2 and Spring Framework 6.2.19.

What QuartzAutoConfiguration does

When Quartz and Spring’s SchedulerFactoryBean are on the classpath, QuartzAutoConfiguration builds one Quartz Scheduler for the application and hands it everything it needs. Reading the 4.1.1 source gives this list, in this order:

  • It creates a SchedulerFactoryBean named quartzScheduler. The bean is declared @ConditionalOnMissingBean, so defining your own SchedulerFactoryBean makes Boot back off completely. SchedulerFactoryBean is a Spring FactoryBean, so the Scheduler itself is what you inject into your own code.

  • It creates a SpringBeanJobFactory, gives it the ApplicationContext and installs it as the scheduler’s job factory. This is what makes constructor injection, @Autowired and setter injection work in jobs (see the next section).

  • It copies the spring.quartz. settings into the factory bean: auto-startup, startup-delay, wait-for-jobs-to-complete-on-shutdown, overwrite-existing-jobs, scheduler-name (only if you set it, otherwise Spring uses the bean name) and the raw Quartz properties under spring.quartz.properties. (only if the map is not empty).

  • It collects every JobDetail bean and every Trigger bean, in @Order, and every Calendar bean (the bean name becomes the calendar name), and registers them with the scheduler.

  • It runs every SchedulerFactoryBeanCustomizer bean in order, last, so a customizer can override anything above.

  • Only for spring.quartz.job-store-type=jdbc and a single candidate DataSource (one bean, or one marked @Primary): it adds a customizer (ordered first) that sets the DataSource, picking the @QuartzDataSource bean when there is one, and the transaction manager, picking the @QuartzTransactionManager bean when there is one. It also adds a schema initializer that depends on spring.quartz.jdbc.initialize-schema. The JDBC store is covered on Job Stores & Persistence.

  • If the Actuator is present, QuartzEndpointAutoConfiguration adds the quartz endpoint for the Scheduler.

flowchart LR subgraph ctx[Beans in the ApplicationContext] JD[JobDetail beans] TR[Trigger beans] CAL[Calendar beans] CUS[SchedulerFactoryBeanCustomizer beans] end PR[spring.quartz.* properties] DS[(DataSource
only for job-store-type jdbc)] AC[QuartzAutoConfiguration] SFB[SchedulerFactoryBean
bean name quartzScheduler
job factory SpringBeanJobFactory] SCH[Scheduler
inject it by type] EP[Actuator quartz endpoint] JD --> AC TR --> AC CAL --> AC CUS --> AC PR --> AC DS -.-> AC AC --> SFB SFB --> SCH SCH --> EP
Figure 1. The beans Spring Boot collects and what it builds from them

The order in which things happen at startup matters in practice, because jobs and triggers are written to the job store while the scheduler is still in standby, and the scheduler only starts once the whole context is ready.

sequenceDiagram autonumber participant Boot as Spring Boot participant AC as QuartzAutoConfiguration participant F as SchedulerFactoryBean participant S as Scheduler quartzScheduler participant JS as JobStore Boot->>AC: collect JobDetail, Trigger, Calendar and customizer beans AC->>F: create it with SpringBeanJobFactory and the spring.quartz settings AC->>F: setJobDetails, setCalendars, setTriggers, then run each customizer Boot->>F: afterPropertiesSet F->>S: create the scheduler from the merged Quartz properties F->>S: register jobs, calendars and triggers while in standby S->>JS: store them, or skip those that exist when overwrite is off Boot->>F: start, as a SmartLifecycle in the last phase F->>S: start, after startup-delay seconds if set S-->>S: the scheduler thread begins to fire triggers
Figure 2. Startup order, from bean collection to the first firing

The settings Boot reads, with the defaults taken from the 4.1.1 source (the complete list, including the JDBC properties, is on Spring Boot Configuration):

Table 2. The spring.quartz.* properties used on this page
Property Default Effect

spring.quartz.job-store-type

memory

memory is Quartz’s RAMJobStore; jdbc is Spring’s LocalDataSourceJobStore.

spring.quartz.scheduler-name

none

Without it the scheduler is named after the factory bean, quartzScheduler (measured).

spring.quartz.auto-startup

true

Start the scheduler when the context has started. false leaves it in standby.

spring.quartz.startup-delay

0s

Delay before starting, a Duration that Boot passes to Spring as whole seconds, so 500ms becomes 0.

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

false

Whether closing the context waits for running jobs to finish.

spring.quartz.overwrite-existing-jobs

false

Whether bean definitions replace jobs and triggers already stored in a persistent job store.

spring.quartz.properties.*

empty

Raw Quartz properties such as org.quartz.threadPool.threadCount.

Two things that the auto-configuration does not do are worth knowing from the start. It does not register listeners: JobListener and TriggerListener beans are ignored unless you register them in a customizer (see Listeners & Plugins). And it adds no metrics or health indicator for Quartz (see Observability).

Source: Spring Boot: Quartz Scheduler and QuartzAutoConfiguration.java at the v4.1.1 tag. On 3.5 the same class is QuartzAutoConfiguration.java in the other package.

Jobs: QuartzJobBean and dependency injection

A Quartz job is a class that implements org.quartz.Job. Quartz creates a new instance for every firing and throws it away afterwards, so a job cannot keep state in its fields between runs (state belongs in the JobDataMap, see Jobs & Job Data). Spring Framework’s QuartzJobBean is the usual base class: it implements execute for you, binds the job data to setters, and leaves one method for you to implement, executeInternal.

A four-step flow along the top. Step 1, the trigger fires: a worker thread receives a TriggerFiredBundle with the JobDetail, the Trigger and their two JobDataMaps, and no job object exists yet. Step 2, SpringBeanJobFactory calls createBean on the ApplicationContext for the job class, which performs constructor injection and injects @Autowired fields and setters, creating a new instance on every firing. Step 3, QuartzJobBean.execute binds the SchedulerContext and the merged JobDataMap to setters by name, and unknown keys are ignored. Step 4, executeInternal runs with services and settings already in place. Below step 2, a dashed box shows the ApplicationContext supplying regular Spring beans. Below step 3, three stacked boxes show the Trigger JobDataMap as the strongest source, the JobDetail JobDataMap in the middle and the SchedulerContext as the weakest, applied from the bottom up so a later value replaces an earlier one. A measured note says values 1, 2 and 3 give 3. Four sentences at the bottom summarise the consequences: take services in the constructor, put settings in the JobDataMap with a matching setter, a plain Job gets the three maps in step 2, and a JDBC job store serializes the JobDataMap so beans must stay out of it

There are two kinds of input, and they travel by different routes.

  • Spring beans (services, repositories, a Clock) come from the ApplicationContext. SpringBeanJobFactory calls createBean(jobClass), which runs the normal bean machinery: the constructor is autowired, @Autowired fields and methods are injected, and the bean post-processors run. The job is a prototype that Spring creates but does not register as a bean.

  • Settings (a limit, a template name, a tenant) come from job data. Each entry of the merged JobDataMap whose key matches a setter name is applied to that setter by Spring’s BeanWrapper, which converts the type, for example the String "100" to an int. Keys without a matching setter are ignored.

Constructor injection and a JobDataMap setter

The job below receives the OverdueLoanService through its constructor and the per-run limit through a setter. Nothing in it is Quartz-specific except the base class and executeInternal.

OverdueLoanReminderJob.java: a constructor-injected bean and a setter-injected setting
package bookshelf;

import org.quartz.JobExecutionContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.quartz.QuartzJobBean;

public class OverdueLoanReminderJob extends QuartzJobBean {

    private static final Logger log = LoggerFactory.getLogger(OverdueLoanReminderJob.class);

    private final OverdueLoanService service;   // constructor injection: a Spring bean
    private int maxReminders = 50;               // setter injection: a JobDataMap entry

    public OverdueLoanReminderJob(OverdueLoanService service) {
        this.service = service;
    }

    public void setMaxReminders(int maxReminders) {
        this.maxReminders = maxReminders;
    }

    @Override
    protected void executeInternal(JobExecutionContext context) {
        int sent = service.remindOverdue(maxReminders);
        log.info("Overdue-loan reminders sent: {} (limit {}, trigger {})",
                sent, maxReminders, context.getTrigger().getKey());
    }
}

Source: QuartzJobBean Javadoc and Spring Boot: Quartz Scheduler, whose example job also uses setters.

Which injection styles work

All the styles in the table were run in the test project, each in a QuartzJobBean subclass fired by a trigger.

Table 3. How a job can receive what it needs (measured on Spring Boot 4.1.1)
Style Works Notes

Constructor taking beans

yes

The preferred style: the dependencies are final. A package-private constructor worked too.

@Autowired field

yes

Fine for a small job, harder to test without Spring.

@Autowired setter

yes

The setter form of the same thing.

Setter for a bean without @Autowired

no

The setter is never called with the bean, so the field stays null. Setters are called only for JobDataMap and SchedulerContext entries.

Setter for a JobDataMap entry

yes

The key must equal the property name: maxReminders calls setMaxReminders. A misspelled key is silently ignored.

Spring bean stored in the JobDataMap

in memory only

Works with the RAM store. With a JDBC store the map is serialized and the application fails at startup, see the pitfalls.

The Boot reference page shows a job whose setMyService setter has no annotation and a comment that says it injects the bean. In the test above a setter written that way received nothing, while the same setter annotated with @Autowired worked. Use the constructor, or add the annotation.

Where the data comes from and who wins

A job can receive settings from three places. Spring applies them in a fixed order and a later value replaces an earlier one: first the SchedulerContext (one map for the whole scheduler, set with SchedulerFactoryBean.setSchedulerContextAsMap), then the JobDetail’s `JobDataMap, then the Trigger’s `JobDataMap. The merge was measured with the value 1 in the context, 2 on the job and 3 on the trigger.

Table 4. One setting from three sources: the trigger wins, then the job, then the scheduler context
Sources present Value the setter received

SchedulerContext, JobDetail and Trigger

3 (from the trigger)

SchedulerContext and JobDetail

2 (from the job)

SchedulerContext only

1 (from the context)

The trigger level is how one job class serves several schedules with different settings: two triggers on the same JobDetail, each with its own usingJobData("maxReminders", n).

A job that implements plain org.quartz.Job (not QuartzJobBean) is populated by SpringBeanJobFactory when the instance is created, from the same three maps with the same precedence, and gets the same constructor and @Autowired injection. Using QuartzJobBean is simply the shorter route, and it keeps the binding next to executeInternal.

Worked example: the Bookshelf overdue-loan reminders

The first Bookshelf job runs every night at 02:00 Europe/Madrid and reminds each member who holds an overdue loan. This section builds it end to end. The business code is deliberately plain Spring, with nothing Quartz-specific, because that is how a job should be written.

OverdueLoanService.java: plain Spring code that the job calls
package bookshelf;

import java.time.Clock;
import java.time.LocalDate;
import java.util.List;

import org.springframework.stereotype.Service;

@Service
public class OverdueLoanService {

    private final LoanRepository loans;
    private final ReminderSender sender;
    private final Clock clock;

    public OverdueLoanService(LoanRepository loans, ReminderSender sender, Clock clock) {
        this.loans = loans;
        this.sender = sender;
        this.clock = clock;
    }

    /** Sends at most {@code max} reminders and returns how many were sent. */
    public int remindOverdue(int max) {
        List<Loan> overdue = loans.findOverdue(LocalDate.now(clock));
        overdue.stream().limit(max).forEach(sender::send);
        return Math.min(max, overdue.size());
    }
}

Source: Spring Boot: Quartz Scheduler. Loan is a record, LoanRepository and ReminderSender are interfaces, and Clock is a bean, so the service can be tested without a database or a mail server.

The schedule is declared as two beans: a JobDetail that says what to run and a Trigger that says when. Spring Boot finds both by type.

ReminderSchedule.java: a durable JobDetail and a CronTrigger as beans
package bookshelf;

import java.util.TimeZone;

import org.quartz.CronScheduleBuilder;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
class ReminderSchedule {

    @Bean
    JobDetail overdueLoanReminderJobDetail(@Value("${bookshelf.reminders.max-per-run}") int maxPerRun) {
        return JobBuilder.newJob(OverdueLoanReminderJob.class)
                .withIdentity("overdueLoanReminders", "bookshelf")
                .withDescription("Remind members about overdue loans")
                .usingJobData("maxReminders", maxPerRun)
                .storeDurably()
                .build();
    }

    @Bean
    Trigger overdueLoanReminderTrigger(JobDetail overdueLoanReminderJobDetail,
            @Value("${bookshelf.reminders.cron}") String cron,
            @Value("${bookshelf.reminders.time-zone}") TimeZone zone) {
        return TriggerBuilder.newTrigger()
                .forJob(overdueLoanReminderJobDetail)
                .withIdentity("overdueLoanRemindersTrigger", "bookshelf")
                .withSchedule(CronScheduleBuilder.cronSchedule(cron)
                        .inTimeZone(zone)
                        .withMisfireHandlingInstructionFireAndProceed())
                .build();
    }
}

What each choice does:

  • withIdentity("overdueLoanReminders", "bookshelf") gives the job a key. The name and group together identify it in the job store, in the Actuator and in Scheduler calls. Without an identity Quartz generates a random one.

  • usingJobData("maxReminders", maxPerRun) puts the limit into the JobDataMap, from where setMaxReminders receives it.

  • storeDurably() keeps the JobDetail in the store even when no trigger points at it. Without it a job that has no trigger is rejected at startup (see the pitfalls), and a job whose last trigger is removed disappears. The reminders job is durable so that a trigger can be added, replaced or paused later without recreating the job. The trade-offs are covered on Jobs & Job Data.

  • forJob(overdueLoanReminderJobDetail) attaches the trigger to the job by its key. The trigger bean takes the JobDetail bean as a parameter, so Spring creates the job first.

  • cronSchedule(cron).inTimeZone(zone) fires on a cron expression in an explicit time zone. Always set the zone: without it the trigger uses the JVM default, and a container that runs in UTC fires at 02:00 UTC, not 02:00 in Madrid. The Quartz grammar is explained on Cron Expressions, and the trigger types on Triggers & Schedules.

  • withMisfireHandlingInstructionFireAndProceed() says what to do if the application was down at 02:00: fire once as soon as it is back, then carry on with the normal schedule. Misfires have their own page, Misfires.

  • "Business days only" is not part of this first version. The Quartz way to skip holidays is a Calendar bean, added on Calendars.

The schedule itself (cron expression, zone, limit) is externalised so that the same build can run every few seconds on a developer machine:

application.yml
spring:
  application:
    name: bookshelf-service
  quartz:
    job-store-type: memory          # the default; the JDBC store is covered on the job-stores page
    wait-for-jobs-to-complete-on-shutdown: true
    properties:
      org.quartz.threadPool.threadCount: "5"

bookshelf:
  reminders:
    cron: "0 0 2 * * ?"             # 02:00 every night
    time-zone: Europe/Madrid
    max-per-run: 100

management:
  endpoints:
    web:
      exposure:
        include: health,quartz

Source: Spring Boot: Quartz Scheduler and Spring Boot: Actuator Endpoints. Exposing endpoints in general is covered on Metrics & Observability.

Three of those lines deserve a comment. org.quartz.threadPool.threadCount is a raw Quartz property passed through spring.quartz.properties; the measured default under Boot is 10 threads. wait-for-jobs-to-complete-on-shutdown: true makes a graceful shutdown wait for a running reminder instead of abandoning it. And management.endpoints.web.exposure.include must name quartz explicitly, because over HTTP only health is exposed by default.

The job-registration decision at startup

Registering the job and the trigger at startup looks like a plain "add", and with the in-memory store it is: the store is empty on every start. With a persistent store the beans meet whatever an earlier run left behind, and spring.quartz.overwrite-existing-jobs decides who wins.

flowchart TD A[Startup: the factory bean receives the JobDetail and Trigger beans] --> B{Trigger already in the job store?} B -- no --> C[Schedule it, and add its JobDetail if that is not stored yet] B -- yes --> D{overwrite-existing-jobs} D -- false, the default --> E[Skip: the stored trigger and job data stay as they were] D -- true --> F[Replace the JobDetail and reschedule the trigger from the bean]
Figure 3. What happens to a JobDetail and a Trigger bean when the store may already hold them

This was measured with the JDBC store on an in-memory H2 database, starting the context twice against the same database and changing the cron expression from 0 0 2 * * ? to 0 0 3 * * ? and the limit from 100 to 200 between the starts.

Table 5. Second start with a changed schedule, JDBC store
overwrite-existing-jobs Stored cron after the second start Stored maxReminders

false (default)

0 0 2 * * ?, the old value

100, the old value

true

0 0 3 * * ?, the new value

200, the new value

So with a persistent store and the default setting, editing the cron expression in application.yml and redeploying changes nothing. Either set overwrite-existing-jobs: true (the code is then the source of truth for every bean-defined job and trigger, and a runtime change to those keys is lost at the next restart), or change the trigger at runtime through the Scheduler API, covered on Dynamic Scheduling & Management.

Source: Spring Boot: Quartz Scheduler, which says that jobs created by configuration do not overwrite jobs read from a persistent job store by default, and SchedulerAccessor.java at the v7.0.9 tag for the exact rule.

Running it and reading /actuator/quartz

Start the application with a cron expression that fires every ten seconds, so the first run does not take until 02:00. The override goes through an environment variable because a cron expression contains spaces:

Running the Bookshelf service with a fast schedule
BOOKSHELF_REMINDERS_CRON='0/10 * * * * ?' ./mvnw spring-boot:run -Dspring-boot.run.arguments=--server.port=8089

Source: Spring Boot: Quartz Scheduler. Spring Boot’s relaxed binding maps BOOKSHELF_REMINDERS_CRON to bookshelf.reminders.cron.

The log shows the scheduler being built, started after the web server, and then the job firing on the Quartz worker threads (lines shortened):

Startup and the first two firings (output of the run above)
INFO  org.quartz.core.QuartzScheduler   : Quartz Scheduler v2.5.2 created.
INFO  org.quartz.simpl.RAMJobStore      : RAMJobStore initialized.
INFO  org.quartz.core.QuartzScheduler   : Scheduler meta-data: Quartz Scheduler (v2.5.2) 'quartzScheduler' with instanceId 'NON_CLUSTERED'
  NOT STARTED.
  Using thread pool 'org.quartz.simpl.SimpleThreadPool' - with 5 threads.
  Using job-store 'org.quartz.simpl.RAMJobStore' - which does not support persistence. and is not clustered.
INFO  org.quartz.core.QuartzScheduler   : JobFactory set to: org.springframework.scheduling.quartz.SpringBeanJobFactory@626b639e
INFO  o.s.boot.tomcat.TomcatWebServer   : Tomcat started on port 8089 (http) with context path '/'
INFO  o.s.s.quartz.SchedulerFactoryBean : Starting Quartz Scheduler now
INFO  bookshelf.BookshelfApplication    : Started BookshelfApplication in 1.171 seconds
INFO  [eduler_Worker-1] bookshelf.LoggingReminderSender    : Reminder to ada@example.com: 'The Pragmatic Programmer' was due on 2026-10-02
INFO  [eduler_Worker-1] bookshelf.LoggingReminderSender    : Reminder to linus@example.com: 'Refactoring' was due on 2026-09-25
INFO  [eduler_Worker-1] bookshelf.OverdueLoanReminderJob   : Overdue-loan reminders sent: 2 (limit 100, trigger bookshelf.overdueLoanRemindersTrigger)
INFO  [eduler_Worker-2] bookshelf.OverdueLoanReminderJob   : Overdue-loan reminders sent: 2 (limit 100, trigger bookshelf.overdueLoanRemindersTrigger)

Source: Quartz Quick Start Guide, which starts a scheduler and schedules a first job the same way outside Spring.

The meta-data block reports the three facts that decide how the scheduler behaves: the store (RAMJobStore, no persistence), the thread pool (5 threads, as configured) and that the scheduler is not clustered. The job ran on a different worker thread each time, as the thread name shows. Quartz hands every firing to whichever pool thread is free.

The quartz Actuator endpoint lists what the scheduler knows. It is read-only apart from one operation, "run this job now".

Table 6. The quartz endpoint (measured on the run above)
Request Returns

GET /actuator/quartz

The job group names and the trigger group names.

GET /actuator/quartz/jobs and /actuator/quartz/triggers

Every group with the names of its jobs or triggers.

GET /actuator/quartz/jobs/bookshelf and /actuator/quartz/triggers/bookshelf

One group in detail; triggers are grouped by type (cron, simple, calendarInterval, dailyTimeInterval, custom).

GET /actuator/quartz/jobs/bookshelf/overdueLoanReminders

One job: class name, durability, recovery flag, masked data and its triggers with the previous and next fire times.

GET /actuator/quartz/triggers/bookshelf/overdueLoanRemindersTrigger

One trigger: state, type, start, previous and next fire time, priority and the cron expression with its time zone.

POST /actuator/quartz/jobs/bookshelf/overdueLoanReminders with {"state":"running"}

Runs the job now, once, and returns the trigger time. Any other state returns 400, an unknown job 404.

GET /actuator/quartz/jobs/bookshelf/overdueLoanReminders
{
  "className": "bookshelf.OverdueLoanReminderJob",
  "data": {
    "maxReminders": "******"
  },
  "description": "Remind members about overdue loans",
  "durable": true,
  "group": "bookshelf",
  "name": "overdueLoanReminders",
  "requestRecovery": false,
  "triggers": [
    {
      "group": "bookshelf",
      "name": "overdueLoanRemindersTrigger",
      "previousFireTime": "2026-10-05T09:31:10.000Z",
      "nextFireTime": "2026-10-05T09:31:20.000Z",
      "priority": 5
    }
  ]
}
GET /actuator/quartz/triggers/bookshelf/overdueLoanRemindersTrigger
{
  "group": "bookshelf",
  "name": "overdueLoanRemindersTrigger",
  "state": "NORMAL",
  "type": "cron",
  "startTime": "2026-10-05T09:30:52.000Z",
  "previousFireTime": "2026-10-05T09:31:10.000Z",
  "nextFireTime": "2026-10-05T09:31:20.000Z",
  "priority": 5,
  "data": {},
  "cron": {
    "expression": "0/10 * * * * ?",
    "timeZone": "Europe/Madrid"
  }
}
Running the job now
curl -s -X POST -H 'Content-Type: application/json' \
     -d '{"state":"running"}' \
     localhost:8089/actuator/quartz/jobs/bookshelf/overdueLoanReminders
Response of the POST
{"className":"bookshelf.OverdueLoanReminderJob","group":"bookshelf","name":"overdueLoanReminders","triggerTime":"2026-10-05T09:31:16.005152Z"}

Three behaviours are easy to trip over. The endpoint is not exposed over HTTP unless management.endpoints.web.exposure.include names it; without that, GET /actuator/quartz returns 404. The job data is masked as because management.endpoint.quartz.show-values defaults to never; set it to always only for trusted users, optionally together with management.endpoint.quartz.roles. And the endpoint can trigger a job but cannot pause, resume, delete or reschedule anything; that needs the Scheduler API. Securing an endpoint that can start jobs is a real concern, and Production & Best Practices returns to it.

The official smoke test

Spring Boot ships a small Quartz application that it builds and tests on every release, spring-boot-smoke-test-quartz, and it is the best compact reference for how the authors expect the pieces to fit together. In the v4.1.1 tag it consists of:

  • a QuartzJobBean subclass with a single setName setter, so the job data entry name reaches it by name, which prints a greeting;

  • an application class that declares three JobDetail beans (all durable) and five Trigger beans, one or two of each builder kind: two SimpleTrigger`s (every two seconds, and every 24 hours), a `CronTrigger (03:00 on weekdays), a CalendarIntervalTrigger (weekly) and a DailyTimeIntervalTrigger (Tuesdays and Thursdays between 09:00 and 18:00);

  • an application.properties with spring.quartz.job-store-type=jdbc and management.endpoints.web.exposure.include=health,quartz; with H2 on the classpath and the default embedded initialisation mode, Boot creates the Quartz tables on its own;

  • one test that starts the application and waits up to five seconds for the greeting in the captured output, and one web test that calls every /actuator/quartz path, expects 404 for unknown names and posts {"state":"running"} to run a job on demand.

Its build uses exactly the dependencies from the table at the top of this page, including spring-boot-starter-quartz-test.

The same two checks, for the Bookshelf job, take this shape. The first test asks the Scheduler what was registered; the second waits for the job to fire. @SpringBootTest starts the whole application, so the YAML, the beans and the auto-configuration are all exercised, and the properties on the annotation speed the schedule up.

OverdueLoanReminderSmokeTest.java
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.hamcrest.Matchers.containsString;

import java.time.Duration;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.quartz.CronTrigger;
import org.quartz.JobDetail;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.TriggerKey;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;

@SpringBootTest(properties = {
        "bookshelf.reminders.cron=0/2 * * * * ?",   // fire every two seconds instead of at 02:00
        "bookshelf.reminders.max-per-run=1"
})
@ExtendWith(OutputCaptureExtension.class)
class OverdueLoanReminderSmokeTest {

    @Autowired
    Scheduler scheduler;

    @Test
    void jobAndTriggerAreRegistered() throws SchedulerException {
        JobDetail job = scheduler.getJobDetail(JobKey.jobKey("overdueLoanReminders", "bookshelf"));
        CronTrigger trigger = (CronTrigger) scheduler
                .getTrigger(TriggerKey.triggerKey("overdueLoanRemindersTrigger", "bookshelf"));

        assertThat(job.isDurable()).isTrue();
        assertThat(job.getJobDataMap().getInt("maxReminders")).isEqualTo(1);
        assertThat(trigger.getJobKey()).isEqualTo(job.getKey());
        assertThat(trigger.getTimeZone().getID()).isEqualTo("Europe/Madrid");
    }

    @Test
    void jobFires(CapturedOutput output) {
        await().atMost(Duration.ofSeconds(10))
                .until(output::toString, containsString("Overdue-loan reminders sent: 1 (limit 1"));
    }
}

Source: spring-boot-smoke-test-quartz at the v4.1.1 tag, whose SampleQuartzApplicationTests and SampleQuartzApplicationWebTests this test follows. Both tests passed in the test project (2 tests, 1.1 s). Better ways to test jobs without waiting for a clock, such as calling the job’s service directly or driving a Scheduler by hand, are on Testing.

Spring Framework’s factory beans

Before Spring Boot and before Quartz had its fluent builders, Spring Framework made Quartz configurable from XML with four factory beans in org.springframework.scheduling.quartz. They still ship in Spring Framework 7.0.9 and still work with Spring Boot: a bean of type FactoryBean<JobDetail> or FactoryBean<Trigger> produces a JobDetail or Trigger bean, which the auto-configuration then collects like any other.

Table 7. The four factory beans and what they produce
Factory bean Produces What it adds

JobDetailFactoryBean

JobDetail

Properties instead of JobBuilder: jobClass, durability, requestsRecovery, description, jobDataAsMap and applicationContextJobDataKey. The name defaults to the bean name and the group to DEFAULT.

MethodInvokingJobDetailFactoryBean

JobDetail

Calls a method on a Spring bean (targetObject or targetBeanName, targetMethod, arguments) without writing a job class. concurrent=false makes the job non-concurrent.

SimpleTriggerFactoryBean

SimpleTrigger

startDelay in milliseconds, repeatInterval, repeatCount, misfireInstructionName and a jobDetail reference.

CronTriggerFactoryBean

CronTrigger

cronExpression, timeZone, calendarName, startDelay, misfireInstructionName and a jobDetail reference.

The example below declares one job of each kind, as Java configuration. The first pair is a job class with a cron trigger. The second pair calls a method on a bean and repeats every second, with concurrent set to false.

FactoryBeanSchedule.java: the same building blocks as factory beans
package bookshelf;

import java.util.Map;
import java.util.TimeZone;

import org.quartz.JobDetail;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.CronTriggerFactoryBean;
import org.springframework.scheduling.quartz.JobDetailFactoryBean;
import org.springframework.scheduling.quartz.MethodInvokingJobDetailFactoryBean;
import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean;

@Configuration(proxyBeanMethods = false)
class FactoryBeanSchedule {

    @Bean
    JobDetailFactoryBean remindersJobDetail() {
        JobDetailFactoryBean factory = new JobDetailFactoryBean();
        factory.setName("factoryReminders");
        factory.setGroup("bookshelf");
        factory.setJobClass(OverdueLoanReminderJob.class);
        factory.setDurability(true);
        factory.setJobDataAsMap(Map.of("maxReminders", 7));
        return factory;
    }

    @Bean
    CronTriggerFactoryBean remindersTrigger(JobDetail remindersJobDetail) {
        CronTriggerFactoryBean factory = new CronTriggerFactoryBean();
        factory.setName("factoryCron");
        factory.setGroup("bookshelf");
        factory.setJobDetail(remindersJobDetail);
        factory.setCronExpression("0 0 2 * * ?");
        factory.setTimeZone(TimeZone.getTimeZone("Europe/Madrid"));
        factory.setMisfireInstructionName("MISFIRE_INSTRUCTION_FIRE_ONCE_NOW");
        return factory;
    }

    @Bean
    MethodInvokingJobDetailFactoryBean cacheRefreshJobDetail(CatalogueCache cache) {
        MethodInvokingJobDetailFactoryBean factory = new MethodInvokingJobDetailFactoryBean();
        factory.setName("cacheRefresh");
        factory.setGroup("bookshelf");
        factory.setTargetObject(cache);
        factory.setTargetMethod("refresh");
        factory.setConcurrent(false);
        return factory;
    }

    @Bean
    SimpleTriggerFactoryBean cacheRefreshTrigger(JobDetail cacheRefreshJobDetail) {
        SimpleTriggerFactoryBean factory = new SimpleTriggerFactoryBean();
        factory.setName("cacheRefreshTrigger");
        factory.setGroup("bookshelf");
        factory.setJobDetail(cacheRefreshJobDetail);
        factory.setStartDelay(500);        // milliseconds
        factory.setRepeatInterval(1000);   // milliseconds
        return factory;
    }
}

Source: Spring Framework: Task Execution and Scheduling, section "Using the Quartz Scheduler", and the Javadoc of JobDetailFactoryBean, MethodInvokingJobDetailFactoryBean, SimpleTriggerFactoryBean and CronTriggerFactoryBean. In the test project (the same class with a Counter bean in place of CatalogueCache, and a cron expression that fires every two seconds) both jobs fired: the cron job logged limit 7, and the method-invoking job was called twice within the first seconds.

Reading the 7.0.9 source and inspecting the registered objects shows where the factory beans differ from the builders:

Table 8. Defaults and behaviours that differ from the fluent builders (measured and read from the 7.0.9 source)
Detail What happens

Trigger priority

The factory beans leave it at 0. Builder-made triggers have Quartz’s default of 5. The two matter only when triggers compete for a free worker thread, but a mix of the two styles silently gives the factory-bean triggers a lower priority.

MethodInvokingJobDetailFactoryBean and concurrent

The default is true. With false the job class is StatefulMethodInvokingJob, which carries @DisallowConcurrentExecution and @PersistJobDataAfterExecution; the test confirmed both flags and durable=true. The job is always durable.

MethodInvokingJobDetailFactoryBean and persistence

It stores the method invoker, which is not serializable, in the JobDataMap. With the in-memory store this is harmless. With job-store-type=jdbc the application fails at startup: NotSerializableException: Unable to serialize JobDataMap for insertion into database because the value of property 'methodInvoker' is not serializable. Use a QuartzJobBean instead.

startDelay and the start time

A trigger without startTime starts now plus startDelay milliseconds (default 0).

CronTriggerFactoryBean time zone

Defaults to the JVM default zone when timeZone is not set.

SimpleTriggerFactoryBean repeat

repeatCount defaults to -1 (forever) and repeatInterval to 0. Leaving the interval out fails at startup with SchedulerException: Repeat Interval cannot be zero. (measured).

Names

Without name, the bean name is used, and the group defaults to DEFAULT. Set both explicitly if you want the same keys as the builder version, because the Actuator, the job store and Scheduler calls all address a job by its group and name.

Misfire instruction

Pass an integer to setMisfireInstruction or, more readably, the name of a constant to setMisfireInstructionName (for example MISFIRE_INSTRUCTION_FIRE_ONCE_NOW); an unknown name is rejected at startup.

When would you still reach for them? For new code, the builders in the worked example are shorter and map one-to-one onto the Quartz documentation, so they are the default recommendation. The factory beans remain useful in four situations: an existing Spring XML configuration that you do not want to rewrite, because the same properties can be set from XML; a shared library that exposes configurable triggers through @ConfigurationProperties; MethodInvokingJobDetailFactoryBean for a quick "call this bean method on a schedule" when the in-memory store is enough; and misfireInstructionName when you want the instruction spelled out as text in configuration. If the only goal is to call a method on a timer with the in-memory store, @Scheduled is simpler still, and the trade-off is laid out on Standard Scheduling vs Quartz.

Pitfalls

Every row below was reproduced in the test project unless it says otherwise.

Table 9. Common first-week problems with Quartz on Spring Boot
Pitfall What you see Fix

A setter for a Spring bean without @Autowired

The setter is never called with the bean, the field stays null and the job throws a NullPointerException.

Inject beans through the constructor, or annotate the field or setter with @Autowired.

A misspelled JobDataMap key

No error. The setter is not called and the default value is used: a job with the key maxReminder ran with its default of 50 instead of the configured value.

Cover the effect in a smoke test; keep job data keys and setter names in constants or in one builder method.

A Spring bean stored in the JobDataMap with the JDBC store

Startup fails with NotSerializableException because the whole map is serialized into the database.

Keep only Strings and primitives in the map, and take beans from the constructor.

A JobDetail that is not durable and has no trigger

Startup fails with SchedulerException: Jobs added with no trigger must be durable.

Call storeDurably(), or give the job a trigger.

A trigger whose job does not exist

Startup fails with The job (bookshelf.nope) referenced by the trigger does not exist.; a trigger with no forJob and no jobDetail fails with Trigger’s related Job’s name cannot be null.

Declare the JobDetail bean, and attach the trigger with forJob(…​).

Editing the cron expression with a persistent store

After a redeploy the old schedule keeps running, because the stored trigger wins unless overwrite-existing-jobs is true.

Set the property, or change the trigger at runtime through the Scheduler.

Leaving the time zone out of a cron trigger

The trigger uses the JVM zone, so a container in UTC fires at the wrong local hour.

inTimeZone(…​), or timeZone on the factory bean.

startup-delay below one second

500ms is truncated to whole seconds, so the delay is 0.

Use whole seconds, or start the scheduler yourself (auto-startup: false).

The in-memory store in production

Nothing survives a restart, and every instance runs every job (two instances send every reminder twice).

Use a JDBC store, see Job Stores & Persistence and Clustering.

A quartz.properties file on the classpath

It is ignored by Spring Boot: with the file present the scheduler still had 10 threads and a 5-second misfire threshold.

Use spring.quartz.properties.* or a customizer, see Spring Boot Configuration.

More than one DataSource with job-store-type=jdbc

No error. Boot silently falls back to the in-memory store, so jobs vanish at restart.

Mark one DataSource @Primary; annotate the Quartz one with @QuartzDataSource as well when it is not the primary one (@QuartzDataSource alone does not help).

/actuator/quartz returns 404, or data shows as

The endpoint is not exposed over HTTP by default and masks job data by default.

Add quartz to management.endpoints.web.exposure.include; set management.endpoint.quartz.show-values only for trusted users.

Mixing factory-bean and builder triggers

The factory-bean triggers run at priority 0, the builder ones at 5.

Pick one style, or set priority explicitly on both.

What changed

Table 10. Differences that affect a Quartz application between Spring Boot 3.5, 4.0 and 4.1
Area Spring Boot 3.5.16 Spring Boot 4.1.1

Auto-configuration package

org.springframework.boot.autoconfigure.quartz

org.springframework.boot.quartz.autoconfigure, in the new spring-boot-quartz module (since 4.0.0)

Types you import: @QuartzDataSource, @QuartzTransactionManager, SchedulerFactoryBeanCustomizer

in the 3.5 package above

in the 4.x package above; update the imports when upgrading

Actuator endpoint classes

org.springframework.boot.actuate.quartz

org.springframework.boot.quartz.actuate.endpoint; the endpoint id is quartz on both lines

Quartz test starter

none; use spring-boot-starter-test

spring-boot-starter-quartz-test (since 4.0.0)

Quartz version managed by Boot

2.5.2

2.5.2

Spring Framework

6.2.19

7.0.9. The package org.springframework.scheduling.quartz and its classes are unchanged; the 7.0 sources add JSpecify nullness annotations

spring.quartz.* property names

job-store-type, jdbc.initialize-schema, overwrite-existing-jobs and the rest

the same names

Two earlier changes can also surprise someone upgrading Quartz itself, as opposed to Spring Boot. Quartz 2.5 removed the misspelled method JobDetail.isConcurrentExectionDisallowed(), and community job stores compiled against 2.4 or earlier that still call it fail at runtime. The MongoDB store is the one that fails silently: it throws NoSuchMethodError on isConcurrentExectionDisallowed, the scheduler starts and nothing fires (see MongoDB). The Redis store fails visibly, for other reasons: at startup with Boot’s Jedis 7.4.1 (a NoSuchMethodError on JedisCommands.del(String)) and, with Jedis 3.3.0, on the JSON (de)serialization of JobDetailImpl (a Jackson UnrecognizedPropertyException) (see Redis). Neither store works with the Quartz 2.5.2 that both Boot lines manage. The full 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 and the Spring Boot 4.0 release notes at the Spring Boot wiki. The package and class names above were read from the 3.5.16 jars and the 4.1.1 sources.

Where to go next