cronuru
Guide

Java Quartz Scheduler — A Complete Guide

Quartz is Java's enterprise-grade scheduler — used across the JVM ecosystem by Apache Camel, Talend, Camunda, and Spring's Quartz integration. This guide covers Quartz beyond the dialect page: setting up the Scheduler, the difference between SimpleTrigger and CronTrigger, JobDataMap for parameterizing jobs, JDBC job stores for persistence, clustering, and the Spring Boot integration.

Updated

The Quartz model

Quartz separates what to run from when to run it:

  • Job — a class implementing org.quartz.Job with one method, execute(JobExecutionContext). Defines what work to do.
  • JobDetail — an instance of a Job with associated configuration (durability, requests recovery, JobDataMap).
  • Trigger — defines when the Job fires. Two main types: SimpleTrigger (fixed start + interval) and CronTrigger (cron expression).
  • Scheduler — the runtime that manages JobDetails and Triggers, fires Jobs at the right time.

The “Hello world” of Quartz:

JobDetail job = JobBuilder.newJob(MyJob.class)
    .withIdentity("myJob", "group1")
    .build();

Trigger trigger = TriggerBuilder.newTrigger()
    .withIdentity("myTrigger", "group1")
    .withSchedule(CronScheduleBuilder.cronSchedule("0 */5 * * * ?"))
    .build();

Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();
scheduler.start();
scheduler.scheduleJob(job, trigger);

This conceptual separation matters in practice: one Job class can be triggered by many different Triggers, and one Trigger fires exactly one JobDetail. Parameterize with JobDataMap (covered below) to keep Job classes generic.

Setting up the Scheduler

First, add Quartz to your project. The current stable release is 2.5.2 (December 2025):

<dependency>
    <groupId>org.quartz-scheduler</groupId>
    <artifactId>quartz</artifactId>
    <version>2.5.2</version>
</dependency>
// Gradle
implementation 'org.quartz-scheduler:quartz:2.5.2'

Spring Boot users should skip the raw dependency and use spring-boot-starter-quartz instead (see Spring Boot integration below) — it pulls in a Boot-managed Quartz version for you.

Quartz reads configuration from quartz.properties on the classpath (or programmatically via Properties + StdSchedulerFactory).

Minimal quartz.properties for in-memory operation:

org.quartz.scheduler.instanceName = MyScheduler
org.quartz.scheduler.instanceId = AUTO

org.quartz.threadPool.class = org.quartz.simpl.SimpleThreadPool
org.quartz.threadPool.threadCount = 10

org.quartz.jobStore.class = org.quartz.simpl.RAMJobStore

For JDBC-backed (production):

org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX
org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.StdJDBCDelegate
org.quartz.jobStore.dataSource = myDS
org.quartz.jobStore.tablePrefix = QRTZ_

org.quartz.dataSource.myDS.driver = org.postgresql.Driver
org.quartz.dataSource.myDS.URL = jdbc:postgresql://localhost/myapp
org.quartz.dataSource.myDS.user = myuser
org.quartz.dataSource.myDS.password = mypass
org.quartz.dataSource.myDS.maxConnections = 5

The Quartz table schema lives in org.quartz.impl.jdbcjobstore.tables_*.sql for each supported database. Run the appropriate one before starting the scheduler.

Jobs and Triggers

A Quartz Job is a class with one method:

public class DailyReportJob implements Job {
    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        // ... do work
    }
}

Two main Trigger types:

SimpleTrigger — fixed start + interval:

SimpleTrigger every5Min = TriggerBuilder.newTrigger()
    .withIdentity("trigger1", "group1")
    .startNow()
    .withSchedule(SimpleScheduleBuilder.simpleSchedule()
        .withIntervalInMinutes(5)
        .repeatForever())
    .build();

CronTrigger — cron expression:

CronTrigger weekdayMornings = TriggerBuilder.newTrigger()
    .withIdentity("trigger2", "group1")
    .withSchedule(CronScheduleBuilder.cronSchedule("0 0 9 ? * MON-FRI")
        .inTimeZone(TimeZone.getTimeZone("America/New_York")))
    .build();

