Quartz Fundamentals

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 explains what is inside a Quartz scheduler before any framework is added on top of it: the library and its two release lines, the small set of components that cooperate to fire a job, the three types you work with every day (Job, JobDetail, Trigger), the fluent builders that create them, and the lifecycle of the scheduler itself. The examples use plain Java, no Spring, so that every behaviour can be attributed to Quartz and not to a wrapper. The next page, Getting Started with Spring Boot, shows what Spring Boot adds. If you are still deciding whether you need Quartz at all, read Standard Scheduling vs Quartz first.

It was written against Quartz 2.5.2, Spring Boot 4.1.1, Spring Framework 7.0.9 and Java 21, and every example below was compiled and run on 5 October 2026 against Quartz 2.5.2 in a test project (the run outputs are quoted, not assumed; the two version probes also ran against Quartz 2.4.1). The page itself uses no Spring API, so the Spring Boot 3.5 line (Boot 3.5.16, Spring Framework 6.2.19) changes nothing here: it manages the same Quartz 2.5.2.

What Quartz is

Quartz is a job scheduling library for the JVM. You add the artifact org.quartz-scheduler:quartz to your application, create a Scheduler object inside your own process, tell it what to run (a Job class and the data it needs) and when (one or more Trigger objects), and Quartz calls your code at those moments on threads that it owns. It is licensed under the Apache License 2.0, its source lives at github.com/quartz-scheduler/quartz, and the 2.5.2 POM lists the Terracotta engineers at IBM as the developers.

Three properties of that sentence matter for everything that follows.

  • It is a library, not a server. There is no Quartz daemon to deploy. The scheduler lives and dies with your JVM, and the only way for several application instances to cooperate is a shared job store (see Clustering).

  • It schedules calls, not processes. A job is a Java object whose execute method Quartz invokes on a worker thread. Running something in another process or container is out of scope (the comparison page lists the alternatives).

  • Time-based state is separate from behaviour. What to run (JobDetail), when to run it (Trigger) and where that information is kept (JobStore) are three independent pieces. That separation is what allows persistence, runtime rescheduling, many triggers per job and misfire handling, all of which @Scheduled does not have.

The API shown here is the Quartz 2.x API (since 2.0 the JobBuilder and TriggerBuilder DSL replaced creating JobDetailImpl and CronTriggerImpl objects by hand). There is no 3.x release: the latest release on Maven Central at the time of writing is 2.5.2, published on 1 December 2025.

The 2.4 and 2.5 lines

Quartz is developed on two parallel lines that differ in the platform they target, not in features. Both come from the same sources; pick the line that matches your application’s baseline.

Table 1. The two current Quartz lines
Aspect 2.4.x (2.4.0, 2.4.1) 2.5.x (2.5.0, 2.5.1, 2.5.2)

Minimum Java

Java 8. Measured: the 2.4.1 jar contains class files of major version 52.

Java 11. Measured: the 2.5.2 jar contains class files of major version 55.

Enterprise namespace

javax. Measured in the 2.4.1 jar: javax.transaction.UserTransaction is used by the JTA support, and the POM lists javax.xml:jaxb-api.

jakarta. Measured in the 2.5.2 sources: jakarta.transaction (used by JobStoreCMT and the JTA job shells) and, per the POM, jakarta.xml.bind:jakarta.xml.bind-api for the XML scheduling-data plugin.

Release dates

2.4.0 on 13 November 2024

2.5.0 on 12 November 2024, 2.5.1 on 24 October 2025, 2.5.2 on 1 December 2025

Used by

Applications on Java 8 or on javax platforms (Spring Boot 2.x and earlier)

Spring Boot 3.x and 4.x: both Boot 3.5.16 and Boot 4.1.1 manage Quartz 2.5.2

Source: Quartz 2.5.0 release notes and Quartz 2.4.0 release notes, plus the jar and POM contents of 2.4.1 and 2.5.2 inspected on Maven Central artifacts.

Everything else that changed in 2.4.0 applies to both lines, and it is the part that surprises people upgrading from 2.3.x.

Table 2. What 2.4.0 changed for everyone
Change What it means for your project

Third-party libraries are provided

The POMs generated since 2.4.0 declare c3p0, HikariCP and the log4j binding with provided scope (measured in the 2.4.1 and 2.5.2 POMs; slf4j-api stays a runtime dependency, and the JAXB API is provided in 2.4.1 but runtime in 2.5.2). If Quartz manages its own connection pool (org.quartz.dataSource.*), add c3p0 or HikariCP yourself. With Spring Boot this rarely matters because Boot supplies the DataSource.

TerracottaJobStore removed

The 2.5.2 jar contains no Terracotta class. Clustering is JDBC-only (community stores aside); the Terracotta pages that remain on the Quartz website describe a feature that no longer exists.

