Calendars

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.

A Quartz calendar answers one question for a trigger: "may you fire at this instant?". It does not create fire times, it removes them. A trigger that is modified by a calendar computes its next candidate fire time as usual, asks the calendar whether that instant is included, and, when it is not, moves on to the following candidate. This page covers the six calendar classes that ship with Quartz, how to chain them, how to register them — programmatically with addCalendar or as Spring beans that Spring Boot collects for you — how a trigger refers to one with modifiedByCalendar, and the Bookshelf business-day calendar that the nightly overdue-loan reminders and the monthly retention cleanup use. The trigger types and the builder methods are in Triggers and Schedules; what happens to a fire time that is skipped because of an outage rather than a calendar is in Misfires; the cron syntax used by CronCalendar is in Cron Expressions.

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 behaviour below was run in a test project (the results are quoted, not assumed), and the class and method names were checked against the Quartz 2.5.2 and Spring sources. On Spring Boot 3.5.16 (Spring Framework 6.2.19) the Quartz version is the same Quartz 2.5.2 and the calendar classes are identical; only the Spring Boot package of the Quartz auto-configuration differs (org.springframework.boot.autoconfigure.quartz instead of org.springframework.boot.quartz.autoconfigure), and none of the code on this page imports it.

What a calendar is, and what it is not

org.quartz.Calendar is an interface with a handful of methods: isTimeIncluded(long), getNextIncludedTime(long), getBaseCalendar() / setBaseCalendar(Calendar), getDescription() / setDescription(String) and clone(). It extends java.io.Serializable, which matters because a JDBC job store keeps calendars in the QRTZ_CALENDARS table as serialized objects, so any custom implementation must be serializable and keep serializing across versions.

It has nothing to do with java.util.Calendar, apart from the name. That clash is real: a class that imports both org.quartz. and java.util. does not compile where it says Calendar ("reference to Calendar is ambiguous", measured), and the Quartz calendars take java.util.Calendar in some methods (AnnualCalendar.setDayExcluded) while implementing org.quartz.Calendar themselves.

Table 1. org.quartz.Calendar against java.util.Calendar
org.quartz.Calendar java.util.Calendar

Purpose

A set of included and excluded instants that a trigger consults

A date and time field calculator (legacy, replaced by java.time)

Question it answers

"Is this instant allowed to fire?" (isTimeIncluded) and "when is the next allowed instant?" (getNextIncludedTime)

"What is the year, month, day, hour of this instant?"

Time representation

long epoch milliseconds

A mutable object holding an instant and a time zone

Composition

Chainable: every calendar has an optional base calendar

Not applicable

Persistence

Serializable, stored by name in the scheduler (QRTZ_CALENDARS on JDBC)

Not related

The simplest use of the interface is a calendar that excludes a few days, registered with the scheduler and referenced by name from a trigger. The rest of the page builds on this shape.

The shape of every calendar example: register by name, reference by name
Scheduler scheduler = ...;                       // any started or standby scheduler

HolidayCalendar holidays = new HolidayCalendar();
holidays.setTimeZone(TimeZone.getTimeZone("Europe/Madrid"));
holidays.addExcludedDate(Date.from(LocalDate.parse("2026-12-25")
        .atStartOfDay(ZoneId.of("Europe/Madrid")).toInstant()));


scheduler.addCalendar("holidays", holidays, false, false);              // name, calendar, replace, updateTriggers

Trigger trigger = TriggerBuilder.newTrigger()
        .withIdentity("overdueLoanRemindersTrigger", "bookshelf")
        .forJob("overdueLoanReminders", "bookshelf")
        .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?"))
        .modifiedByCalendar("holidays")                                 // look the calendar up by name
        .build();

How a trigger uses a calendar

A trigger stores only the calendar’s name (Trigger.getCalendarName(), null when there is none). The scheduler resolves the name to the registered calendar object each time it computes a fire time, so replacing the calendar under the same name changes every trigger that refers to it. When a trigger fires, it advances to the next fire time and, while that time is not included by the calendar, advances again. The calendar therefore skips occurrences. It never shifts them to another day, a point that decides how the Bookshelf retention cleanup is built further down.

flowchart TD A[Trigger computes the next candidate fire time] --> B{Trigger has a calendar name?} B -->|no| F([Fire at the candidate time]) B -->|yes| C[Scheduler looks the calendar up by name] C --> D{Does the calendar and every base calendar in its chain include the time?} D -->|yes| F D -->|no| G[Skip it: take the next candidate after the excluded time] G --> B
Figure 1. How a calendar-modified trigger picks its next fire time

