Cron Expressions

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 cron expression is a compact string that describes a recurring schedule. Quartz uses it in the CronTrigger (see Triggers and Schedules for the other trigger types), Spring uses a close relative of it in @Scheduled(cron = …​) and in org.springframework.scheduling.support.CronExpression, and Unix cron and the Kubernetes CronJob use a third, shorter dialect. The three dialects look alike and are not the same: copying an expression from one to another is the most common source of a job that runs on the wrong day, every second, or never. This page defines the Quartz grammar, shows how to validate and preview an expression with the CronExpression API, explains time zones and daylight-saving transitions, and translates between the dialects. For where a cron schedule fits among the other options, see Standard Scheduling vs Quartz; for what happens when a fire time is missed, see Misfires.

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 expression below was parsed and evaluated with org.quartz.CronExpression.getNextValidTimeAfter (Quartz 2.5.2) and, for the Spring column, with org.springframework.scheduling.support.CronExpression.next (Spring Framework 7.0.9) in a test project, and the fire times quoted are the measured ones. The Unix and Kubernetes columns are taken from the crontab(5) manual page and the Kubernetes documentation and were not executed. The Quartz cron grammar is part of Quartz itself, so it is identical on Spring Boot 3.5.16 (which also manages Quartz 2.5.2); the Spring column was measured on Spring Framework 7.0.9 only, and Spring Boot 3.5 uses Spring Framework 6.2.19.

In the running Bookshelf scenario, three of the five jobs are cron-driven: the nightly overdue-loan reminders, the weekly statistics report and the monthly retention cleanup. The cache refresh is a fixed-rate job and the per-member reminders are one-shot triggers, so neither needs cron.

The seven fields of a Quartz cron expression, left to right: seconds 0-59, minutes 0-59, hours 0-23, day-of-month 1-31, month 1-12 or JAN-DEC, day-of-week 1-7 or SUN-SAT with Sunday as 1, and an optional year. The example 0 0 2 ? * MON-FRI fires at 02:00:00 on weekdays. Below the fields, two rules: day-of-month and day-of-week cannot both be restricted, so one must be a question mark, and day-of-week counts from Sunday as 1

The grammar

A Quartz expression has six required fields and one optional field, separated by white space. The order is fixed, and unlike Unix cron it starts with seconds.

Table 1. The fields of a Quartz cron expression (verified against CronExpression 2.5.2)
Position Field Allowed values Special characters

1

Seconds

0-59

, - * /

2

Minutes

0-59

, - * /

3

Hours

0-23

, - * /

4

Day-of-month

1-31

, - * ? / L W

5

Month

1-12 or JAN-DEC

, - * /

6

Day-of-week

1-7 or SUN-SAT, where 1 is Sunday

, - * ? / L #

7

Year (optional)

a year, or omitted

, - * /

Names and special characters are case-insensitive: 0 0 12 ? * mon-fri is accepted, and toString() returns it in upper case. Out-of-range values are rejected when the expression is parsed, with a java.text.ParseException: hour 24 ("Hour values must be between 0 and 23"), second or minute 60 ("Minute and Second values must be between 0 and 59") and month 13 ("Month values must be between 1 and 12") all fail.

The Javadoc of CronExpression in 2.5.2 still says that the month field is 0-11. The parser disagrees: month 0 is rejected ("Month values must be between 1 and 12") and month 12 is accepted and means December. Trust the parser, and prefer the JAN-DEC names, which cannot be misread.

Special characters

Table 2. What each special character means in Quartz
Character Fields Meaning Example and measured result

*

all

Every value of the field.

0 * * * * ? fires every minute (12:01:00, 12:02:00, …​).

?

day-of-month, day-of-week

"No specific value": used in whichever of the two day fields you are not restricting.

0 0 2 ? * MON-FRI: day-of-month is unrestricted, day-of-week is restricted.

-

all

An inclusive range.

0 0 9-17 ? * MON-FRI fires hourly from 09:00 to 17:00 on weekdays.

,

all

A list of values or ranges.

0 0 8,12,18 * * ? fires at 08:00, 12:00 and 18:00.

/

all

An increment: a/n means a, a+n, a+2n, …​ up to the maximum of the field. It does not wrap around.