NativeJob removed from quartz-jobs

The 2.4.0 release notes give code-execution security concerns as the reason. Measured: the 2.5.2 quartz-jobs jar has no NativeJob; NoOpJob, FileScanJob, DirectoryScanJob and the JMS, mail, EJB and JMX jobs remain.

Build moved to Gradle

Only matters if you build Quartz or its examples from source.

The 2.5.x line has one behaviour change you can observe from plain Java, in cron expressions: from 2.5.1 the L value can be combined with other values in the day-of-month field. The probe below ran against both jars.

The L probe, run on Quartz 2.4.1 and 2.5.2
for (String e : new String[] {"0 0 12 L * ?", "0 0 12 5,15,L * ?"}) {
    try {
        CronExpression c = new CronExpression(e);
        System.out.println(e + " -> valid, next: " + c.getNextValidTimeAfter(new Date()));
    } catch (Exception ex) {
        System.out.println(e + " -> invalid: " + ex.getMessage());
    }
}
Measured result of the L probe
Quartz 2.5.2:  0 0 12 5,15,L * ? -> valid
Quartz 2.4.1:  0 0 12 5,15,L * ? -> invalid: Support for specifying 'L' and 'LW' with other days of the month is not implemented
both:          0 0 12 L * ?      -> valid (last day of the month)

The full grammar is in Cron Expressions. The other differences between releases, and between Spring Boot 3.5 and 4.x, are collected in Migration & What’s Changed.

The architecture

A Scheduler is a facade over a handful of cooperating parts. Knowing them makes configuration properties, thread-dump entries and log lines readable, and it shows precisely which parts you can replace.

Component diagram. On the left, the application holds a SchedulerFactory, a JobDetail plus Trigger built with the builders, and its own Job class. In the middle a Scheduler box, a StdScheduler handle delegating to QuartzScheduler, contains the QuartzSchedulerThread that acquires and fires triggers and never runs job code, a JobFactory that builds a new Job instance per firing, a ListenerManager with listeners and plugins, and a JobRunShell per firing that runs on a worker thread and holds the new Job instance. On the right two pluggable parts: the JobStore, with RAMJobStore, JobStoreTX and JobStoreCMT, LocalDataSourceJobStore and community stores, and the ThreadPool, with SimpleThreadPool, ten threads in the bundled defaults. Arrows show the scheduler thread acquiring and firing triggers from the job store, passing each fired trigger to the thread pool with runInThread, and a worker running the JobRunShell
Table 3. The components of a Quartz scheduler
Component Type Role and default

Scheduler

Interface org.quartz.Scheduler; the object you get is a StdScheduler that delegates to org.quartz.core.QuartzScheduler

The client API: schedule, unschedule, pause, resume, listen, start and shut down. Every call goes through to the QuartzScheduler, which owns the other parts. Measured: getSchedulerClass() reports StdScheduler, while the metadata summary prints QuartzScheduler.

SchedulerFactory

Interface with StdSchedulerFactory and DirectSchedulerFactory implementations

Builds and registers schedulers. StdSchedulerFactory reads configuration from properties; DirectSchedulerFactory takes the parts as Java objects. Both register the scheduler by name in a JVM-wide repository.

QuartzSchedulerThread

org.quartz.core.QuartzSchedulerThread, one per scheduler, named <schedulerName>_QuartzSchedulerThread

The heart of the scheduler: a loop that waits for a free worker, asks the job store for the triggers due next, sleeps until the first fire time, marks them fired and hands each one to the thread pool. If it finds nothing to do it waits up to org.quartz.scheduler.idleWaitTime (default 30000 ms) unless a scheduling change wakes it. It never executes your job.

ThreadPool

Interface org.quartz.spi.ThreadPool (runInThread, blockForAvailableThreads); implementations SimpleThreadPool and ZeroSizeThreadPool

The worker threads that run jobs. The default SimpleThreadPool is configured with org.quartz.threadPool.threadCount. The class has no built-in default (an unset count is invalid); the bundled quartz.properties sets 10, and Spring Boot’s scheduler also measured 10. Worker threads are named <schedulerName>_Worker-<n>. Spring’s SchedulerFactoryBean can install its own adapter instead.

JobStore

Interface org.quartz.spi.JobStore; RAMJobStore, JobStoreTX, JobStoreCMT and, under Spring, LocalDataSourceJobStore

Keeps JobDetail, Trigger and Calendar objects, and answers the scheduler thread’s questions (acquireNextTriggers, triggersFired, triggeredJobComplete). Default: RAMJobStore, in memory. See Job Stores & Persistence.

JobFactory

Interface org.quartz.spi.JobFactory with one method, newJob(TriggerFiredBundle, Scheduler)