The same calendar logic is applied when a misfired trigger is updated (the updateAfterMisfire(Calendar) method of the trigger implementations receives the calendar), so a trigger that misfires still lands on an included time. The misfire instructions themselves are in Misfires.

The six calendars that ship with Quartz

All six live in org.quartz.impl.calendar and extend BaseCalendar, which holds the optional base calendar, the description and the time zone. Four facts hold for every one of them:

  • Each calendar excludes something. The "included" set is everything else.

  • The time zone defaults to the JVM default (TimeZone.getDefault()) when none is set, so a calendar built on a laptop in one zone and run on a server in UTC excludes different instants. Always set the zone explicitly; the Bookshelf uses Europe/Madrid.

  • The base calendar is consulted first: a time that the base calendar excludes is excluded, whatever the outer calendar says.

  • getNextIncludedTime(t) returns t itself when t is included, otherwise the first included instant after it.

Table 2. What each calendar excludes, with its default (measured on Quartz 2.5.2, Europe/Madrid)
Class Excludes Default Granularity

HolidayCalendar

Specific dates, added with addExcludedDate(Date)

Nothing; getNextIncludedTime of an excluded day is 00:00 of the next included day

Whole days (the date is normalized to the start of the day in the calendar’s zone)

AnnualCalendar

The same month and day every year, added with setDayExcluded(java.util.Calendar, true)

Nothing; the year of the supplied java.util.Calendar is ignored

Whole days

WeeklyCalendar

Days of the week; the index is the java.util.Calendar constant (SUNDAY is 1 …​ SATURDAY is 7)

Saturday and Sunday, with the no-argument, base-calendar and (base calendar, time zone) constructors; nothing with the WeeklyCalendar(TimeZone) constructor

Whole days

MonthlyCalendar

Days of the month 1 to 31, with setDayExcluded(int, boolean)

Nothing

Whole days

DailyCalendar

A time range inside each day, "HH:MM[:SS[:mmm]]", start strictly before end, so it cannot cross midnight

The range is excluded; setInvertTimeRange(true) makes the range the only included time

Milliseconds

CronCalendar

Every instant that matches a Quartz cron expression, in the expression’s time zone

The matched instants are excluded

Seconds (the expression is evaluated to the second)

HolidayCalendar

HolidayCalendar is the calendar for dates that happen once: a public holiday in one particular year, a library closed for renovation. addExcludedDate keeps one entry per day, removeExcludedDate takes it back and getExcludedDates returns an unmodifiable sorted set.

HolidayCalendar for one-off dates
HolidayCalendar holidays = new HolidayCalendar();
holidays.setTimeZone(TimeZone.getTimeZone("Europe/Madrid"));
holidays.addExcludedDate(Date.from(LocalDate.parse("2026-12-25")
        .atStartOfDay(ZoneId.of("Europe/Madrid")).toInstant()));

long christmasMorning = ZonedDateTime.of(2026, 12, 25, 10, 0, 0, 0, ZoneId.of("Europe/Madrid"))
        .toInstant().toEpochMilli();

holidays.isTimeIncluded(christmasMorning);       // false
holidays.getNextIncludedTime(christmasMorning);  // 2026-12-26T00:00+01:00 (Europe/Madrid)
// the same date next year is not excluded: isTimeIncluded(2027-12-25T10:00) is true

AnnualCalendar

AnnualCalendar is the calendar for days that repeat on the same date every year: 1 January, 25 December, 1 May. It takes a java.util.Calendar per excluded day and uses only its month and day, so the year you put in the object is irrelevant (the official Example 8 below uses 2005, and still excludes the day in every other year).

AnnualCalendar for recurring dates
AnnualCalendar recurring = new AnnualCalendar(TimeZone.getTimeZone("Europe/Madrid"));

GregorianCalendar christmas = new GregorianCalendar(TimeZone.getTimeZone("Europe/Madrid"));
christmas.clear();
christmas.set(2000, java.util.Calendar.DECEMBER, 25);          // the year is ignored
recurring.setDayExcluded(christmas, true);

recurring.isTimeIncluded(at("2027-12-25T10:00"));   // false: every year
recurring.isTimeIncluded(at("2027-12-26T10:00"));   // true
recurring.getDaysExcluded().size();                 // 1

One trap belongs to this class. setDayExcluded(day, true) first asks isDayExcluded(day), which includes the base calendar. If the java.util.Calendar you pass is a day that the base calendar excludes, the call concludes "already excluded" and silently adds nothing. With a weekend WeeklyCalendar as the base and the reference year 2000, 1 January (a Saturday in 2000) is dropped while 25 December (a Monday in 2000) is kept: getDaysExcluded().size() is 1 after adding both, and 1 January 2027 is then included (measured). Build the AnnualCalendar without a base calendar, add its days, and make it the innermost link of the chain, as the Bookshelf calendar below does.