The cron syntax here is Quartz’s — see the Quartz dialect reference. Note the mandatory ? placeholder when day-of-month and day-of-week would otherwise both be specific.

Schedule a job with a trigger:

scheduler.scheduleJob(jobDetail, trigger);

A JobDetail can have multiple Triggers — fire the same job at different times:

Set<Trigger> triggers = new HashSet<>();
triggers.add(trigger1);
triggers.add(trigger2);
scheduler.scheduleJob(jobDetail, triggers, true);  // replace = true

JobDataMap

JobDataMap is how you parameterize Jobs. It’s a serializable Map<String, Object> attached to either the JobDetail or the Trigger (or both — they merge).

JobDetail job = JobBuilder.newJob(SendEmailJob.class)
    .withIdentity("sendEmail", "group1")
    .usingJobData("recipient", "team@example.com")
    .usingJobData("subject", "Daily report")
    .build();

// Or per-trigger
Trigger triggerForCustomerA = TriggerBuilder.newTrigger()
    .withSchedule(CronScheduleBuilder.cronSchedule("0 0 9 * * ?"))
    .usingJobData("customerId", "A123")
    .build();

Inside the Job:

public class SendEmailJob implements Job {
    public void execute(JobExecutionContext context) {
        JobDataMap data = context.getMergedJobDataMap();
        String recipient = data.getString("recipient");
        String customerId = data.getString("customerId");
        // ...
    }
}

Use JobDataMap to keep Job classes generic and reusable. One SendEmailJob class with many JobDetail + Trigger combos beats one Job class per email type.

JDBC job stores

The default RAMJobStore is simplest but loses everything on restart. For production, use JobStoreTX (or JobStoreCMT if your container manages transactions).

Setup:

  1. Run the Quartz table schema for your database (PostgreSQL, MySQL, Oracle, SQL Server, etc.). The SQL scripts ship with Quartz under org/quartz/impl/jdbcjobstore/.
  2. Configure quartz.properties with jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX.
  3. Configure a dataSource Quartz can use.

Once on JDBCJobStore:

  • Scheduled jobs and triggers persist across restarts
  • Job execution history is queryable (via Quartz’s history extensions or your own queries on the QRTZ_ tables)
  • You can change schedules at runtime and they survive restart
  • You can enable clustering

The Quartz table footprint is ~11 tables (QRTZ_JOB_DETAILS, QRTZ_TRIGGERS, QRTZ_CRON_TRIGGERS, etc.). They’re managed by Quartz — don’t write to them directly.

Clustering

Run multiple Quartz Scheduler instances against the same JDBCJobStore database. Each instance gets a unique instanceId; they coordinate via row-level locks on the QRTZ_ tables.

org.quartz.scheduler.instanceId = AUTO
org.quartz.jobStore.isClustered = true
org.quartz.jobStore.clusterCheckinInterval = 20000

What clustering gives you:

  • Failover — if one instance dies mid-job, another instance recovers and re-runs (if requestRecovery = true on the JobDetail)
  • Load balancing — triggered jobs go to whichever instance picks them up first
  • No single point of failure for the scheduler itself

Caveats:

  • All instances must use the same database
  • Job classes must be available on every instance
  • Clock skew between instances can cause subtle timing issues — keep them NTP-synced
  • RAMJobStore doesn’t support clustering — JDBC only

For multi-region deployments, run separate Quartz clusters per region and partition your schedules accordingly.

Spring Boot integration

Spring Boot has a Quartz auto-configuration. Add the starter:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

Configure in application.yml:

spring:
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: never   # see the warning below
    properties:
      org:
        quartz:
          scheduler.instanceId: AUTO
          jobStore.isClustered: true

Don’t use initialize-schema: always in production. Quartz’s bundled DDL scripts begin with DROP TABLE, so always re-runs them on every startup and wipes all persisted jobs and triggers — exactly what the JDBC store and clustering are meant to protect. Use never and create the schema out-of-band (a migration tool like Flyway/Liquibase, or run the tables_*.sql script once). embedded is fine for throwaway in-memory databases only.

Define jobs as Spring beans:

@Component
public class DailyReportJob extends QuartzJobBean {
    @Autowired private ReportService reportService;