Creates the Job instance for each firing. The scheduler’s default is PropertySettingJobFactory, which instantiates the class through its no-argument constructor and then calls setters named after JobDataMap keys. Spring replaces it so that jobs are Spring-aware.

JobRunShell

org.quartz.core.JobRunShell, one per firing

A Runnable that wraps a single execution: it calls the listeners, invokes Job.execute, converts the outcome into a completion instruction and reports it back to the job store.

ListenerManager and plugins

JobListener, TriggerListener, SchedulerListener, SchedulerPlugin

Extension points around the same sequence. See Listeners & Plugins.

Creating a scheduler: StdSchedulerFactory

StdSchedulerFactory is what almost every application uses, and it is what Spring’s SchedulerFactoryBean uses internally. It builds the scheduler from a set of properties under the org.quartz. prefix: scheduler name and instance id, thread pool, job store, listeners and plugins. Where those properties come from depends on the constructor.

Table 4. Where StdSchedulerFactory reads its properties from
How you create it Properties used

new StdSchedulerFactory() (or StdSchedulerFactory.getDefaultScheduler())

The file named by the system property org.quartz.properties, else a quartz.properties file in the working directory, else a quartz.properties on the classpath, else the org/quartz/quartz.properties bundled inside the Quartz jar (name DefaultQuartzScheduler, SimpleThreadPool with 10 threads, RAMJobStore, misfireThreshold 60000).

new StdSchedulerFactory(Properties)

Only the properties you pass. Everything not set takes the library default, except that a SimpleThreadPool needs an explicit threadCount. Measured: with a RAMJobStore configured this way the misfireThreshold is 5000 ms, not 60000 (see Misfires).

new StdSchedulerFactory(String fileName)

The given file.

A scheduler is registered by name in a JVM-wide SchedulerRepository. Calling getScheduler() again with the same name returns the existing instance (unless it has been shut down, in which case a new one is created), and getScheduler(String) and getAllSchedulers() look schedulers up in that repository. That is also why two schedulers in one JVM need different instanceName values.

Configuring the parts with raw Quartz properties
org.quartz.scheduler.instanceName = BookshelfScheduler
org.quartz.threadPool.class = org.quartz.simpl.SimpleThreadPool
org.quartz.threadPool.threadCount = 4
org.quartz.jobStore.class = org.quartz.simpl.RAMJobStore

Wiring the parts yourself: DirectSchedulerFactory

DirectSchedulerFactory skips properties altogether: you construct the thread pool and the job store and pass them in. It is rarely the right choice (properties are easier to externalise), but it makes the architecture concrete, and it is handy in tests.

A scheduler assembled in code
DirectSchedulerFactory factory = DirectSchedulerFactory.getInstance();
factory.createScheduler("DirectBookshelf", "direct-1",
        new SimpleThreadPool(2, Thread.NORM_PRIORITY), new RAMJobStore());
Scheduler scheduler = factory.getScheduler("DirectBookshelf");

The next block is the metadata of three schedulers created in one JVM: one from the bundled defaults, one from the properties above and one from DirectSchedulerFactory. It comes from Scheduler.getMetaData(), which is also what a monitoring endpoint would read.

Measured: three schedulers in one JVM (Quartz 2.5.2)
--- StdSchedulerFactory(), bundled defaults
  name / instance : DefaultQuartzScheduler / NON_CLUSTERED
  thread pool     : org.quartz.simpl.SimpleThreadPool size 10
  job store       : org.quartz.simpl.RAMJobStore persistent=false clustered=false
  inStandby       : true, started=false
--- StdSchedulerFactory(Properties)
  name / instance : BookshelfScheduler / NON_CLUSTERED
  thread pool     : org.quartz.simpl.SimpleThreadPool size 4
--- DirectSchedulerFactory.createScheduler(...)
  name / instance : DirectBookshelf / direct-1
  thread pool     : org.quartz.simpl.SimpleThreadPool size 2
All schedulers in this JVM: [BookshelfScheduler, DefaultQuartzScheduler, DirectBookshelf]

Replacing the JobFactory

The JobFactory is the one component you will meet again on the Spring pages, so it is worth seeing in isolation. The scheduler calls it once per firing, on the scheduler thread, before the job is handed to a worker. The default PropertySettingJobFactory needs a public no-argument constructor and injects values through setters. Anything else, such as constructor injection of a collaborator, needs your own factory (or, with Spring, SpringBeanJobFactory, which Spring Boot’s auto-configuration installs for you).

A JobFactory that injects a collaborator through the constructor
public class RefreshJob implements Job {
    private final Catalogue catalogue;
    public RefreshJob(Catalogue catalogue) { this.catalogue = catalogue; }