AnnualCalendar next to a base calendar: the days that are silently ignored
AnnualCalendar withBase = new AnnualCalendar(new WeeklyCalendar(null, madrid), madrid);
withBase.setDayExcluded(january1Of2000, true);       // Saturday in 2000: already excluded by the base, NOT added
withBase.setDayExcluded(december25Of2000, true);     // Monday in 2000: added
withBase.getDaysExcluded().size();                   // 1, not 2
withBase.isTimeIncluded(at("2027-01-01T10:00"));     // true: Friday 1 January 2027 is not excluded

AnnualCalendar innermost = new AnnualCalendar(madrid);        // no base while adding days
innermost.setDayExcluded(january1Of2000, true);
innermost.getDaysExcluded().size();                           // 1, and 2027-01-01 is excluded

WeeklyCalendar

WeeklyCalendar excludes days of the week, and by default it excludes the weekend. The index into the day array is the java.util.Calendar constant, not the ISO day number and not the Quartz cron day number (which is a separate convention in Cron Expressions): SUNDAY is 1, MONDAY is 2, …​ SATURDAY is 7.

The constructors are not consistent, and the difference is easy to miss. With the no-argument constructor, with the base-calendar constructor and with the (base calendar, time zone) constructor, Saturday and Sunday are excluded. With new WeeklyCalendar(TimeZone) nothing is excluded, although the class Javadoc says that the default excludes the weekend (measured: Saturday excluded is true, true, true and false respectively). To get the documented default and an explicit zone, use new WeeklyCalendar(null, zone) or call setTimeZone on the no-argument instance, and set the days you need explicitly when it matters.

WeeklyCalendar: the constructor pitfall and changing the excluded days
TimeZone madrid = TimeZone.getTimeZone("Europe/Madrid");

new WeeklyCalendar().isDayExcluded(java.util.Calendar.SATURDAY);              // true
new WeeklyCalendar(null, madrid).isDayExcluded(java.util.Calendar.SATURDAY);  // true
new WeeklyCalendar(madrid).isDayExcluded(java.util.Calendar.SATURDAY);        // false: nothing excluded

WeeklyCalendar weekdaysOnly = new WeeklyCalendar(null, madrid);
weekdaysOnly.isTimeIncluded(at("2026-10-10T10:00"));          // false: Saturday
weekdaysOnly.getNextIncludedTime(at("2026-10-10T10:00"));     // 2026-10-12T00:00+02:00: Monday

weekdaysOnly.setDayExcluded(java.util.Calendar.FRIDAY, true); // a four-day week
weekdaysOnly.isTimeIncluded(at("2026-10-09T10:00"));          // false: Friday

MonthlyCalendar

MonthlyCalendar excludes days of the month, numbered 1 to 31 (not zero-based for the methods; the array it exposes with getDaysExcluded is zero-based). isDayExcluded and setDayExcluded reject a day outside 1 to 31 with an IllegalArgumentException ("The day parameter must be in the range of 1 to 31", measured). Nothing is excluded by default, so a MonthlyCalendar you only construct changes nothing.

MonthlyCalendar: skip the 1st and the 15th of every month
MonthlyCalendar paydays = new MonthlyCalendar(TimeZone.getTimeZone("Europe/Madrid"));
paydays.setDayExcluded(1, true);
paydays.setDayExcluded(15, true);

paydays.isTimeIncluded(at("2026-11-01T03:00"));          // false
paydays.getNextIncludedTime(at("2026-11-01T03:00"));     // 2026-11-02T00:00+01:00
paydays.isDayExcluded(32);                               // IllegalArgumentException

DailyCalendar

DailyCalendar works inside each day. The constructor takes a start and an end time as strings in the form HH:MM, with optional seconds and milliseconds, and requires the start to be before the end: a range that crosses midnight is rejected with an IllegalArgumentException ("Invalid time range", measured for 22:00 to 02:00). By default the range is excluded, which is the opposite of what the name suggests to many readers. With setInvertTimeRange(true) the range becomes the only included time. To keep jobs out of an overnight window, either exclude two ranges by chaining two DailyCalendar instances (for example 00:00 to 06:00 and 22:00 to 23:59:59.999) or use a CronCalendar.

DailyCalendar: exclude a maintenance window, then invert it
DailyCalendar maintenance = new DailyCalendar("02:00", "04:00");
maintenance.setTimeZone(TimeZone.getTimeZone("Europe/Madrid"));

maintenance.isTimeIncluded(at("2026-10-05T03:00"));       // false: inside the excluded range
maintenance.isTimeIncluded(at("2026-10-05T05:00"));       // true
maintenance.getNextIncludedTime(at("2026-10-05T03:00"));  // 2026-10-05T04:00:00.001+02:00