0 5/15 * * * ? fires at minutes 5, 20, 35 and 50. 7/6 in the month field means July only.

L

day-of-month, day-of-week

"Last". In day-of-month: the last day of the month; L-3 is the third-to-last day. In day-of-week alone: 7 (Saturday). After a weekday: the last such weekday of the month.

0 0 12 L * ? fires on 2026-10-31, 2026-11-30, 2026-12-31. 0 0 12 ? * 6L fires on the last Friday: 2026-10-30. 0 0 0 ? * L fires on Saturdays.

W

day-of-month

"Nearest weekday" to the given day. It never crosses a month boundary.

0 0 12 15W * ? fires on 2026-10-15 (a Thursday), then on Monday 2026-11-16, because 15 November is a Sunday. 1W in August 2026 fires on Monday the 3rd, because the 1st is a Saturday.

LW

day-of-month

The last weekday (Monday to Friday) of the month.

0 0 12 LW * ? fires on Friday 2026-10-30, Monday 2026-11-30, Thursday 2026-12-31.

#

day-of-week

"The nth such weekday of the month": 6#3 is the third Friday. Only one such expression is allowed in the field.

0 0 12 ? * 6#3 fires on 2026-10-16, 2026-11-20, 2026-12-18. 6 is rejected ("A numeric value between 1 and 5 must follow the '' option") and 3#1,5#2 is rejected ("Support for specifying multiple "nth" days is not implemented.").

Two practical points about L and the others. First, the combinations the parser does not implement are rejected rather than guessed: 6L,2 (an L weekday plus another weekday) fails with "Support for specifying 'L' with other days of the week is not implemented". Second, the parser is more permissive than the documentation: L,15 in day-of-month is accepted and works (it fires on the 15th and on the last day), but W combined with a range, such as 10-20W, is accepted and produces consecutive days including weekends, which is not "nearest weekday". The Javadoc says not to combine L or W with lists or ranges; follow it.

The 2.5.1 change to L

In the 2.5.0 release candidates a refactoring broke L and L-n in months with fewer than 31 days, so that "last day of the month" schedules never fired in short months. The fix is pull request #1284, "Fix regression: 'L' last day of month specifier with short months won’t be triggered", in the Quartz 2.5.1 milestone. On 2.5.2 the behaviour is correct, and it was measured across a leap-year boundary:

Table 3. L, L-1 and LW in short months, Quartz 2.5.2 (measured from 2027-01-31 13:00, Europe/Madrid)
Expression Fire times

0 0 12 L * ?

2027-02-28, 2027-03-31, 2027-04-30, 2027-05-31, and later 2028-02-29

0 0 12 L-1 * ?

2027-02-27, 2027-03-30, 2027-04-29, 2027-05-30

0 0 12 LW * ?

Friday 2027-02-26, Wednesday 2027-03-31, Friday 2027-04-30, Monday 2027-05-31

The same expressions on Quartz 2.4.1 give the same dates. The 2.5.0 and 2.5.1 artifacts were not run for this page, so the broken behaviour itself is quoted from the pull request, not reproduced. The practical advice is simple: on Spring Boot 4.1 and 3.5.16 the starter manages Quartz 2.5.2, so you are not affected; if you pin Quartz yourself, do not stay on 2.5.0.

The ? rule

Quartz cannot combine a day-of-month restriction with a day-of-week restriction (Unix cron would run the job when either matches). Instead, exactly one of the two fields must be ?, which means "I do not care about this field".

Table 4. The ? rule: valid and invalid combinations (Quartz 2.5.2, measured)
Expression Valid? Result

0 0 2 * * ?

yes

Every day at 02:00.

0 0 2 ? * MON-FRI

yes

Weekdays at 02:00.

0 30 3 1 * ?

yes

The 1st of every month at 03:30.

0 0 2 * * *

no

"Support for specifying both a day-of-week AND a day-of-month parameter is not implemented."

0 0 12 13 * FRI

no

Same message: "Friday the 13th" cannot be written in one Quartz expression.

0 0 2 ? * ?

no

"'?' can only be specified for Day-of-Month -OR- Day-of-Week."

