Build it yourself

Cron expression parser

hardConfig and dependencies

Problem statement

Parse a standard 5-field cron expression and compute when it fires next, the core of any job scheduler, from crontab to Kubernetes CronJobs to CI schedules.

API (Java: CronExpr(String), matches(LocalDateTime), nextAfter(LocalDateTime) returning null when there is no match)

◈ DIAGRAM
CronExpr(expr: str) # raises ValueError on invalid input
matches(t: datetime) -> bool # does minute t fire?
next_after(t: datetime) -> datetime or None

Supported subset. Fields are, in order, minute (0-59), hour (0-23), day of month (1-31), month (1-12), day of week (0-6, 0 = Sunday). Each field is a comma-separated list of:

  • *: every value
  • n: a single number
  • a-b: an inclusive range, a <= b
  • */s, a-b/s: every s-th value of the range. n/s means from n to the field's maximum, every s.

Not supported (must raise ValueError): names like JAN or MON, 7 for Sunday, L, W, #, ?, and macros such as @daily. Seconds and years are not fields.

Rules

  • Day matching follows classic cron: if both day-of-month and day-of-week are restricted (neither starts with *), a day matches if either field matches. If only one is restricted, only that one counts.
  • next_after(t) returns the first matching minute strictly after t, ignoring seconds. If nothing matches within 5 years (for example 0 0 31 2 *), return None.
  • Error messages: expected 5 fields, got N, <field> value V out of range LO-HI, invalid <field> value 'X', <field> range A-B is reversed, step must be a positive integer.
  • Times are naive local datetimes; ignore time zones and daylight saving.

2026-09-24 is a Thursday.

Examples

Example 1

Input: c = CronExpr("*/15 9-17 * * 1-5") # every 15 min, 09:00-17:45, Mon-Fri c.matches(2026-09-24 10:30) c.matches(2026-09-24 10:31) c.next_after(2026-09-24 17:50) c.next_after(2026-09-25 17:45)

Output: true false 2026-09-25 09:00 2026-09-28 09:00

Explanation: After 17:45 on Friday the next business-hours slot is Monday at 09:00. Saturday and Sunday are skipped whole days at a time.

Example 2

Input: CronExpr("30 4 1,15 * 5").next_after(2026-09-24 00:00) CronExpr("30 4 1,15 * 5").next_after(2026-09-25 04:30) CronExpr("0 0 29 2 *").next_after(2026-03-01 00:00) CronExpr("0 0 31 2 *").next_after(2026-03-01 00:00)

Output: 2026-09-25 04:30 2026-10-01 04:30 2028-02-29 00:00 null

Explanation: Both day fields are restricted, so a Friday or the 1st or 15th all match. February 29 next occurs in 2028. February 31 never exists.

Example 3

Input: CronExpr("60 * * * *") CronExpr("*/0 * * * *") CronExpr("* * *") CronExpr("5-1 * * * *") CronExpr("0 0 * * mon")

Output: error: minute value 60 out of range 0-59 error: step must be a positive integer error: expected 5 fields, got 3 error: minute range 5-1 is reversed error: invalid day of week value 'mon'

Explanation: Names are outside the supported subset, so they are rejected rather than guessed at.

Hints

Approach

Parsing. Split into exactly 5 fields. For each comma-separated part, peel off an optional /step, then read *, a-b or a single number, validating digits, range and order, and add range(start, end + 1, step) to the field's set (a BitSet in Java). Record whether the day-of-month and day-of-week fields start with *, because that decides the day rule.

Matching. Minute, hour and month are membership tests. For the day: if both day fields are unrestricted, any day matches. If one is restricted, use only that one. If both are restricted, match if either matches. Convert weekdays carefully: Python's weekday() has Monday = 0 and Java's getDayOfWeek() has Monday = 1 to Sunday = 7, while cron has Sunday = 0.

Next time. Start at t truncated to the minute plus one minute, then loop:

  1. Month not allowed: jump to the 1st of the next month at 00:00.
  2. Day not allowed: jump to the next midnight.
  3. Hour not allowed: jump to the next hour at :00.
  4. Minute not allowed: step one minute.
  5. Otherwise: this is the answer.