maintenance.setInvertTimeRange(true);                     // now only 02:00-04:00 is included
maintenance.isTimeIncluded(at("2026-10-05T03:00"));       // true
maintenance.isTimeIncluded(at("2026-10-05T05:00"));       // false

CronCalendar

CronCalendar excludes every instant that matches a Quartz cron expression, evaluated in the zone passed to the constructor (or later with setTimeZone, which it forwards to the expression). It is the most flexible calendar and the easiest to get wrong because the expression is matched to the second: the expression 0 0 0-6 * * ? matches only the instants 00:00:00 to 06:00:00 on the hour, so 03:00:00 is excluded and 03:00:30 is included (measured). To exclude a whole hour range use in the seconds and minutes fields: * 0-6 * * ? excludes 00:00:00 to 06:59:59, and the next included time from 03:00:30 is 07:00:00 (measured). The expression follows the Quartz rules, so setting both the day-of-month and the day-of-week field is rejected with a ParseException ("Support for specifying both a day-of-week AND a day-of-month parameter is not implemented", measured).

CronCalendar: exclude nights and weekends
TimeZone madrid = TimeZone.getTimeZone("Europe/Madrid");

CronCalendar nights = new CronCalendar(null, "* * 0-6 * * ?", madrid);       // 00:00:00 .. 06:59:59
nights.isTimeIncluded(at("2026-10-05T03:00:30"));       // false
nights.isTimeIncluded(at("2026-10-05T07:00:00"));       // true

CronCalendar weekends = new CronCalendar(null, "* * * ? * SAT,SUN", madrid); // note the ? in day-of-month
weekends.isTimeIncluded(at("2026-10-10T10:00"));        // false: Saturday
weekends.isTimeIncluded(at("2026-10-05T10:00"));        // true: Monday

Chaining calendars

Calendars compose through the base calendar. Every BaseCalendar subclass accepts a base calendar in its constructor, and setBaseCalendar sets it later. A time is included only when the outer calendar and every calendar below it include it, so the chain is an intersection of "allowed" sets: it excludes the union of what each link excludes. Only the outermost calendar is registered with the scheduler (the base calendars travel inside it when it is serialized).

Chaining a holiday calendar on a weekly calendar
WeeklyCalendar weekends = new WeeklyCalendar(null, madrid);        // innermost: Saturday and Sunday
HolidayCalendar businessDays = new HolidayCalendar(weekends, madrid);   // outermost: one-off dates
businessDays.addExcludedDate(Date.from(LocalDate.parse("2026-12-25")
        .atStartOfDay(ZoneId.of("Europe/Madrid")).toInstant()));

businessDays.isTimeIncluded(at("2026-12-25T10:00"));         // false: the holiday (a Friday)
businessDays.isTimeIncluded(at("2026-12-26T10:00"));         // false: Saturday, excluded by the base calendar
businessDays.isTimeIncluded(at("2026-12-28T10:00"));         // true:  Monday
businessDays.getNextIncludedTime(at("2026-12-25T10:00"));    // 2026-12-28T00:00+01:00: skips the whole weekend
businessDays.getBaseCalendar() == weekends;                  // true

Chains of two or three links are the norm: an AnnualCalendar for the holidays that fall on the same date every year, a WeeklyCalendar for weekends, a HolidayCalendar for the one-off dates (a bridge day, a local holiday). Once the chain is built, the order does not change which times are excluded. While it is being built it does matter for one class: put the AnnualCalendar innermost and fill it before anything is attached to it (see the trap in the AnnualCalendar section).

Registering a calendar

A calendar has to be registered with the scheduler under a name before a trigger can use it. There are two ways, and in a Spring Boot application the second is the default choice.

With Scheduler.addCalendar

addCalendar(String calName, Calendar calendar, boolean replace, boolean updateTriggers) registers the calendar:

  • replace decides what happens when the name already exists. With false, registering the same name twice throws ObjectAlreadyExistsException ("Calendar with name 'holidays' already exists.", measured). With true the new calendar replaces the old one.

  • updateTriggers decides whether the triggers that refer to the replaced calendar recompute their next fire time against the new one. With false they keep the fire time that was computed against the old calendar until they fire next.

deleteCalendar(name) removes a calendar, getCalendar(name) returns it and getCalendarNames() lists the registered names. Deleting a calendar that a trigger still refers to fails with a SchedulerException whose message is, with Quartz’s own spelling, "Calender cannot be removed if it referenced by a Trigger!" (measured). Reschedule or delete the triggers first.