    @Override
    protected void executeInternal(JobExecutionContext context) {
        reportService.generateDaily();
    }
}

Then declare the JobDetail + Trigger as Spring beans:

@Configuration
public class QuartzConfig {
    @Bean
    public JobDetail dailyReportJobDetail() {
        return JobBuilder.newJob(DailyReportJob.class)
            .withIdentity("dailyReport")
            .storeDurably()
            .build();
    }

    @Bean
    public Trigger dailyReportTrigger(JobDetail dailyReportJobDetail) {
        return TriggerBuilder.newTrigger()
            .forJob(dailyReportJobDetail)
            .withSchedule(CronScheduleBuilder.cronSchedule("0 0 9 ? * MON-FRI"))
            .build();
    }
}

This integration is the cleanest path for Spring Boot apps that want Quartz — Spring handles the DataSource, configures Quartz to use Spring’s bean factory for job instantiation (so @Autowired works in Jobs), and initializes the schema on startup.

For simpler scheduling that doesn’t need Quartz’s features, see Spring @Scheduled instead.

Misfire handling

A misfire happens when a Trigger should have fired but didn’t (scheduler down, thread pool exhausted, JDBCJobStore lock contention). Quartz has policies for what to do.

For CronTrigger:

CronScheduleBuilder.cronSchedule("0 0 9 ? * MON-FRI")
    .withMisfireHandlingInstructionDoNothing();         // skip the missed fire
    // or
    .withMisfireHandlingInstructionFireAndProceed();    // fire once immediately, then resume schedule
    // or
    .withMisfireHandlingInstructionIgnoreMisfires();    // fire all missed (potentially many) ASAP

For SimpleTrigger:

SimpleScheduleBuilder.simpleSchedule()
    .withIntervalInMinutes(5)
    .repeatForever()
    .withMisfireHandlingInstructionNextWithRemainingCount();
    // or NextWithExistingCount, NowWithExistingCount, NowWithRemainingCount,
    //    IgnoreMisfires, FireNow

The defaults are usually wrong for production:

  • For daily jobs, you want DoNothing (don’t fire 10 hours late)
  • For high-frequency jobs, you want IgnoreMisfires or NowWithExistingCount (don’t queue up hundreds of missed fires)

Set the misfire policy explicitly on every Trigger. The miss-threshold itself is org.quartz.jobStore.misfireThreshold (default 60000ms = 60 seconds — Triggers more than 60s late are considered “misfired”).

Frequently asked questions

When should I use Quartz instead of Spring @Scheduled?
Use @Scheduled for simple in-process scheduling where you don't need persistence, distribution, or advanced trigger features. Use Quartz when you need any of: durable jobs that survive restarts, multi-instance coordination (clustering), JobDataMap to parameterize jobs, calendar exclusions (skip holidays/weekends), misfire handling policies, or the Quartz cron extensions (`L`, `W`, `#`).
What's the difference between SimpleTrigger and CronTrigger?
SimpleTrigger fires at a specific start time, optionally repeating at a fixed interval (`repeatInterval`). Use it for 'fire once at this time' or 'every N milliseconds starting now'. CronTrigger fires according to a cron expression — much more flexible for calendar-aligned schedules like 'every weekday at 9 AM'.
How does JobDataMap work?
JobDataMap is a serializable Map attached to a JobDetail. It's passed into your Job's execute() method via context.getMergedJobDataMap(). Use it to parameterize jobs — the same Job class can fire with different inputs by binding different JobDataMaps to different triggers.
Do I need a database for Quartz?
No. Quartz's default RAMJobStore keeps everything in memory — simplest setup but loses scheduled jobs on restart. For production, you usually want JDBCJobStore (with any JDBC database) which persists jobs and triggers to tables Quartz creates. Once you're on JDBCJobStore, you can also enable clustering.
How does Quartz clustering work?
Multiple Quartz Scheduler instances point at the same JDBCJobStore database. They coordinate via row-level locking on the Quartz tables — a triggered job is claimed by one instance and others skip it. Set `org.quartz.jobStore.isClustered = true` to enable. Each instance must have a unique `instanceId`.