Each jump resets the smaller units to their minimum, so no match is skipped. The loop does at most a few thousand iterations even across 5 years, and it stops at the limit for impossible schedules.

ComplexityTime O(field sizes) parse; next_after about O(days + 24 + 60) iterations per callSpace O(1) (at most 60 + 24 + 31 + 12 + 7 allowed values)
Python
from datetime import datetime, timedelta
_FIELDS = [("minute", 0, 59), ("hour", 0, 23), ("day of month", 1, 31),
("month", 1, 12), ("day of week", 0, 6)]
def _number(s, name, lo, hi):
if not s.isdigit():
raise ValueError(f"invalid {name} value '{s}'")
v = int(s)
if not lo <= v <= hi:
raise ValueError(f"{name} value {v} out of range {lo}-{hi}")
return v
def _parse_field(text, name, lo, hi):
allowed = set()
for part in text.split(","):
step = 1
if "/" in part:
part, step_text = part.split("/", 1)
if not step_text.isdigit() or int(step_text) == 0:
raise ValueError("step must be a positive integer")
step = int(step_text)
if part == "*":
start, end = lo, hi
elif "-" in part:
a, b = part.split("-", 1)
start, end = _number(a, name, lo, hi), _number(b, name, lo, hi)
if start > end:
raise ValueError(f"{name} range {start}-{end} is reversed")
else:
start = _number(part, name, lo, hi)
end = hi if step > 1 else start # "5/10" means 5, 15, 25, ...
allowed.update(range(start, end + 1, step))
return allowed
class CronExpr:
def __init__(self, expr):
parts = expr.split()
if len(parts) != 5:
raise ValueError(f"expected 5 fields, got {len(parts)}")
(self.minutes, self.hours, self.dom, self.months, self.dow) = (
_parse_field(text, *spec) for text, spec in zip(parts, _FIELDS))
# Classic cron rule: if both day fields are restricted, a day matches if EITHER does.
self.dom_any = parts[2].startswith("*")
self.dow_any = parts[4].startswith("*")
def _day_ok(self, t):
dom_ok = t.day in self.dom
dow_ok = (t.weekday() + 1) % 7 in self.dow # Python: Monday=0; cron: Sunday=0
if self.dom_any and self.dow_any:
return True
if self.dom_any:
return dow_ok
if self.dow_any:
return dom_ok
return dom_ok or dow_ok
def matches(self, t):
return (t.minute in self.minutes and t.hour in self.hours
and t.month in self.months and self._day_ok(t))
def next_after(self, t):
"""First matching minute strictly after t, or None if none within 5 years."""
t = t.replace(second=0, microsecond=0) + timedelta(minutes=1)
limit = t + timedelta(days=5 * 366)
while t <= limit:
if t.month not in self.months: # skip the whole month
t = (t.replace(day=1, hour=0, minute=0) + timedelta(days=32)).replace(day=1)
elif not self._day_ok(t): # skip the whole day
t = t.replace(hour=0, minute=0) + timedelta(days=1)
elif t.hour not in self.hours: # skip the whole hour
t = t.replace(minute=0) + timedelta(hours=1)
elif t.minute not in self.minutes:
t += timedelta(minutes=1)
else:
return t
return None

Follow-up questions

  • Add month and weekday names (JAN, MON-FRI) and 7 as Sunday.
  • Support macros like @hourly and @daily by expanding them before parsing.
  • Make next_after time-zone aware. What should happen to a 02:30 job on the night clocks jump from 02:00 to 03:00?

Frequently asked questions

Yes. It is how classic Vixie cron and the POSIX description behave: 30 4 1,15 * 5 runs on the 1st, the 15th and every Friday, not only on Fridays that fall on the 1st or 15th. It surprises people, which is why interviewers ask about it. Some other schedulers, such as Quartz, avoid the ambiguity by requiring ? in one of the two fields.

In Go, store each field as a uint64 bitmask (60 bits cover minutes), which makes membership a shift and an AND, and use time.Time with AddDate and Truncate for the jumps. Libraries such as robfig/cron follow this approach. Production schedulers must also decide what happens around daylight-saving changes, when a local time can occur twice or not at all. Many sidestep this by scheduling in UTC, and Kubernetes CronJobs accept an explicit time zone.