Replacing a calendar at runtime and updating the triggers that use it
HolidayCalendar updated = new HolidayCalendar(weekends, madrid);
current.getExcludedDates().forEach(updated::addExcludedDate);               // keep the old dates
updated.addExcludedDate(Date.from(LocalDate.parse("2026-12-28")
        .atStartOfDay(ZoneId.of("Europe/Madrid")).toInstant()));            // a new bridge day

scheduler.addCalendar("businessDays", updated, true, true);                 // replace + recompute the triggers

// the overdue-loan trigger had its next fire at 2026-12-28T02:00+01:00; now it is 2026-12-29T02:00+01:00
scheduler.getTrigger(TriggerKey.triggerKey("overdueLoanRemindersTrigger", "bookshelf")).getNextFireTime();

As a Spring Calendar bean

Spring Boot’s QuartzAutoConfiguration injects every org.quartz.Calendar bean in the application context as a Map<String, Calendar> and passes it to the SchedulerFactoryBean (setCalendars). The bean name is the calendar name, so a bean called businessDays is registered under the name businessDays and triggers refer to it with modifiedByCalendar("businessDays"). Spring registers each one with addCalendar(name, calendar, true, true): replace and update triggers. The order inside SchedulerAccessor is: job details, then calendars, then triggers, so a trigger bean can refer to a calendar bean without any further ordering. Because the registration replaces what is in the store, the calendars of a JDBC job store are overwritten with the configured ones at every startup, which is what makes a change in application.yml take effect after a restart.

Two consequences follow from "bean name equals calendar name":

  • A calendar bean must not share its name with any other bean. A @Component class called BusinessDays collides with a calendar bean businessDays and the application fails at startup with BeanDefinitionOverrideException (measured).

  • A typo in the name of a trigger’s calendar does not fail at startup in every path. See the pitfalls table.

A Calendar bean, picked up and registered by Spring Boot
@Configuration(proxyBeanMethods = false)
class SchedulingCalendars {

    @Bean("holidays")                     // the bean name is the Quartz calendar name
    HolidayCalendar holidays() {
        HolidayCalendar calendar = new HolidayCalendar();
        calendar.setTimeZone(TimeZone.getTimeZone("Europe/Madrid"));
        calendar.addExcludedDate(Date.from(LocalDate.parse("2026-12-25")
                .atStartOfDay(ZoneId.of("Europe/Madrid")).toInstant()));
        return calendar;
    }

    @Bean
    Trigger overdueLoanRemindersTrigger(JobDetail overdueLoanRemindersJob) {
        return TriggerBuilder.newTrigger().forJob(overdueLoanRemindersJob)
                .withIdentity("overdueLoanRemindersTrigger", "bookshelf")
                .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?")
                        .inTimeZone(TimeZone.getTimeZone("Europe/Madrid")))
                .modifiedByCalendar("holidays")
                .build();
    }
}

modifiedByCalendar

TriggerBuilder.modifiedByCalendar(String calName) sets the calendar name on the trigger being built, whatever its schedule builder (the simple and cron triggers of the Bookshelf jobs use it below). The name can be read back with Trigger.getCalendarName() (on a JDBC store it is the CALENDAR_NAME column of QRTZ_TRIGGERS). A trigger refers to one calendar; to combine several rules, build a chain and register its outermost link.

A calendar changes only the times the trigger would fire at. It does not change the trigger’s schedule: a trigger set to fire every day at 02:00 and modified by a calendar that excludes Fridays simply does nothing on Fridays.

Which fire times survive: the nightly overdue-loan reminder with and without the business-day calendar
cron 0 0 2 * * ?, Europe/Madrid, from 2026-12-27T12:00

without a calendar:  2026-12-28 02:00 ; 12-29 ; 12-30 ; 12-31 ; 2027-01-01 ; 01-02 (Sat) ; 01-03 (Sun) ; 01-04
with businessDays:   2026-12-28 02:00 ; 12-29 ; 12-30 ;       (12-31 one-off holiday, 2027-01-01 recurring holiday,
                     01-02 and 01-03 weekend)   2027-01-04 ; 01-05 ; 01-06 ; 01-07 ; 01-08

The listing above is the measured output of TriggerUtils.computeFireTimes for the calendar configured in the next section (31 December 2026 as a one-off holiday, 1 January and 25 December as recurring holidays, weekends excluded).

A Bookshelf business-day calendar from configuration

Two Bookshelf jobs run on business days only: the nightly overdue-loan reminders and the monthly retention cleanup. The holidays belong in configuration, not in code, so that operations can add a bridge day without a release. The calendar is a HolidayCalendar over a WeeklyCalendar over an AnnualCalendar: recurring holidays from recurring-holidays (as --MM-dd, the format Spring uses for java.time.MonthDay) in the innermost AnnualCalendar, weekends from the property weekend in the WeeklyCalendar, and one-off dates from holidays in the outermost HolidayCalendar.