If you need "Friday the 13th", use a CronTrigger on the 13th (0 0 12 13 * ?) and let the job check the day of the week, or use a Calendar (see Calendars).

The CronExpression API

org.quartz.CronExpression is a standalone parser and evaluator. You can use it without a scheduler to validate user-supplied schedules, to preview fire times in a UI and to test trigger definitions. The members used in this page, all checked against the 2.5.2 source and run:

Table 5. The CronExpression API
Member Behaviour (measured)

new CronExpression(String)

Parses the expression, throws java.text.ParseException (checked) if it is invalid and IllegalArgumentException if it is null.

static boolean isValidExpression(String)

true if the expression parses. 0 2 * * * (five fields), @daily and 0 0 24 * * ? all return false.

static void validateExpression(String)

Throws the ParseException instead of returning false; the message says what is wrong.

Date getNextValidTimeAfter(Date)

The first fire time strictly after the argument, in the expression’s time zone, or null if there is none (0 0 2 31 2 ? never fires).

Date getNextInvalidTimeAfter(Date)

The first second after the argument that does not match; from Monday 02:00:00 with 0 0 2 ? * MON-FRI it returns 02:00:01.

Date getTimeBefore(Date)

The fire time before the argument. Added in 2.5.1 (pull request #1350); from Monday 12:00 it returns Monday 02:00.

boolean isSatisfiedBy(Date)

true only if the date matches at second granularity: Monday 02:00:00 yes, Monday 02:00:30 no.

void setTimeZone(TimeZone) / TimeZone getTimeZone()

The zone in which the fields are read. If you never set it, getTimeZone() returns the JVM default zone (measured with -Duser.timezone=Asia/Tokyo).

Date getFinalFireTime()

Not implemented: the source says "NOT YET IMPLEMENTED" and it always returns null, even for 0 0 0 1 1 ? 2030.

String getExpressionSummary()

A multi-line dump of the parsed sets (daysOfWeek: 2,3,4,5,6 for MON-FRI), useful when debugging numbering.

The two helpers below are the ones worth keeping in a project: one that fails fast with a readable message, and one that previews the next fire times in an explicit zone.

Validating an expression and previewing its next fire times
import org.quartz.CronExpression;

import java.text.ParseException;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.TimeZone;

public final class CronSupport {

    private CronSupport() { }

    /** Fails fast with the parser's message, e.g. when a schedule comes from configuration. */
    public static void requireValidCron(String expression) {
        try {
            CronExpression.validateExpression(expression);
        } catch (ParseException e) {
            throw new IllegalArgumentException(
                    "Invalid Quartz cron '" + expression + "': " + e.getMessage(), e);
        }
    }

    /** The next {@code count} fire times after {@code after}, read in {@code zone}. */
    public static List<ZonedDateTime> nextFireTimes(String expression, ZoneId zone,
                                                    ZonedDateTime after, int count) throws ParseException {
        CronExpression cron = new CronExpression(expression);
        cron.setTimeZone(TimeZone.getTimeZone(zone));
        List<ZonedDateTime> result = new ArrayList<>();
        Date next = Date.from(after.toInstant());
        for (int i = 0; i < count; i++) {
            next = cron.getNextValidTimeAfter(next);
            if (next == null) {
                break; // the expression never fires again
            }
            result.add(next.toInstant().atZone(zone));
        }
        return result;
    }
}

Running these on Friday 2026-10-09 12:00 in Europe/Madrid gives exactly what the expression promises:

Output of the two helpers (measured)
requireValidCron("0 0 2 * * *")
  -> IllegalArgumentException: Invalid Quartz cron '0 0 2 * * *': Support for specifying both a day-of-week AND a
     day-of-month parameter is not implemented.

nextFireTimes("0 0 2 ? * MON-FRI", Europe/Madrid, 2026-10-09T12:00, 3)
  -> [2026-10-12T02:00+02:00, 2026-10-13T02:00+02:00, 2026-10-14T02:00+02:00]

Using an expression in a trigger

CronScheduleBuilder.cronSchedule(String) validates the expression and throws a RuntimeException ("CronExpression '…​' is invalid.", with the ParseException as the cause) if it is wrong. Without inTimeZone, the trigger uses the JVM default zone (measured); in a service that moves between hosts or containers, always set the zone.

A cron trigger for the Bookshelf overdue-loan reminders
import org.quartz.CronScheduleBuilder;
import org.quartz.CronTrigger;
import org.quartz.TriggerBuilder;

import java.util.TimeZone;

class BookshelfTriggers {

    static CronTrigger overdueTrigger() {
        return TriggerBuilder.newTrigger()
                .withIdentity("overdueLoanRemindersTrigger", "bookshelf")
                .forJob("overdueLoanReminders", "bookshelf")
                .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 ? * MON-FRI")
                        .inTimeZone(TimeZone.getTimeZone("Europe/Madrid"))
                        .withMisfireHandlingInstructionFireAndProceed())
                .build();
    }
}