    @Override
    public void execute(JobExecutionContext context) { catalogue.refresh(); }
}

JobFactory factory = new JobFactory() {
    private final JobFactory fallback = new SimpleJobFactory();

    @Override
    public Job newJob(TriggerFiredBundle bundle, Scheduler scheduler) throws SchedulerException {
        if (bundle.getJobDetail().getJobClass() == RefreshJob.class) {
            return new RefreshJob(catalogue);          // a new instance for every firing
        }
        return fallback.newJob(bundle, scheduler);
    }
};
scheduler.setJobFactory(factory);

Run with a scheduled RefreshJob, the factory was called, the job executed and printed its line ([catalogue] refreshed). If newJob throws, the scheduler moves every trigger of that job to the ERROR state, which needs a human to fix, so a factory should fail only when it truly cannot build the job. The same property can be set in configuration with org.quartz.scheduler.jobFactory.class. The Spring side is in Getting Started with Spring Boot.

Job, JobDetail and Trigger

Quartz separates three things that @Scheduled fuses into one annotation.

Table 5. The three types you work with
Type What it is Notes

Job

An interface with one method, void execute(JobExecutionContext context) throws JobExecutionException. Your code.

A new instance is created for every execution by the JobFactory, so instance fields do not carry state from one firing to the next. State travels in the JobDataMap (see Jobs & Job Data).

JobDetail

The definition of a job: its key, the Job class, an optional description, a JobDataMap, and the flags durability and requestsRecovery.

Created with JobBuilder. Quartz stores the JobDetail, never the Job object. A JobDetail that has no trigger is only allowed if it is durable (storeDurably()); addJob with a non-durable job fails with "Jobs added with no trigger must be durable." (measured).

Trigger

The schedule: when to fire, for which job, with which priority and optional extra data.

Created with TriggerBuilder. One job can have many triggers; every trigger points at exactly one job (forJob). The default priority is 5. The concrete types are SimpleTrigger, CronTrigger, CalendarIntervalTrigger and DailyTimeIntervalTrigger (see Triggers & Schedules).

Keys and groups

A job and a trigger are each identified by a key, which is a name plus a group. JobKey and TriggerKey are different types, so a job and a trigger may share a name (measured: jobKey("x").equals(triggerKey("x")) is false). If you leave the group out, Quartz uses the group DEFAULT, and a key prints as group.name. Groups exist for you to organise and to select: you can list, pause, resume or listen to everything in a group with a GroupMatcher. A trigger created without an identity gets a generated name in the DEFAULT group.

Keys, groups, durable jobs and several triggers for one job
JobDetail job = newJob(RemindJob.class)
        .withIdentity("memberReminder", "bookshelf")
        .usingJobData("memberId", "m-1")
        .storeDurably()
        .build();
scheduler.addJob(job, false);          // a durable job can exist without any trigger

Trigger t1 = newTrigger().withIdentity("reminder-1", "reminders").forJob(job)
        .usingJobData("memberId", "m-42")       // trigger data overrides job data
        .startNow()
        .withSchedule(simpleSchedule().withIntervalInMilliseconds(300).withRepeatCount(1))
        .build();
Trigger t2 = newTrigger().withIdentity("reminder-2")
        .forJob(jobKey("memberReminder", "bookshelf")).startNow().build();
scheduler.scheduleJob(t1);
scheduler.scheduleJob(t2);

scheduler.getJobKeys(jobGroupEquals("bookshelf"));        // [bookshelf.memberReminder]
scheduler.getTriggersOfJob(job.getKey());                  // both triggers
Measured output of the keys example (Quartz 2.5.2)
jobKey("x")                 = DEFAULT.x  group=DEFAULT
jobKey("x","bookshelf")     = bookshelf.x
same name, other type equal? false
job groups    : [bookshelf]
trigger groups: [reminders, DEFAULT]
jobs in bookshelf: [bookshelf.memberReminder]
triggers of job  : [reminders.reminder-1, DEFAULT.reminder-2]
  [job] instance @ea66c0c  reminds member m-1  (trigger DEFAULT.reminder-2)
  [job] instance @218bf60d reminds member m-42 (trigger reminders.reminder-1)
  [job] instance @2e9bd29e reminds member m-42 (trigger reminders.reminder-1)

The output shows two things beyond the keys. Each firing used a different job instance (three distinct hash codes for three firings of one JobDetail), and the member id the job saw came from the trigger when the trigger carried one (m-42) and from the job otherwise (m-1): the job receives a merged JobDataMap, with trigger values taking precedence. The RemindJob has a setMemberId setter and no code to read the map, because the default PropertySettingJobFactory calls setters named after the map keys. The merge rules and persistence are in Jobs & Job Data.

The fluent DSL