application.yml: the Bookshelf business-day configuration
bookshelf:
  business-calendar:
    zone: Europe/Madrid
    weekend: [SATURDAY, SUNDAY]
    recurring-holidays:        # same date every year, MonthDay format --MM-dd
      - "--01-01"
      - "--12-25"
    holidays:                  # one-off dates
      - 2026-12-31
The properties record
@ConfigurationProperties("bookshelf.business-calendar")
public record BusinessCalendarProperties(
        @DefaultValue("Europe/Madrid") ZoneId zone,
        @DefaultValue({"SATURDAY", "SUNDAY"}) Set<DayOfWeek> weekend,
        @DefaultValue List<MonthDay> recurringHolidays,
        @DefaultValue List<LocalDate> holidays) {
}
The calendar bean: annual, weekly and holiday links chained
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(BusinessCalendarProperties.class)    // or @ConfigurationPropertiesScan
public class BusinessCalendarConfig {

    /** Quartz weekday constants are java.util.Calendar's: SUNDAY = 1 ... SATURDAY = 7. */
    static int quartzDay(DayOfWeek day) {
        return day == DayOfWeek.SUNDAY ? java.util.Calendar.SUNDAY : day.getValue() + 1;
    }

    @Bean("businessDays")           // bean name = Quartz calendar name
    org.quartz.Calendar businessDays(BusinessCalendarProperties props) {
        TimeZone zone = TimeZone.getTimeZone(props.zone());

        AnnualCalendar annual = new AnnualCalendar(zone);          // innermost, no base calendar while adding days
        for (MonthDay holiday : props.recurringHolidays()) {
            GregorianCalendar day = new GregorianCalendar(zone);
            day.clear();
            day.set(2000, holiday.getMonthValue() - 1, holiday.getDayOfMonth());   // the year is ignored
            annual.setDayExcluded(day, true);
        }

        WeeklyCalendar weekly = new WeeklyCalendar(annual, zone);  // (base, zone) constructor: weekend by default
        for (int day = java.util.Calendar.SUNDAY; day <= java.util.Calendar.SATURDAY; day++) {
            weekly.setDayExcluded(day, false);                     // then say exactly what the property says
        }
        props.weekend().forEach(d -> weekly.setDayExcluded(quartzDay(d), true));

        HolidayCalendar holidays = new HolidayCalendar(weekly, zone);
        props.holidays().forEach(d ->
                holidays.addExcludedDate(Date.from(d.atStartOfDay(props.zone()).toInstant())));
        holidays.setDescription("Bookshelf business days (Europe/Madrid)");
        return holidays;
    }
}
The nightly overdue-loan reminder trigger uses the calendar by name
@Bean
Trigger overdueLoanRemindersTrigger(JobDetail overdueLoanRemindersJob) {
    return TriggerBuilder.newTrigger().forJob(overdueLoanRemindersJob)
            .withIdentity("overdueLoanRemindersTrigger", "bookshelf")
            .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?")    // every day: the calendar removes
                    .inTimeZone(TimeZone.getTimeZone("Europe/Madrid"))        // weekends and holidays
                    .withMisfireHandlingInstructionFireAndProceed())
            .modifiedByCalendar("businessDays")
            .build();
}

Run against this configuration (measured, 5 October 2026), the calendar answers as follows for 02:00 Europe/Madrid:

Table 3. Business-day answers for the configured calendar
Date Day Included Why

2026-12-24

Thursday

yes

a normal day

2026-12-25

Friday

no

recurring holiday (--12-25)

2026-12-26

Saturday

no

weekend

2026-12-28

Monday

yes

a normal day

2026-12-31

Thursday

no

one-off holiday in holidays

2027-01-01

Friday

no

recurring holiday (--01-01)

2027-12-25

Saturday

no

weekend, and also the recurring holiday

The monthly retention cleanup: a calendar skips, it does not shift

The retention cleanup should run on the last business day of the month. The cron field LW means "last weekday of the month", so 0 30 3 LW * ? already skips weekends, and putting the same calendar on the trigger removes a last weekday that is a holiday. That is where the trap lies: the calendar does not move the run to the previous business day, it drops that month’s run. With 31 December 2026 (a Thursday, the last weekday of December) configured as a holiday, the measured fire times of the cleanup are:

0 30 3 LW * ? with and without the business-day calendar
without a calendar:  2026-11-30 03:30 ; 2026-12-31 03:30 ; 2027-01-29 03:30 ; 2027-02-26 03:30
with businessDays:   2026-11-30 03:30 ;                     2027-01-29 03:30 ; 2027-02-26 03:30 ; 2027-03-31 03:30