// overdueTrigger().getCronExpression()  -> 0 0 2 ? * MON-FRI
// overdueTrigger().getTimeZone().getID() -> Europe/Madrid
// first fire after Fri 2026-10-09 12:00 -> 2026-10-12T02:00+02:00[Europe/Madrid]

The builder also has shortcuts that generate the expression for you. Their output, measured, shows how they number days: dailyAtHourAndMinute(2, 0) produces 0 0 2 ? * *, weeklyOnDayAndHourAndMinute(DateBuilder.MONDAY, 6, 0) produces 0 0 6 ? * 2 (DateBuilder.MONDAY is 2), monthlyOnDayAndHourAndMinute(1, 3, 30) produces 0 30 3 1 * ? and atHourAndMinuteOnGivenDaysOfWeek(2, 0, DateBuilder.MONDAY, DateBuilder.FRIDAY) produces 0 0 2 ? * 2,6. Use the DateBuilder constants rather than raw numbers, and the page Triggers and Schedules for the rest of the builders.

The Spring side

Spring has its own parser. @Scheduled(cron = …​) takes a Spring expression and an optional zone; the default zone is the empty string, meaning the server’s local zone. The special value - (the constant Scheduled.CRON_DISABLED) turns the task off, which is handy with a property placeholder.

The same schedule with @Scheduled and with Spring’s CronExpression
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.support.CronExpression;

import java.time.ZonedDateTime;

class Reminders {

    @Scheduled(cron = "0 0 2 * * MON-FRI", zone = "Europe/Madrid")
    void overdueLoanReminders() { /* ... */ }

    // a "-" value disables the task, e.g. in a profile that must not run it
    @Scheduled(cron = "${bookshelf.statistics.cron:-}", zone = "Europe/Madrid")
    void weeklyStatistics() { /* ... */ }
}

class SpringCron {

    static ZonedDateTime nextOverdueReminder(ZonedDateTime after) {
        return CronExpression.parse("0 0 2 * * MON-FRI").next(after);
    }
}

// nextOverdueReminder(2026-10-09T12:00 Europe/Madrid) -> 2026-10-12T02:00+02:00[Europe/Madrid]   (measured)

The annotation compiles and its cron() and zone() values read back as written; the - constant is checked. The placeholder form was not executed in an application context on this page.

Time zones and daylight saving

A cron expression has no time zone of its own: the zone belongs to the trigger (Quartz setTimeZone / inTimeZone) or to the annotation (zone). The same expression therefore fires at different instants in different zones.

Table 6. 0 0 2 * * ? evaluated in four zones, next fire after 2026-10-05T10:00Z (measured)
Zone Next fire (UTC)

UTC

2026-10-06T02:00:00Z

Europe/Madrid

2026-10-06T00:00:00Z

America/New_York

2026-10-06T06:00:00Z

Asia/Kolkata

2026-10-05T20:30:00Z

Daylight-saving transitions create two special days. In Europe/Madrid in 2026 the clocks went forward on 29 March (02:00 became 03:00, so 02:00-02:59 does not exist) and back on 25 October (03:00 became 02:00, so 02:00-02:59 happens twice). The measured behaviour of both engines:

Table 7. Daylight-saving behaviour, Europe/Madrid 2026 (measured; first fire times listed)
Expression Quartz 2.5.2 Spring Framework 7.0.9

0 30 2 * * ? on the gap day (29 March)

