Cron expression parser
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)
CronExpr(expr: str) # raises ValueError on invalid inputmatches(t: datetime) -> bool # does minute t fire?next_after(t: datetime) -> datetime or NoneSupported 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 valuen: a single numbera-b: an inclusive range,a <= b*/s,a-b/s: everys-th value of the range.n/smeans fromnto the field's maximum, everys.
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 aftert, ignoring seconds. If nothing matches within 5 years (for example0 0 31 2 *), returnNone.- 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:
- Month not allowed: jump to the 1st of the next month at 00:00.
- Day not allowed: jump to the next midnight.
- Hour not allowed: jump to the next hour at :00.
- Minute not allowed: step one minute.
- 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.
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)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 NoneFollow-up questions
- Add month and weekday names (
JAN,MON-FRI) and7as Sunday. - Support macros like
@hourlyand@dailyby expanding them before parsing. - Make
next_aftertime-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.