Since Quartz 2.0 you create jobs and triggers with builders designed to be used with static imports, so the code reads as a sentence.

Table 6. The builder classes
Builder Use

JobBuilder

newJob(Class), then withIdentity, withDescription, usingJobData (String, Integer, Long, Float, Double, Boolean or a whole JobDataMap), storeDurably, requestRecovery, and build() to get a JobDetail.

TriggerBuilder

newTrigger(), then withIdentity, withDescription, withPriority, startNow, startAt (Date or Instant), endAt, forJob, modifiedByCalendar, usingJobData, withSchedule(…​), and build().

SimpleScheduleBuilder, CronScheduleBuilder, CalendarIntervalScheduleBuilder, DailyTimeIntervalScheduleBuilder

Passed to withSchedule(…​) to choose the trigger type. build() then returns that type (a CronTrigger, for example), which is why the generic type of the TriggerBuilder changes after withSchedule.

DateBuilder

Static helpers for start and end times: futureDate(n, unit), evenMinuteDate, evenHourDate, nextGivenMinuteDate(date, base), todayAt, tomorrowAt, dateOf, and day-of-week and month constants.

Defining the nightly overdue-loan reminder with the DSL
import static org.quartz.CronScheduleBuilder.dailyAtHourAndMinute;
import static org.quartz.JobBuilder.newJob;
import static org.quartz.TriggerBuilder.newTrigger;

JobDetail job = newJob(OverdueLoanRemindersJob.class)
        .withIdentity("overdueLoanReminders", "bookshelf")
        .withDescription("Nightly overdue-loan reminders")
        .storeDurably()
        .build();

Trigger nightly = newTrigger()
        .withIdentity("overdueLoanRemindersTrigger", "bookshelf")
        .forJob(job)
        .withPriority(7)
        .withSchedule(dailyAtHourAndMinute(2, 0))
        .build();                                   // a CronTrigger

Date evenMinute = DateBuilder.evenMinuteDate(new Date());               // next whole minute
Date quarter = DateBuilder.nextGivenMinuteDate(new Date(), 15);         // :00, :15, :30 or :45

Measured with the base time 10:07:13: evenMinuteDate returned 10:08:00 and nextGivenMinuteDate(.., 15) returned 10:15:00; the built trigger had type CronTrigger, priority 7 and the job key bookshelf.overdueLoanReminders, and a trigger built without withPriority reported the default 5. The overdue reminder here is only a definition; making it production-grade (time zone, calendar, misfire policy) is the subject of the pages that follow. Note that dailyAtHourAndMinute uses the JVM’s default time zone unless you add inTimeZone, which matters as soon as a server moves between regions: Cron Expressions covers it.

The scheduler lifecycle

A scheduler goes through a small number of states, and several surprises come from confusing them.

Table 7. Scheduler states and the methods that move between them
Method Effect Measured state afterwards

getScheduler()

The scheduler is created and initialised in standby. Triggers can already be scheduled, but nothing fires.

isStarted=false, isInStandbyMode=true, isShutdown=false

start()

Leaves standby. The first call also starts the job store’s own processing (for the JDBC store, misfire and recovery handling) and the plugins. Later calls just resume.

isStarted=true, isInStandbyMode=false

startDelayed(int seconds)

Returns immediately and calls start() from a new thread after the delay. Useful when the jobs depend on resources that are initialised later.

Standby for the delay (measured: the call returned after 1 ms, the state was still standby, and 2.5 s later the scheduler had started)

standby()

Stops firing triggers without destroying anything. The scheduler can be started again; trigger misfire instructions are not applied by the start() call that ends standby, misfires are detected right afterwards by the job store’s normal process.

isStarted=true (it stays true), isInStandbyMode=true

shutdown() and shutdown(boolean waitForJobsToComplete)

Stops firing, releases the threads and resources. shutdown() is shutdown(false). A shut-down scheduler cannot be restarted.

isShutdown=true; start() then throws "The Scheduler cannot be restarted after shutdown() has been called." (measured)

isStarted() only reports whether start() has ever been called; it stays true through standby and even after shutdown. To ask whether jobs can fire right now, test isInStandbyMode() and isShutdown() as well. The program below walks the whole lifecycle and prints those three flags at every step.

Walking through the lifecycle (trimmed)
Scheduler s = StdSchedulerFactory.getDefaultScheduler();
state("after getScheduler()", s);
s.start();
state("after start()", s);
s.standby();
state("after standby()", s);
s.start();
s.standby();
s.startDelayed(2);                                  // returns at once
state("right after startDelayed(2)", s);
Thread.sleep(2_500);
state("2.5 s later", s);