Source: measured in a test run; see Quartz 2.5.x Javadoc: CronExpression and Quartz 2.5.x Javadoc: Calendar.

December has no run. A schedule that must never miss a month is built the other way round: fire on every business day, keep the calendar on the trigger, and let the job decide whether today is the last business day by asking the same calendar what the next included day is.

Fire every weekday through the calendar; the job checks "last business day of the month"
@Bean
Trigger retentionCleanupTrigger(JobDetail retentionCleanupJob) {
    return TriggerBuilder.newTrigger().forJob(retentionCleanupJob)
            .withIdentity("retentionCleanupTrigger", "bookshelf")
            .withSchedule(CronScheduleBuilder.cronSchedule("0 30 3 ? * MON-FRI")
                    .inTimeZone(TimeZone.getTimeZone("Europe/Madrid")))
            .modifiedByCalendar("businessDays")
            .build();
}

@Component
public class BusinessDayRules {                  // not named BusinessDays: that would clash with the bean "businessDays"

    private final org.quartz.Calendar calendar;
    private final ZoneId zone;

    BusinessDayRules(@Qualifier("businessDays") org.quartz.Calendar calendar, BusinessCalendarProperties props) {
        this.calendar = calendar;
        this.zone = props.zone();
    }

    public boolean isLastBusinessDayOfMonth(LocalDate day) {
        long startOfDay = day.atStartOfDay(zone).toInstant().toEpochMilli();
        if (!calendar.isTimeIncluded(startOfDay)) {
            return false;                                           // not a business day at all
        }
        long startOfTomorrow = day.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli();
        LocalDate next = Instant.ofEpochMilli(calendar.getNextIncludedTime(startOfTomorrow)).atZone(zone).toLocalDate();
        return next.getMonth() != day.getMonth();                   // the next business day is in another month
    }
}

The job calls isLastBusinessDayOfMonth(LocalDate.now(zone)) first and returns when it is false. Measured results:

Table 4. isLastBusinessDayOfMonth for the dates that matter
Date Day Result

2026-11-27

Friday

false (Monday 30 November is still in the month)

2026-11-30

Monday

true

2026-12-30

Wednesday

true (31 December is a holiday, so the 30th is the last business day)

2026-12-31

Thursday

false (a holiday: the trigger does not even fire)

2027-01-29

Friday

true (the 30th and 31st are a weekend)

2027-01-31

Sunday

false

The retention cleanup then launches the Spring Batch job described in Running a Job.

Example 8: the official calendar example

Quartz ships a small runnable example for calendars, org.quartz.examples.example8.CalendarExample. It registers an AnnualCalendar that excludes 4 July, 31 October (Halloween) and 25 December, schedules an hourly job that starts on 31 October and is modified by the calendar, and prints the first run time. Because 31 October is excluded, the first run is the next day. The trimmed version below keeps its logic (the official class uses a plain-Java StdSchedulerFactory and the example’s own SimpleJob).

Example 8 (trimmed): an AnnualCalendar of holidays and an hourly trigger modified by it
Scheduler sched = new StdSchedulerFactory().getScheduler();

AnnualCalendar holidays = new AnnualCalendar();
holidays.setDayExcluded(new GregorianCalendar(2005, 6, 4), true);     // 4 July
holidays.setDayExcluded(new GregorianCalendar(2005, 9, 31), true);    // 31 October
holidays.setDayExcluded(new GregorianCalendar(2005, 11, 25), true);   // 25 December

sched.addCalendar("holidays", holidays, false, false);

Date runDate = DateBuilder.dateOf(0, 0, 10, 31, 10);                  // see the note below

JobDetail job = JobBuilder.newJob(SimpleJob.class).withIdentity("job1", "group1").build();
SimpleTrigger trigger = TriggerBuilder.newTrigger().withIdentity("trigger1", "group1")
        .startAt(runDate)
        .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInHours(1).repeatForever())
        .modifiedByCalendar("holidays")
        .build();

Date firstRunTime = sched.scheduleJob(job, trigger);                  // 1 November, not 31 October
sched.start();

Run on 5 October 2026 against Quartz 2.5.2, the trigger’s first fire time was Sunday 1 November 2026 at 00:00:10, the start of the first non-holiday day (plus the 10 seconds from the start time), and the scheduler held the calendar holidays. Two details of the official source are worth knowing:

  • The comments say the job starts "at 10 am", but DateBuilder.dateOf(hour, minute, second, dayOfMonth, month) takes the hour first, so dateOf(0, 0, 10, 31, 10) is 00:00:10 on 31 October (measured: Sat Oct 31 00:00:10), and dateOf(10, 0, 0, 31, 10) is 10:00:00. Either way the whole of 31 October is excluded.

  • AnnualCalendar ignores the year of the GregorianCalendar it receives, so the year 2005 in the example is a leftover and the three days are excluded every year. The example uses replace=false, updateTriggers=false; registering the same name a second time then fails with ObjectAlreadyExistsException.

