Cron Field Anatomy
A standard Unix crontab line has five whitespace-separated fields that answer when a job runs. Quartz (used by Spring Boot @Scheduled, Jenkins and many Java apps) adds a leading seconds field — six fields total, with an optional seventh year field.
# Unix 5-field (crontab, cron-parser, croniter):
# ┌───────────── minute (0-59)
# │ ┌─────────── hour (0-23)
# │ │ ┌───────── day-of-month (1-31)
# │ │ │ ┌─────── month (1-12 or JAN-DEC)
# │ │ │ │ ┌───── day-of-week (0-7 or SUN-SAT, 0 and 7 = Sunday)
# │ │ │ │ │
# * * * * * command
# Quartz 6-field (seconds first):
# second minute hour day-of-month month day-of-week [year]A Pragmatic 5-Field Validator
A single giant regex that validates every semantic rule is unreadable. Prefer a two-step approach: a cheap shape check with regex, then range-check each field in code.
// Step 1 — shape check (exactly 5 whitespace-separated tokens)
const SHAPE = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)$/;
// Step 2 — a single field is one of: *, number, a-b range, */n step, a-b/n step, or a comma list of those
const FIELD = /^(\*|\d+|\d+-\d+|\*\/\d+|\d+-\d+\/\d+|(\d+|\d+-\d+)(,(\d+|\d+-\d+))+)$/;
// Step 3 — month names and day names (case-insensitive)
const MONTH_NAME = /^(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)$/i;
const DAY_NAME = /^(SUN|MON|TUE|WED|THU|FRI|SAT)$/i;Validating with Range Awareness
Each field has a legal numeric range. The field regex above says the shape is OK, but60 0 * * *is still invalid because minute > 59. Wrap the field regex with a range check:
// Each field's (lo, hi) range for a standard 5-field cron
const RANGES = [
[0, 59], // minute
[0, 23], // hour
[1, 31], // day-of-month
[1, 12], // month
[0, 7], // day-of-week (0 and 7 both = Sunday)
];
function fieldInRange(field, lo, hi) {
if (field === '*') return true;
// Expand steps and ranges, then verify every number is in [lo, hi]
const atoms = field.split(',');
for (const atom of atoms) {
const nums = atom.replace(/\*\//, '') // */5 → 5 (step size, no range check)
.match(/\d+/g) || [];
if (!nums.every(n => +n >= lo && +n <= hi)) return false;
}
return true;
}The Compact One-Liner (When You Want It)
For form validation where you want a single regex that rejects most garbage without a parsing step, this is the pragmatic middle ground. It allows ranges, steps and comma lists, but does not verify numeric bounds — treat it as a fast reject filter.
const CRON_LOOSE =
/^(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+|JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)\s+(\*|[0-9,\-\/]+|SUN|MON|TUE|WED|THU|FRI|SAT)$/i;
CRON_LOOSE.test('*/15 * * * *'); // true
CRON_LOOSE.test('0 0 * * MON'); // true
CRON_LOOSE.test('0 0 1 */3 *'); // true
CRON_LOOSE.test('60 0 * * *'); // true — but minute 60 is invalid (not caught here)
CRON_LOOSE.test('garbage'); // falseQuartz: Six Fields with ? L W #
Quartz adds a seconds field and introduces four special characters in the day-of-month and day-of-week slots: ? (no specific value, must appear in exactly one of dom/dow),L (last day), W (nearest weekday), # (nth weekday of the month, e.g. FRI#3 = third Friday).
const QUARTZ = /^(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)\s+(\*|[0-9,\-\/]+)\s+(\*|\?|[0-9,\-\/LW]+)\s+(\*|[0-9,\-\/]+|JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)\s+(\*|\?|[0-9,\-\/L#]+|SUN|MON|TUE|WED|THU|FRI|SAT)(\s+(\*|[0-9,\-\/]+))?$/i;
QUARTZ.test('0 0 12 * * ?'); // true — noon every day
QUARTZ.test('0 15 10 ? * MON-FRI'); // true — 10:15 weekdays
QUARTZ.test('0 0 12 L * ?'); // true — noon on last day of month
QUARTZ.test('0 0 12 ? * 6#3'); // true — noon on 3rd FridayMatching Shortcut Strings
Vixie cron accepts eight nickname strings. The regex is straightforward and composes cleanly with the field validator via alternation:
const NICKNAME = /^@(reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)$/i;
// Full validator that accepts either a nickname or a 5-field expression:
const CRON_ANY = new RegExp(
'^(' + NICKNAME.source.slice(1, -1) + '|(\\S+\\s+){4}\\S+)$', 'i'
);
CRON_ANY.test('@daily'); // true
CRON_ANY.test('@reboot'); // true
CRON_ANY.test('0 */6 * * *'); // true
CRON_ANY.test('@fake'); // falseLanguage-Specific Usage
JavaScript / TypeScript
const SHAPE = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)$/;
const FIELD = /^(\*|\d+|\d+-\d+|\*\/\d+|\d+-\d+\/\d+|(\d+|\d+-\d+)(,(\d+|\d+-\d+))+)$/;
const RANGES = [[0,59],[0,23],[1,31],[1,12],[0,7]];
function validateCron(expr) {
if (/^@(reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)$/i.test(expr)) return true;
const m = SHAPE.exec(expr);
if (!m) return false;
for (let i = 0; i < 5; i++) {
const f = m[i + 1];
if (!FIELD.test(f) && !/^(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC|SUN|MON|TUE|WED|THU|FRI|SAT)$/i.test(f)) return false;
}
return true;
}Python
import re
NICKNAME = re.compile(r'^@(reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)$', re.I)
SHAPE = re.compile(r'^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)$')
FIELD = re.compile(r'^(\*|\d+|\d+-\d+|\*/\d+|\d+-\d+/\d+|(\d+|\d+-\d+)(,(\d+|\d+-\d+))+)$')
def validate_cron(expr: str) -> bool:
if NICKNAME.match(expr):
return True
m = SHAPE.match(expr)
if not m:
return False
for field in m.groups():
if not FIELD.match(field) and not re.match(r'^[A-Z]{3}(-[A-Z]{3})?$', field, re.I):
return False
return True
# For production, pair with croniter which handles numeric ranges and semantics:
# from croniter import croniter
# croniter.is_valid(expr)Java (Spring Boot / Quartz)
import java.util.regex.Pattern;
static final Pattern QUARTZ = Pattern.compile(
"^(\\*|[0-9,\\-/]+)\\s+(\\*|[0-9,\\-/]+)\\s+(\\*|[0-9,\\-/]+)\\s+" +
"(\\*|\\?|[0-9,\\-/LW]+)\\s+(\\*|[0-9,\\-/]+)\\s+" +
"(\\*|\\?|[0-9,\\-/L#]+)(\\s+(\\*|[0-9,\\-/]+))?$",
Pattern.CASE_INSENSITIVE
);
public static boolean looksLikeCron(String expr) {
return QUARTZ.matcher(expr).matches();
}
// For semantic validation, use org.quartz.CronExpression:
// try { new CronExpression(expr); return true; } catch (ParseException e) { return false; }Common Pitfalls
Day-of-week numbering disagreement
Unix cron and most POSIX implementations use 0-6 (Sunday to Saturday), with 7 as an alias for Sunday. Quartz uses 1-7 (Sunday to Saturday). AWS EventBridge uses 1-7 (Sunday to Saturday). If your regex only allows 0-6, you'll reject valid Quartz expressions.
Mixing dom and dow in Quartz
Quartz requires that exactly one of day-of-month and day-of-week be ?. The regex above allows ? in either field but does not enforce the mutual-exclusion rule — add that check in code.
Non-standard dialects
AWS EventBridge adds @every 15m-style expressions and does not accept the nickname shortcuts. GitLab CI uses standard 5-field. Google Cloud Scheduler supports both Unix 5-field and every 5 minsEnglish syntax. Always check your scheduler's docs before shipping a cross-platform regex.
Seconds field confusion
A 6-field expression without context is ambiguous: is it Quartz (seconds first) or just a typo? Decide which dialect you accept and reject the other with a clear error message rather than silently accepting a shifted expression.
Cron Regex Cheatsheet
| Goal | Pattern | Notes |
|---|---|---|
| 5-field shape | /^(\S+\s+){4}\S+$/ | cheap reject |
| 6-field (Quartz) shape | /^(\S+\s+){5}\S+(\s+\S+)?$/ | optional year |
| Nicknames | /^@(reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)$/i | Vixie cron |
| Single field | /^(\*|\d+|\d+-\d+|\*\/\d+|\d+(,\d+)+)$/ | no bounds check |
| Month / Day names | /^(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC|SUN|MON|TUE|WED|THU|FRI|SAT)$/i | case-insensitive |
Testing Your Cron Regex
Use the live Regex Tester with this canonical test block:
0 0 * * * # valid — midnight every day
*/15 * * * * # valid — every 15 minutes
0 9-17 * * MON-FRI # valid — hourly 9-17 on weekdays
0 0 1 */3 * # valid — midnight on 1st every 3 months
0 0 1,15 * * # valid — midnight on 1st and 15th
@daily # valid — nickname
@fake # INVALID — unknown nickname
60 0 * * * # INVALID — minute 60 (shape OK, range fails)
0 24 * * * # INVALID — hour 24
0 0 * * 8 # INVALID — dow 8 (max is 7)
0 0 1 13 * # INVALID — month 13
not a cron # INVALID — wrong shapeWhen to Give Up and Use a Parser
Regex is a great front-door filter — a 10-character typo should never hit your scheduler. But regex cannot know that Feb 30 is impossible, that 0 0 31 2 * will never fire, or that L means something different in day-of-month versus day-of-week. Pair a loose regex with a real parser: cron-parser for Node, croniter for Python, org.quartz.CronExpression for Java, crontab-go for Go. The regex rejects garbage in milliseconds; the parser catches the semantic edge cases.