s.scheduleJob(newJob(SlowReportJob.class).withIdentity("weeklyStatistics", "bookshelf").build(),
        newTrigger().startNow().withSchedule(simpleSchedule()).build());   // the job sleeps 2 s
Thread.sleep(500);
s.shutdown(true);                                   // waits for the running job

static void state(String label, Scheduler s) throws SchedulerException {
    System.out.printf("%-34s isStarted=%b isInStandbyMode=%b isShutdown=%b%n",
            label, s.isStarted(), s.isInStandbyMode(), s.isShutdown());
}
Measured output of the lifecycle walk (log lines removed)
after getScheduler()               isStarted=false isInStandbyMode=true isShutdown=false
after start()                      isStarted=true isInStandbyMode=false isShutdown=false
after standby()                    isStarted=true isInStandbyMode=true isShutdown=false
right after startDelayed(2)        isStarted=true isInStandbyMode=true isShutdown=false
2.5 s later                        isStarted=true isInStandbyMode=false isShutdown=false
  [job] weekly report started
  [job] weekly report finished
shutdown(true) returned after 1504 ms
after shutdown(true)               isStarted=true isInStandbyMode=true isShutdown=true
start() after shutdown: The Scheduler cannot be restarted after shutdown() has been called.

Two details of that run deserve emphasis. First, isStarted was already true on the line "right after startDelayed(2)": the earlier start() had set it, so it cannot tell you whether the delayed start has happened; isInStandbyMode did. Second, the shutdown with waitForJobsToComplete=true blocked for about 1.5 s, the time the running job still needed, and the job logged its finish before the scheduler logged "shutdown complete". The same scenario with shutdown(false) returned after 1 ms and the job was left to finish on its worker thread (it printed "finished" 2 s later), so the application could exit while the job was still running.

Table 8. Choosing the shutdown flavour
Call Behaviour

shutdown(true)

Blocks until every running job has returned. Use for graceful application shutdown. A job that never returns blocks it forever: combine it with InterruptableJob and the org.quartz.scheduler.interruptJobsOnShutdownWithWait property (see Jobs & Job Data).

shutdown(false) or shutdown()

Returns without waiting. Running jobs continue on their threads until they end, unless org.quartz.scheduler.interruptJobsOnShutdown is true (default false), in which case interruptible jobs are asked to stop.

Two practical consequences follow. The scheduler thread and the workers are non-daemon threads by default (org.quartz.scheduler.makeSchedulerThreadDaemon is false), so a plain main that forgets shutdown keeps the JVM alive. And standby() is not the same as pausing triggers: standby halts the whole scheduler and leaves every trigger state untouched, while pauseAll() or pauseTrigger marks triggers as paused. Both leave work undone while they last, which Quartz later treats as a misfire; Misfires explains how it recovers.

Under Spring Boot you rarely call these methods yourself. SchedulerFactoryBean calls start after the context is refreshed, and Boot exposes the same choices as properties. These defaults were read from the Spring Boot 4.1.1 source.

Lifecycle properties under Spring Boot 4.1.1
spring:
  quartz:
    auto-startup: true                         # default true: start() when the context is ready
    startup-delay: 0s                          # default 0s: becomes startDelayed(seconds)
    wait-for-jobs-to-complete-on-shutdown: false   # default false: shutdown(false)

Source: Spring Boot Reference: Quartz Scheduler and the application properties appendix; all spring.quartz.* properties are listed in Spring Boot Configuration.

One trigger firing, step by step

Putting the components together, here is what happens each time a trigger comes due. The names in the diagram are real methods, read from the 2.5.2 sources of QuartzSchedulerThread and JobRunShell.

sequenceDiagram participant ST as QuartzSchedulerThread participant TP as ThreadPool participant JS as JobStore participant F as JobFactory participant W as Worker running JobRunShell participant J as Job instance ST->>TP: blockForAvailableThreads() TP-->>ST: a worker is free ST->>JS: acquireNextTriggers(now + idleWaitTime, maxCount, window) JS-->>ST: triggers due soonest Note over ST: sleeps until the first fire time ST->>JS: triggersFired(triggers) JS-->>ST: TriggerFiredBundle per trigger ST->>F: newJob(bundle, scheduler) F-->>ST: a new Job instance ST->>TP: runInThread(JobRunShell) TP->>W: run() W->>W: TriggerListeners.triggerFired, JobListeners.jobToBeExecuted W->>J: execute(JobExecutionContext) J-->>W: returns or throws JobExecutionException W->>W: JobListeners.jobWasExecuted, trigger.executionComplete W->>JS: triggeredJobComplete(trigger, jobDetail, instruction) Note over ST: meanwhile the loop starts again