Pitfalls

Table 5. Calendar pitfalls, each measured or read from the 2.5.2 and 7.0.9 sources
Pitfall What happens Fix

new WeeklyCalendar(TimeZone) expects the weekend to be excluded

It excludes nothing (the other three constructors exclude Saturday and Sunday), so a "weekday" trigger fires on weekends

Use new WeeklyCalendar(null, zone) or set the days explicitly

scheduleJob(JobDetail, Trigger) with a calendar name that is not registered

No exception: the trigger is stored in state NORMAL and never fires (measured: no execution after 1.5 s; in the sources the fire step skips a trigger whose calendar cannot be found). scheduleJob(Trigger) throws SchedulerException "Calendar not found: nope" instead

Register the calendar first (Spring does: jobs, calendars, triggers), check names against getCalendarNames(), and watch for typos in modifiedByCalendar

A calendar excludes a day that is the trigger’s only candidate in the period (LW, L, 15W, a monthly date)

That occurrence is dropped, not moved to the previous or next business day

Fire daily or weekly through the calendar and let the job decide, as in the retention cleanup above

DailyCalendar range assumed to be the allowed window

The range is excluded by default; the opposite behaviour needs setInvertTimeRange(true)

Read the default as "blackout window"

DailyCalendar range across midnight

IllegalArgumentException: the start must be before the end

Two calendars chained, or a CronCalendar

CronCalendar expression with seconds fixed at 0

Only the exact second matches, so 03:00:30 is still included

Use * in the seconds and minutes fields to cover the whole range

Time zone not set

The JVM default zone is used, so the excluded days move with the host

Set the zone on every calendar (the CronCalendar forwards it to its expression)

Day indexes mixed up

WeeklyCalendar uses java.util.Calendar days (SUNDAY is 1, SATURDAY is 7); MonthlyCalendar methods are 1-based; DayOfWeek from java.time is 1 for Monday

Use the java.util.Calendar constants for WeeklyCalendar (the quartzDay helper above converts)

AnnualCalendar.setDayExcluded on a calendar that already has a base calendar

A reference day that the base calendar excludes is silently not added (1 January 2000 was a Saturday, so it vanishes under a weekend base)

Create the AnnualCalendar without a base, add the days, and make it the innermost link

Calendar bean named like another bean

BeanDefinitionOverrideException at startup (a @Component BusinessDays against the calendar bean businessDays)

Name the calendar bean for what it is and give the helper a different class name

deleteCalendar while a trigger refers to it

SchedulerException: "Calender cannot be removed if it referenced by a Trigger!"

Reschedule or remove the triggers first, or replace the calendar with addCalendar(name, calendar, true, true)

Replacing a calendar with updateTriggers=false

Triggers keep the next fire time computed against the old calendar until they fire

Pass true to recompute them

Custom Calendar implementation on a JDBC store

It is stored serialized, so it must be Serializable and its serialized form must stay readable after a deploy

Prefer the shipped calendars and build the rule from configuration

What changed

  • Quartz 2.5.2 (current). The six calendar classes carry no deprecation annotations in the 2.5.2 sources. Their documented defaults and their actual behaviour differ in two places, both measured above: the WeeklyCalendar(TimeZone) constructor and the AnnualCalendar with a base calendar.

  • Spring Boot 3.5 to 4.x. The auto-configuration still collects Calendar beans into a Map and hands it to SchedulerFactoryBean; only the package of QuartzAutoConfiguration moved. Applications that wrote Calendar beans do not change.

  • Persistence. Under a JDBC job store the calendars are rows in QRTZ_CALENDARS (one of the 11 Quartz tables); Spring Boot overwrites them with the beans on every start. Under the default in-memory store they live only as long as the process.

Verification

Every example above was run on 5 October 2026 in a throwaway Maven project (Spring Boot 4.1.1, Quartz 2.5.2, compiled for Java 21 on JDK 26): the calendar classes standalone, chaining, Example 8, the Spring Boot business-day calendar loaded both from command-line style properties and from a YAML profile (weekend, recurring-holidays and holidays bound as shown), the retention cleanup comparison, the isLastBusinessDayOfMonth helper and the runtime replacement of a calendar. The scheduler in those runs used the in-memory store; calendars under a JDBC job store were not exercised on this page and are described from the sources and the table list measured on the database pages.