Skipped: 28 March 02:30, then 30 March 02:30.

Skipped: same as Quartz.

0 30 3 * * ? on the gap day

Fires 29 March 03:30+02:00.

Same.

0 0 2,3 * * ? on the gap day

Fires only 03:00+02:00.

Same.

0 30 2 * * ? on the repeated day (25 October)

Fires once, 02:30+01:00 (the second occurrence).

Fires twice: 02:30+02:00 and 02:30+01:00.

0 0 2,3 * * ? on the repeated day

02:00+01:00 and 03:00+01:00.

02:00+02:00, 02:00+01:00 and 03:00+01:00.

0 0/30 * * * ?, 0 0 * * * ?

Unaffected on both days.

Unaffected.

The consequences are practical. A nightly job at 02:30 in a DST zone silently skips one night a year, whereas 0 30 3 * * ? fired on both transition days in the measurements above. A job that must run exactly once per day should be scheduled outside the 02:00-02:59 window of the zone, or in UTC. A job that is not safe to run twice must be idempotent if it runs under Spring’s cron, because on the repeated day Spring fires it twice (under Quartz it fires once). An unreleased change on the Quartz main branch touches the fall-back handling in CronExpression, and it is not in 2.5.2, so the table above is what you get today.

Interval-style expressions (0 0/30 * * * ?) follow wall-clock fields, not elapsed time. If you need "every 90 minutes of elapsed time" regardless of the clock, cron is the wrong tool; see the next sections and Triggers and Schedules.

Quartz, Spring and Unix/Kubernetes side by side

The table lists what differs. The Quartz and Spring columns were measured; the Unix and Kubernetes columns come from the crontab(5) manual page and the Kubernetes CronJob documentation and were not run.

Table 8. Cron dialects compared
Aspect Quartz 2.5.2 Spring Framework 7.0.9 Unix crontab(5) Kubernetes CronJob

Fields

6 plus an optional year (7th). A 5-field expression is invalid ("Unexpected end of expression.").

Exactly 6, no year. 5 fields is invalid ("must consist of 6 fields (found 5 …​"), and so is a 7th year field.

5: minute, hour, day-of-month, month, day-of-week.

5, as in the documentation’s field table.

Seconds

First field, 0-59.

First field, 0-59.

Not supported.

Not supported.

Day-of-week numbering

1 = Sunday …​ 7 = Saturday; 0 is invalid.

1 = Monday …​ 5 = Friday; 0 and 7 both mean Sunday.

0 or 7 = Sunday; names allowed.

0-6, Sunday to Saturday, per the documentation’s table.

Day names

SUN-SAT, any case.

SUN-SAT, any case.

First three letters, any case.

Not stated on the page.

Month numbering

1-12 (the Javadoc’s 0-11 is wrong).

1-12.

1-12.

1-12.

Day-of-month and day-of-week both restricted

Invalid: one must be ?.

Allowed, and both must match: 0 0 12 13 * FRI fires only on Friday the 13th (2026-11-13, then 2027-08-13).

Allowed, and the job runs when either field matches.

Not stated on the page; test before relying on it.

?

Required in one day field; invalid in both.

Accepted in either day field, and in both (0 0 2 ? * ? is valid).

Not a cron character.

Accepted, "the same meaning as an asterisk".

L, LW, L-n, W, #

Supported (see the special characters table).

Supported: L, LW, L-3, 15W, 5#3, FRI#3, 5L.

Not documented.

Not documented.

Macros

None: @daily fails with "Unexpected character: @".

@yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly. @reboot fails.

@reboot, @yearly, @daily, @hourly and the like.

@yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly.

Overflowing ranges (22-2)

Accepted: 0 0 22-2 * * ? fires at 22, 23, 0, 1 and 2.

Rejected ("Minimum value must be less than maximum value").

Not specified.

Not specified.

Increment of 60 or more

Rejected: 0 0/90 * * * ? fails with "Increment >= 60 : 90".

Accepted: 0 0/90 * * * * fires hourly at minute 0.

Not applicable.

Not applicable.

Time zone

Per trigger (setTimeZone, inTimeZone); default is the JVM zone.