The steps, in words:

  1. Wait for capacity. The scheduler thread blocks until the thread pool has a free worker, so it never acquires more triggers than it can run (bounded by org.quartz.scheduler.batchTriggerAcquisitionMaxCount, default 1).

  2. Acquire. It asks the job store for the next triggers due before now + idleWaitTime. With the JDBC store this is, by default, a conditional update of each trigger’s state (WAITING to ACQUIRED) without a row lock; the TRIGGER_ACCESS row lock is taken here only with acquireTriggersWithinLock=true or a batch size above 1, and otherwise at the Fire and Complete steps; see Clustering.

  3. Sleep until the fire time. If a new, earlier trigger is scheduled in the meantime, the thread wakes up, releases what it acquired and starts again.

  4. Fire. The job store marks each trigger as fired, computes its next fire time and returns a TriggerFiredBundle. A trigger that was paused, or whose job must not run concurrently, comes back without a bundle and is released.

  5. Create the job. The JobFactory builds a new Job instance, and a JobRunShell is created for it. If that fails, the job’s triggers go to the ERROR state.

  6. Execute on a worker. The shell runs on a pool thread. A TriggerListener can veto the execution, then the job listeners are notified and execute is called.

  7. Complete. The outcome (including a thrown JobExecutionException or any other Throwable, which Quartz logs and converts) becomes a completion instruction for the trigger and the job store, and the listeners are notified again.

The scheduler thread and the worker never share a stack: a slow job occupies one worker and nothing else, which is why thread-pool size is the first scaling dial and why threadCount should exceed the number of jobs that can be due at once.

A first example in plain Java

The first example is the smallest complete Quartz program, modelled on Example 1 of the official examples module (SimpleExample and HelloJob, which schedule a job for the next even minute and shut down after 65 seconds). The Bookshelf version below refreshes the book catalogue. It starts a few seconds after launch, repeats every 2 seconds three times, and shuts down so that the demo ends quickly; the five real Bookshelf jobs follow on the next pages.

Dependencies for a plain-Java project (Maven)
<dependency>
    <groupId>org.quartz-scheduler</groupId>
    <artifactId>quartz</artifactId>
    <version>2.5.2</version>
</dependency>
<dependency>                                   <!-- any SLF4J binding; Quartz logs through SLF4J -->
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-simple</artifactId>
    <version>2.0.16</version>
</dependency>
The Bookshelf catalogue refresh in plain Java
import static org.quartz.JobBuilder.newJob;
import static org.quartz.SimpleScheduleBuilder.simpleSchedule;
import static org.quartz.TriggerBuilder.newTrigger;

public class FirstExample {

    public static class CatalogueRefreshJob implements Job {
        private static final Logger log = LoggerFactory.getLogger(CatalogueRefreshJob.class);

        @Override
        public void execute(JobExecutionContext context) {
            log.info("Refreshing the catalogue (job {}, fired at {}, thread {})",
                    context.getJobDetail().getKey(), context.getFireTime(),
                    Thread.currentThread().getName());
        }
    }

    public static void main(String[] args) throws Exception {
        Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();   // 1. create (in standby)

        JobDetail job = newJob(CatalogueRefreshJob.class)                   // 2. what to run
                .withIdentity("catalogueRefresh", "bookshelf")
                .build();

        Trigger trigger = newTrigger()                                      // 3. when to run it
                .withIdentity("catalogueRefreshTrigger", "bookshelf")
                .startAt(DateBuilder.futureDate(1, DateBuilder.IntervalUnit.SECOND))
                .withSchedule(simpleSchedule().withIntervalInSeconds(2).withRepeatCount(2))
                .build();

        scheduler.scheduleJob(job, trigger);                                // 4. register both
        scheduler.start();                                                  // 5. leave standby

        Thread.sleep(7_000);                                                // the JVM keeps working
        scheduler.shutdown(true);                                           // 6. wait and release
    }
}

withRepeatCount(2) means two repeats after the first firing, so the job fires three times. The log below is the measured output with the Quartz INFO lines trimmed to the ones that show the sequence.

Measured output of the first example (Quartz 2.5.2)
INFO org.quartz.core.QuartzScheduler - Quartz Scheduler v2.5.2 created.
INFO org.quartz.simpl.RAMJobStore - RAMJobStore initialized.
  Using thread pool 'org.quartz.simpl.SimpleThreadPool' - with 10 threads.
  Using job-store 'org.quartz.simpl.RAMJobStore' - which does not support persistence. and is not clustered.
