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.
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 |
Question it answers |
"Is this instant allowed to fire?" ( |
"What is the year, month, day, hour of this instant?" |
Time representation |
|
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 ( |
Not related |
Source: Quartz 2.5.x Javadoc: org.quartz.Calendar, Quartz Tutorial — Lesson 4: More About Triggers and Java SE 21: java.util.Calendar.
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.
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.
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 usesEurope/Madrid. -
The base calendar is consulted first: a time that the base calendar excludes is excluded, whatever the outer calendar says.
-
getNextIncludedTime(t)returnstitself whentis included, otherwise the first included instant after it.
| Class | Excludes | Default | Granularity |
|---|---|---|---|
|
Specific dates, added with |
Nothing; |
Whole days (the date is normalized to the start of the day in the calendar’s zone) |
|
The same month and day every year, added with |
Nothing; the year of the supplied |
Whole days |
|
Days of the week; the index is the |
Saturday and Sunday, with the no-argument, base-calendar and (base calendar, time zone) constructors; nothing with the |
Whole days |
|
Days of the month 1 to 31, with |
Nothing |
Whole days |
|
A time range inside each day, "HH:MM[:SS[:mmm]]", start strictly before end, so it cannot cross midnight |
The range is excluded; |
Milliseconds |
|
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) |
Source: Quartz 2.5.x Javadoc: org.quartz.impl.calendar and the calendar sources at v2.5.2.
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 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 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
Source: Quartz 2.5.x Javadoc: AnnualCalendar.
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 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
Source: AnnualCalendar.java at v2.5.2. Source: Quartz 2.5.x Javadoc: AnnualCalendar.
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.
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 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 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
Source: Quartz 2.5.x Javadoc: DailyCalendar.
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).
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).
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:
-
replacedecides what happens when the name already exists. Withfalse, registering the same name twice throwsObjectAlreadyExistsException("Calendar with name 'holidays' already exists.", measured). Withtruethe new calendar replaces the old one. -
updateTriggersdecides whether the triggers that refer to the replaced calendar recompute their next fire time against the new one. Withfalsethey 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.
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
@Componentclass calledBusinessDayscollides with a calendar beanbusinessDaysand the application fails at startup withBeanDefinitionOverrideException(measured). -
A typo in the name of a trigger’s calendar does not fail at startup in every path. See the pitfalls table.
@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.
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.
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
@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) {
}
@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;
}
}
@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:
| Date | Day | Included | Why |
|---|---|---|---|
2026-12-24 |
Thursday |
yes |
a normal day |
2026-12-25 |
Friday |
no |
recurring holiday ( |
2026-12-26 |
Saturday |
no |
weekend |
2026-12-28 |
Monday |
yes |
a normal day |
2026-12-31 |
Thursday |
no |
one-off holiday in |
2027-01-01 |
Friday |
no |
recurring holiday ( |
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 calendarwithout 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.
@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:
| Date | Day | Result |
|---|---|---|
2026-11-27 |
Friday |
|
2026-11-30 |
Monday |
|
2026-12-30 |
Wednesday |
|
2026-12-31 |
Thursday |
|
2027-01-29 |
Friday |
|
2027-01-31 |
Sunday |
|
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).
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();
Source: Quartz examples: example8 at v2.5.2.
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, sodateOf(0, 0, 10, 31, 10)is 00:00:10 on 31 October (measured:Sat Oct 31 00:00:10), anddateOf(10, 0, 0, 31, 10)is 10:00:00. Either way the whole of 31 October is excluded. -
AnnualCalendarignores the year of theGregorianCalendarit receives, so the year 2005 in the example is a leftover and the three days are excluded every year. The example usesreplace=false, updateTriggers=false; registering the same name a second time then fails withObjectAlreadyExistsException.
Pitfalls
| Pitfall | What happens | Fix |
|---|---|---|
|
It excludes nothing (the other three constructors exclude Saturday and Sunday), so a "weekday" trigger fires on weekends |
Use |
|
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). |
Register the calendar first (Spring does: jobs, calendars, triggers), check names against |
A calendar excludes a day that is the trigger’s only candidate in the period ( |
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 |
|
The range is excluded by default; the opposite behaviour needs |
Read the default as "blackout window" |
|
|
Two calendars chained, or a |
|
Only the exact second matches, so 03:00:30 is still included |
Use |
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 |
Day indexes mixed up |
|
Use the |
|
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 |
Calendar bean named like another bean |
|
Name the calendar bean for what it is and give the helper a different class name |
|
|
Reschedule or remove the triggers first, or replace the calendar with |
Replacing a calendar with |
Triggers keep the next fire time computed against the old calendar until they fire |
Pass |
Custom |
It is stored serialized, so it must be |
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 theAnnualCalendarwith a base calendar. -
Spring Boot 3.5 to 4.x. The auto-configuration still collects
Calendarbeans into aMapand hands it toSchedulerFactoryBean; only the package ofQuartzAutoConfigurationmoved. Applications that wroteCalendarbeans 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.