Per call (zone attribute, CronTrigger(expression, ZoneId)); default is the server zone.

The system zone of the cron daemon.

The timeZone field of the CronJob; see Jobs and CronJobs.

To translate by hand, shift the day-of-week and fix the day fields. The Bookshelf schedules, in all four dialects:

Table 9. The Bookshelf schedules in each dialect
Job Quartz Spring Unix or Kubernetes Notes

Overdue-loan reminders, weekdays 02:00

0 0 2 ? * MON-FRI

0 0 2 * * MON-FRI

0 2 * * 1-5

Kubernetes also needs timeZone: Europe/Madrid.

Weekly statistics, Monday 06:00

0 0 6 ? * MON

0 0 6 * * MON

0 6 * * 1

As a number, Monday is 2 in Quartz but 1 in Spring and Unix.

Retention cleanup, last weekday 03:30

0 30 3 LW * ?

0 30 3 LW * *

not expressible

Unix and Kubernetes have no LW; run daily and let the job check.

Use MON-SUN names whenever the expression has to survive a copy between dialects: a name means the same weekday in all three, a number does not.

A cookbook of annotated expressions

Every row was run: the last column is the first fire time after Monday 2026-10-05 12:00 in Europe/Madrid (so "the next one" is always in the future), computed with CronExpression.getNextValidTimeAfter for the Quartz column and CronExpression.next for the Spring column. A dash in the Spring column means the Spring parser rejects that form.

Table 10. Annotated cron expressions (all verified)
# Quartz Spring What it does First fire

1

0/10 * * * * ?

*/10 * * * * *

Every 10 seconds, at seconds 0, 10, …​, 50.

Mon 12:00:10

2

0 * * * * ?

0 * * * * *

Every minute, on the minute.

Mon 12:01:00

3

0 5/15 * * * ?

0 5/15 * * * *

Four times an hour, at minutes 5, 20, 35 and 50.

Mon 12:05:00

4

0 0/15 9-17 ? * MON-FRI

0 0/15 9-17 * * MON-FRI

Every 15 minutes during office hours, weekdays (last fire 17:45).

Mon 12:15:00

5

0 0 * * * ?

0 0 * * * *

Every hour, on the hour.

Mon 13:00:00

6

0 0 0/6 * * ?

0 0 0/6 * * *

Every six hours: 00:00, 06:00, 12:00, 18:00.

Mon 18:00:00

7

0 0 8,12,18 * * ?

0 0 8,12,18 * * *

Three fixed times a day.

Mon 18:00:00

8

0 0 9-17 ? * MON-FRI

0 0 9-17 * * MON-FRI

Hourly from 09:00 to 17:00 on weekdays.

Mon 13:00:00

9

0 15 10 ? * *

0 15 10 * * *

Every day at 10:15.

Tue 10:15:00

10

0 0 2 * * ?

0 0 2 * * *

Nightly at 02:00.

Tue 02:00:00

11

0 0 2 ? * MON-FRI

0 0 2 * * MON-FRI

Nightly at 02:00 on business days (the overdue-loan reminders).

Tue 02:00:00

12

0 0 12 ? * 2-6

0 0 12 * * 1-5

Weekdays at noon, written with numbers: Monday is 2 in Quartz and 1 in Spring.

Tue 12:00:00

13

0 0 6 ? * MON

0 0 6 * * MON

Mondays at 06:00 (the weekly statistics report).

Mon 2026-10-12 06:00

14

0 0 0 ? * 7

0 0 0 * * 6

Saturdays at midnight; 7 is Saturday in Quartz, 6 in Spring (SAT works in both).

Sat 2026-10-10 00:00

15

0 0 0 ? * 1

0 0 0 * * 0

Sundays at midnight; 1 is Sunday in Quartz, and in Spring 0, 7 and SUN are Sunday.

Sun 2026-10-11 00:00

16

0 30 3 1 * ?

0 30 3 1 * *

The 1st of every month at 03:30.

Sun 2026-11-01 03:30

17

0 0 12 1/2 * ?

0 0 12 1/2 * *

Every other day starting on the 1st; the count restarts each month, so 31 October is followed by 1 November.

Wed 2026-10-07 12:00

18

0 0 12 L * ?