INFO org.quartz.impl.StdSchedulerFactory - Quartz scheduler 'DefaultQuartzScheduler' initialized from default resource file in Quartz package: 'quartz.properties'
INFO org.quartz.core.QuartzScheduler - Scheduler DefaultQuartzScheduler_$_NON_CLUSTERED started.
[DefaultQuartzScheduler_Worker-1] Refreshing the catalogue (job bookshelf.catalogueRefresh, fired at Mon Oct 05 11:26:26 CEST 2026, ...)
[DefaultQuartzScheduler_Worker-2] Refreshing the catalogue (job bookshelf.catalogueRefresh, fired at Mon Oct 05 11:26:28 CEST 2026, ...)
[DefaultQuartzScheduler_Worker-3] Refreshing the catalogue (job bookshelf.catalogueRefresh, fired at Mon Oct 05 11:26:30 CEST 2026, ...)
INFO org.quartz.core.QuartzScheduler - Scheduler DefaultQuartzScheduler_$_NON_CLUSTERED shutting down.
INFO org.quartz.core.QuartzScheduler - Scheduler DefaultQuartzScheduler_$_NON_CLUSTERED shutdown complete.

Read the output against the architecture: the three executions ran on three different worker threads of the default 10-thread SimpleThreadPool (each firing is handed to whichever worker is free), the scheduler name is DefaultQuartzScheduler because no configuration was supplied, and the job store is the in-memory RAMJobStore, so nothing about this scheduler would survive a restart. The Bookshelf job that is genuinely a plain @Scheduled task, this five-minute catalogue refresh, does not need Quartz in production; it is used here only because it is the simplest job that shows every part.

Where the Bookshelf jobs fit

Each of the five scheduled tasks of the running scenario exercises a different part of the model introduced here.

Table 9. The Bookshelf jobs and the fundamentals they rely on
Bookshelf job Quartz concepts it needs Where it is developed

Catalogue refresh (every 5 minutes)

A Job, a JobDetail, a repeating SimpleTrigger. Standard @Scheduled is enough.

This page; Standard Scheduling vs Quartz

Nightly overdue-loan reminders

A durable JobDetail, a CronTrigger with a time zone and a holiday calendar, a misfire policy

Getting Started with Spring Boot, Calendars, Misfires

Per-member reminders

Jobs and one-shot triggers created at runtime, a JobDataMap, persistence

Jobs & Job Data, Dynamic Scheduling & Management

Weekly statistics report

@DisallowConcurrentExecution, requestsRecovery, a JDBC job store, a cluster

Clustering

Monthly retention cleanup

An L/LW cron expression, a calendar, launching a batch job

Cron Expressions

Pitfalls

Table 10. Common mistakes with the fundamentals
Symptom Cause and fix

Triggers are scheduled but nothing ever fires

The scheduler is created in standby and needs start(). Check isInStandbyMode(). Under Spring Boot check spring.quartz.auto-startup and spring.quartz.startup-delay.

The program never exits

The scheduler thread and workers are non-daemon. Call shutdown, or let the Spring context do it.

SchedulerException: The Scheduler cannot be restarted after shutdown() has been called.

shutdown is final. Create a new scheduler (with the same name this is allowed once the old one is shut down) instead of calling start() again.

Jobs added with no trigger must be durable.

addJob of a job that has no trigger needs storeDurably(). Or schedule it with a trigger via scheduleJob(job, trigger).

A job field keeps its value only within one firing

A new Job instance is created for every execution. Keep state in the JobDataMap (with @PersistJobDataAfterExecution) or in an external store.

Settings in quartz.properties seem to be ignored under Spring Boot

Spring does not read the classpath file by itself: use spring.quartz.properties.* or a customizer. See Spring Boot Configuration.

isStarted() is true but jobs do not run

isStarted() reports only that start() was called at some point. It remains true during standby and after shutdown. Test isInStandbyMode() and isShutdown() too.

shutdown(false) lets the application exit while a job is still writing

Use shutdown(true) (Spring: wait-for-jobs-to-complete-on-shutdown: true) and make long jobs interruptible.

A cron expression of five fields fails or L,5 is rejected

Quartz cron has six or seven fields, and combining L with other day-of-month values needs Quartz 2.5.1 or later (measured above). See Cron Expressions.

What changed

Table 11. What changed in the fundamentals
Release Change that touches this page

Quartz 2.0

The JobBuilder/TriggerBuilder DSL and JobKey/TriggerKey; StatefulJob is replaced by @DisallowConcurrentExecution and @PersistJobDataAfterExecution (the interface still exists in 2.5.2, deprecated).

Quartz 2.4.0

Third-party libraries are provided; TerracottaJobStore and NativeJob removed; minimum Java 8 retained.

Quartz 2.5.0

Move to the Jakarta namespace and to Java 11.

Quartz 2.5.1

L can be combined with other day-of-month values.

Quartz 2.5.2

Latest release (1 December 2025); managed by both Spring Boot 3.5.16 and 4.1.1.

Spring Boot 3.5 to 4.x

Nothing on this page changes. The auto-configuration moved packages (see Migration & What’s Changed).