DEV Community

Fred Feng
Fred Feng

Posted on

Stop hand-writing cron: build and parse it in Java

Stop hand-writing cron: build and parse it in Java

Quick, what does this fire?

0 0 18 ? * FRIL
Enter fullscreen mode Exit fullscreen mode

Last Friday of every month, 6 PM. You probably got the 6 PM part. The FRIL is the kind of thing you paste into a comment and hope nobody asks about later.

I got tired of that guessing game, so I've been using cronsmith, a small Java library that lets you build a cron expression by describing it, parse one back when you inherit it from someone else, and print it in whichever flavor your scheduler speaks. It also ships a year-based dialect (YCRON) for the schedules that a month-based line just can't say.

Here's the whole thing in about five minutes.

Build it instead of typing it

The entry point is CronBuilder. You chain the schedule the way you'd say it out loud, and call toString() when you want the string.

new CronBuilder().everySecond(5).toString();
// "*/5 * * * * ?"   -> every 5 seconds

new CronBuilder().everyDay().at(9, 30).toString();
// "0 30 9 * * ?"    -> every day at 09:30

new CronBuilder().everyMonth().everyWeek().Mon().toFri().at(15, 10).toString();
// "0 10 15 ? * MON-FRI"   -> weekdays at 15:10
Enter fullscreen mode Exit fullscreen mode

Nothing surprising yet. The point of building rather than typing shows up the moment you need the awkward stuff, the exact expressions people get wrong:

// Last Friday of the month at 18:00
new CronBuilder().everyMonth().lastDayOfWeek(DayOfWeek.FRIDAY.getValue()).at(18, 0).toString();
// "0 0 18 ? * FRIL"

// 3rd Saturday of the month, every 2 hours
new CronBuilder().everyMonth().dayOfWeek(3, DayOfWeek.SATURDAY).everyHour(2).toString();
// "0 0 */2 ? * SAT#3"

// 3rd-to-last day of the month at 23:30
new CronBuilder().everyMonth().lastDay(3).at(23, 30).toString();
// "0 30 23 L-3 * ?"

// Nearest weekday to the 15th, 09:00 (skip the weekend if the 15th lands on one)
new CronBuilder().everyMonth().latestWeekday(15).at(9, 0).toString();
// "0 0 9 15W * ?"
Enter fullscreen mode Exit fullscreen mode

You didn't have to remember that L, #, W, and FRIL even existed. You described the rule; the string is a byproduct. And ranges compose the same way you'd read them:

new CronBuilder().everyMinute(5).second(5)
    .andSecond(10).toSecond(30).andSecond(32).toSecond(59, 2).toString();
// "5,10-30,32/2 */5 * * * ?"
Enter fullscreen mode Exit fullscreen mode

Read one back

Half the time you're not writing a cron string; you're staring at one that's already in a config file. CRON.parse turns it into the same object you'd have built, so it normalizes as it goes:

CRON.parse("0 0 12 ? * FRIL").toString();   // "0 0 12 ? * FRIL"
CRON.parse("0 0 12 ? * TUE#2").toString();  // 2nd Tuesday
CRON.parse("0 0 12 LW * ?").toString();     // last weekday of the month
Enter fullscreen mode Exit fullscreen mode

Feed it a 5-field Unix line and it fills in the seconds and canonicalizes the day names for you:

CRON.parse("*/5 * * * *").toString();   // "0 */5 * * * ?"
CRON.parse("0 9 * * 1-5").toString();   // "0 0 9 ? * MON-FRI"
CRON.parse("0 0 12 ? * 1").toString();  // "0 0 12 ? * SUN"
Enter fullscreen mode Exit fullscreen mode

That last one is the reason I stopped trusting my own eyes: was 1 Sunday or Monday? Now I just parse it and read the answer.

One schedule, four flavors

Quartz, Spring, AWS EventBridge, and classic Unix all disagree about field counts and syntax. Build once, print for each:

CronExpression daily = new CronBuilder().everyDay().at(9, 30);

CRON.toQuartzString(daily);  // "0 30 9 * * ?"
CRON.toSpringString(daily);  // "0 30 9 * * ?"
CRON.toAwsString(daily);     // "30 9 * * ? *"
CRON.toUnixString(daily);    // "30 9 * * *"
Enter fullscreen mode Exit fullscreen mode

Some schedules only exist in some flavors. L, #, and seconds have no Unix equivalent, and cronsmith tells you so instead of quietly emitting something wrong. The conversion throws rather than lies.

When a month is the wrong unit: YCRON

Standard cron thinks in months. Plenty of real schedules don't: "the 100th day of the year", "Monday of ISO week 20", "every other year". That's what YCRON is for. It's a separate, year-based line with seven fields:

<sec> <min> <hour> <day-of-week> <week-of-year> <day-of-year> [<year>]
Enter fullscreen mode Exit fullscreen mode

You pick the date one of two ways, and the field you're not using becomes ?:

  • day-of-week + week-of-year together, as in "Monday of week 20". Day-of-year is then ?.
  • day-of-year alone, as in "the 100th day". Day-of-week and week-of-year are then ?.

Build it with the same CronBuilder, just starting from a year:

// The 100th day of 2026, at noon
new CronBuilder().year(2026).day(100).at(12, 0, 0);

// Monday of ISO week 20, 2026, at 09:00
new CronBuilder().year(2026).week(20).Mon().at(9, 0, 0);

// Every year, week 40, every day, midnight
new CronBuilder().everyYear().week(40).everyDay().at(0, 0, 0);
Enter fullscreen mode Exit fullscreen mode

Parsing has its own front door, YCRON.parse, so the traditional path stays untouched:

CronExpression a = YCRON.parse("0 0 12 ? ? 100 2026");  // day 100 of 2026, noon
CronExpression b = YCRON.parse("0 0 9 MON 20 ? 2026");  // Monday of week 20, 2026, 09:00

a.getCronType();  // CronType.YCRON
Enter fullscreen mode Exit fullscreen mode

Leave the year off (or make it *) and it means every year, exactly like the other fields.

Actually fire it

An expression isn't much use if you can't ask it when. Every CronExpression computes its own next times, no scheduler required:

CronExpression cron = new CronBuilder().everyMonth().latestWeekday(15).at(9, 0);

cron.getNextFiredDateTime();          // the next LocalDateTime it fires

cron.consume(System.out::println, 5); // print the next 5 fire times
// 2027-01-15T09:00
// 2027-02-15T09:00
// 2027-03-15T09:00
// 2027-04-15T09:00
// 2027-05-14T09:00   <- the 15th is a Saturday, so it slides to Friday the 14th
Enter fullscreen mode Exit fullscreen mode

That "W" behavior isn't a footnote in a spec anymore; you can watch it happen.

Get it

One dependency:

<dependency>
    <groupId>com.github.paganini2008</groupId>
    <artifactId>cronsmith</artifactId>
    <version>1.0.0</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

And your first line:

System.out.println(new CronBuilder().everyDay().at(9, 0).getNextFiredDateTime());
Enter fullscreen mode Exit fullscreen mode

That's the loop I want with cron: describe the rule, get a string I can commit, and get a datetime I can trust, without ever mentally parsing FRIL again. Source and full syntax reference are on GitHub.

Top comments (0)