0 0 12 L * *

The last day of the month.

Sat 2026-10-31 12:00

19

0 0 12 L-3 * ?

0 0 12 L-3 * *

Three days before the end of the month.

Wed 2026-10-28 12:00

20

0 30 3 LW * ?

0 30 3 LW * *

The last weekday of the month at 03:30 (the retention cleanup); 2026-11-30 is a Monday, 2026-10-31 a Saturday.

Fri 2026-10-30 03:30

21

0 0 12 15W * ?

0 0 12 15W * *

The weekday nearest the 15th.

Thu 2026-10-15 12:00

22

0 0 12 ? * 6#3

0 0 12 * * 5#3

The third Friday of the month (FRI#3 works in both).

Fri 2026-10-16 12:00

23

0 0 12 ? * 6L

0 0 12 * * 5L

The last Friday of the month (FRIL works in both).

Fri 2026-10-30 12:00

24

0 0 12 ? * 4#5

0 0 12 * * 3#5

The fifth Wednesday: only in months that have one, so after October the next fires are 2026-12-30 and 2027-03-31.

Wed 2026-12-30 12:00

25

0 0 12 ? 1-3 MON,WED

0 0 12 * 1-3 MON,WED

Mondays and Wednesdays, January to March only.

Mon 2027-01-04 12:00

26

0 0 0 1 7/6 ?

0 0 0 1 7/6 *

/ does not wrap: only July, on the 1st (2027-07-01, then 2028-07-01).

Thu 2027-07-01 00:00

27

0 0 22-2 * * ?

-

An overflowing range: 22:00, 23:00, 00:00, 01:00 and 02:00. In Spring, list the hours instead.

Mon 22:00:00

28

0 0 0 1 1 ? 2030

-

Once only, using the optional year field; after it, getNextValidTimeAfter returns null.

Tue 2030-01-01 00:00

Expressions 11, 13 and 20 are the three Bookshelf cron jobs. They run from the Spring Boot spring-boot-starter-quartz setup shown in Getting Started with Spring Boot; a holiday calendar for the reminders is added in Calendars.

Two cookbook recipes from the Quartz documentation cannot be written as cron at all: "every 90 minutes" (increments of 60 or more are rejected, and an hourly pattern is not 90-minute spacing) and "every other week" (cron has no concept of weeks since a start date). Both are SimpleTrigger or CalendarIntervalTrigger schedules.

Testing an expression

Put every production expression under a unit test that states the fire times you expect. It costs a few lines, it needs no scheduler and it catches the Sunday-numbering and ? mistakes before deployment.

A JUnit test for the Bookshelf schedules
import org.junit.jupiter.api.Test;

import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;

class BookshelfCronTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");

    @Test
    void overdueRemindersRunOnBusinessDays() throws Exception {
        ZonedDateTime friday = ZonedDateTime.of(2026, 10, 9, 12, 0, 0, 0, MADRID);
        List<ZonedDateTime> times =
                CronSupport.nextFireTimes("0 0 2 ? * MON-FRI", MADRID, friday, 3);
        assertEquals(List.of(
                ZonedDateTime.of(2026, 10, 12, 2, 0, 0, 0, MADRID),
                ZonedDateTime.of(2026, 10, 13, 2, 0, 0, 0, MADRID),
                ZonedDateTime.of(2026, 10, 14, 2, 0, 0, 0, MADRID)), times);
    }

    @Test
    void aOneOffYearExpressionStopsFiring() throws Exception {
        ZonedDateTime friday = ZonedDateTime.of(2026, 10, 9, 12, 0, 0, 0, MADRID);
        assertEquals(1, CronSupport.nextFireTimes("0 0 0 1 1 ? 2030", MADRID, friday, 5).size());
    }
}

Both tests passed on the baseline. The testing page, Testing Scheduled Jobs, builds on this with TriggerUtils.computeFireTimes and integration tests with a running scheduler.

Pitfalls

Table 11. Cron pitfalls and what actually happens (all measured on Quartz 2.5.2 and Spring Framework 7.0.9)
Mistake What happens What to do

0 * * * * ? when you meant "hourly"

Fires every minute (12:01:00, 12:02:00, …​). * * * * * ? fires every second.

Always set the seconds and minutes: 0 0 * * * ? is hourly.

Both day fields set (0 0 2 * * *)

Quartz rejects it: "Support for specifying both a day-of-week AND a day-of-month parameter is not implemented."

Put ? in one of the two day fields.

? in both day fields

Quartz rejects it; Spring accepts it (0 0 2 ? * ? is daily at 02:00).

Put ? in exactly one of the two day fields and the restriction (or *) in the other.

Copying a weekday number between dialects

Quartz 1 is Sunday, Spring 1 is Monday: 0 0 0 ? * 1 fires on Sundays in Quartz, 0 0 0 * * 1 on Mondays in Spring. 1/2 in the day-of-week field gives Sunday, Tuesday, Thursday, Saturday in Quartz and Monday, Wednesday, Friday, Sunday in Spring.

Use MON-SUN names. Quartz rejects 0 ("Day-of-Week values must be between 1 and 7"), Spring accepts it.

Pasting a Unix 5-field expression

Quartz fails ("Unexpected end of expression.") and Spring fails ("must consist of 6 fields (found 5 …​"), both at startup.

Add the seconds field in front and, for Quartz, a ?.

Using a macro such as @daily in Quartz

"Unexpected character: @".

Use 0 0 0 * * ?. Macros work in Spring and Kubernetes only.

0 0/90 * * * ? for "every 90 minutes"

Quartz rejects it ("Increment >= 60 : 90"); Spring accepts it and fires every hour at minute 0, which is not what was meant.

Use a SimpleTrigger or CalendarIntervalTrigger for intervals that do not divide the field.

Expecting / to wrap around

7/6 in the month field means July only. 1/2 in day-of-month restarts every month: 31 October, then 1 November.

Use a list, or a different trigger type.

A date that does not exist (0 0 2 31 2 ?, or 5#6 in Spring)

The expression is accepted but getNextValidTimeAfter returns null: the trigger never fires and nothing is logged as an error. Quartz rejects #6 at parse time.

Preview with nextFireTimes and assert on it.

02:00-02:59 local time in a DST zone

The job is skipped once a year on the spring-forward day, and fires twice on the fall-back day under Spring.

Move the job out of the transition window, use UTC, or make it idempotent.

No explicit time zone

The trigger uses the JVM default zone, so the same code fires at a different instant in a container with another TZ.

Always call inTimeZone (Quartz) or set zone (Spring).

L or W with lists and ranges

Accepted but not meaningful: 10-20W fires on consecutive days including weekends. 6L,2 is rejected.

Use one L or W value per field, as the Javadoc says.

Friday the 13th (day-of-month and weekday)

Quartz cannot express it; Spring requires both to match; Unix cron runs on either.

In Quartz, schedule the 13th and check the weekday in the job.

W on the 31st at the end of a month

Measured difference: in January 2027 (the 31st is a Sunday) Quartz 31W fires on Friday 29 January, while Spring skipped January and fired next on 31 March.

Prefer LW for "last weekday".

Expecting getFinalFireTime() to work

Always null: it is not implemented.

Iterate with getNextValidTimeAfter instead.

Trusting the Javadoc month range

0-11 is documented, 1-12 is what the parser accepts.

Use JAN-DEC.

What changed recently

  • Quartz 2.5.1 fixed L and L-n in months shorter than 31 days (pull request #1284) and added CronExpression.getTimeBefore (pull request #1350). In 2.5.2, L 30 * * * ? (an L in the seconds field) is rejected at parse time ("'L' not expected in seconds, minutes or hours fields."); the Quartz GitHub issue that asked for this, #1283, is in the 2.5.1 milestone.

  • Quartz 2.5.0 moved to the Jakarta namespace and JDK 11; the cron grammar did not change.

  • Spring Boot 3.5.16 and 4.1.1 both manage Quartz 2.5.2, so the Quartz grammar above applies to both. Only the Spring Framework version differs (6.2.19 against 7.0.9); the Spring column was measured on 7.0.9.

  • An unreleased change on the Quartz main branch alters CronExpression handling of the DST fall-back hour. It is not in 2.5.2 and nothing on this page depends on it.