How Cron Expressions Really Work: The 5-Field vs 6-Field Syntax, Day-of-Week Traps (Sunday 0 vs 7), Daylight Saving Time Skips, and Production Failure Modes (2026)
Every backend engineer, DevOps specialist, and site reliability engineer has encountered the string 0 0 * * * pasted into a Kubernetes CronJob manifest, a GitHub Actions workflow, or an AWS EventBridge rule.
Invented by Ken Thompson in Unix Version 7 (1979) and later standardized by Paul Vixie in 1987, cron is one of the longest-surviving scheduling abstractions in computing. Yet behind its compact, space-delimited facade lies a minefield of subtle dialect incompatibilities, mathematical gotchas, and production failure modes.
Consider this real-world production incident:
- A batch billing job scheduled with
30 2 * * *(2:30 AM every morning) silently failed to execute on March 8th when the clocks sprang forward for Daylight Saving Time. - Conversely, on November 1st when the clocks fell back, a payroll distribution job executed twice within 60 minutes, generating duplicate payout transactions.
- In another cluster, a migration from a standard Linux crontab to a Java/Spring Quartz scheduler caused a scheduled report to run on Saturdays instead of Sundays because the two engines disagreed on whether Sunday is index
0or1.
This architectural guide demystifies how cron evaluation engines parse time specifications, compares the POSIX standard against enterprise Quartz engines, dissects special characters (*, /, ?, L, W, #), explains how to eliminate Daylight Saving Time outages, and provides production-ready concurrency patterns.
1. The Architectural Divergence: POSIX 5-Field vs. Quartz 6/7-Field Schedulers
The most frequent source of developer confusion is assuming that all cron expressions follow the same structural schema. In modern cloud architectures, you will encounter two fundamentally different formats:
Standard POSIX / Vixie Cron (5 Fields)
Used by: Linux crontab, Kubernetes CronJob, GitHub Actions, Node.js node-cron, Python celery.schedules, and Google Cloud Scheduler.
ββββββββββββββ Minute (0 - 59)
β ββββββββββββββ Hour (0 - 23)
β β ββββββββββββββ Day of the Month (1 - 31)
β β β ββββββββββββββ Month of the Year (1 - 12 or JAN - DEC)
β β β β ββββββββββββββ Day of the Week (0 - 7 or SUN - SAT, where 0 and 7 are Sunday)
β β β β β
* * * * *
Enterprise / Quartz Cron (6 or 7 Fields)
Used by: Spring Framework (@Scheduled), AWS EventBridge (formerly CloudWatch Events), Quartz Scheduler, Apache Camel, and Jenkins.
ββββββββββββββ Seconds (0 - 59)
β ββββββββββββββ Minute (0 - 59)
β β ββββββββββββββ Hour (0 - 23)
β β β ββββββββββββββ Day of the Month (1 - 31)
β β β β ββββββββββββββ Month (1 - 12 or JAN - DEC)
β β β β β ββββββββββββββ Day of the Week (1 - 7 or SUN - SAT, where 1 is Sunday!)
β β β β β β ββββββββββββββ Year (Optional: 1970 - 2099)
β β β β β β β
* * * * * * *
Comparative Architecture Matrix
| Property | POSIX / Linux Cron | Quartz / Spring Cron | AWS EventBridge |
|---|---|---|---|
| Field Count | 5 fields | 6 or 7 fields | 6 fields |
| Sub-minute Resolution | β No (Minute-level minimum) | β Yes (Seconds field included) | β No (Seconds must be 0 or omitted) |
| Sunday Index | 0 and 7 both represent Sunday |
1 is Sunday, 7 is Saturday |
1 is Sunday, 7 is Saturday |
| Day Conflict Handling | Logical OR between Day-of-Month and Day-of-Week | Requires explicit ? in one of the day fields |
Requires explicit ? in one of the day fields |
Special Modifiers (L, W, #) |
β Not supported in pure POSIX | β
Supported (5L, 15W, 2#2) |
β
Supported (L, W, #) |
| Year Specification | β Not supported | β Optional 7th field | β
Mandatory 6th field (or *) |
Critical Danger: Pasting a 5-field expression like
0 12 * * ?into a Linux crontab will trigger a syntax error because?is not a valid POSIX token. Conversely, pasting0 12 * * *into AWS EventBridge will fail validation because AWS requires a?in either Day-of-Month or Day-of-Week!
2. Decoding Special Characters: *, /, -, ,, ?, L, W, #
Cron engines rely on set-theoretical evaluations. Understanding how these symbols map to discrete integer intervals prevents unexpected triggers.
1. The Asterisk (*)
Represents the entire range of valid values for that field. In the minute field, * means "every minute from 0 through 59".
2. The Comma (,)
Defines a discrete list of explicit values.
15,30,45 * * * *triggers at minute 15, minute 30, and minute 45 of every hour.
3. The Hyphen (-)
Defines an inclusive continuous range.
0 9-17 * * *triggers at minute 0 of every hour between 9:00 AM and 5:00 PM inclusive (9 triggers per day).
4. The Slash (/) β Step Intervals
Defines incremental steps across a range. The syntax is <start>/<step>.
*/15 * * * *means starting from 0, step by 15: evaluates to0, 15, 30, 45.10/20 * * * *means starting from minute 10, step by 20: evaluates to10, 30, 50.- The Zero-Minute Fallacy: Many engineers write
* / 5 * * * *expecting it to run every 5 minutes. While this works, writing*/5 * * * *is equivalent to0,5,10...55. If you write5/5, the first trigger of the hour occurs at minute 5, not minute 0.
5. The Question Mark (?) β The Quartz/AWS Disambiguator
In POSIX cron, if both Day-of-Month and Day-of-Week are specified, the engine evaluates them as a logical OR (the job runs if either condition matches).
In Quartz and AWS EventBridge, having wildcards in both fields is strictly forbidden because it introduces ambiguity. You must specify ? (meaning "no specific value / disregard this field") in one of the two day fields:
- Run at 10:15 AM on the 1st of every month:
0 15 10 1 * ? - Run at 10:15 AM every Friday:
0 15 10 ? * FRI
6. The L Character β "Last"
- When used alone in Day-of-Month (
0 0 L * ?), it dynamically resolves to the last calendar day of the current month (28, 29, 30, or 31). - When used in Day-of-Week (
0 0 ? * 5Lin Quartz), it means the last Friday of the month.
7. The W Character β Nearest Weekday (MondayβFriday)
When specified with a day of the month (e.g. 15W), the engine triggers on the weekday closest to that date:
- If the 15th is a Saturday, it triggers on Friday the 14th.
- If the 15th is a Sunday, it triggers on Monday the 16th.
- It will never jump over a month boundary: if
1Wfalls on a Saturday, it triggers on Monday the 3rd, not the previous month.
8. The # Character β Nth Weekday of the Month
The syntax is <day-of-week>#<occurrence>.
0 0 ? * 2#2(in Quartz) translates to: The second Monday of the month (Day 2 = Monday, #2 = 2nd occurrence).- Indispensable for billing cycles, recurring monthly meetings, or maintenance windows that must follow calendar business rhythms (e.g., US Thanksgiving: fourth Thursday in November).
3. The Day-of-the-Week Incompatibility Matrix
The most dangerous cross-platform inconsistency is how engines index the days of the week:
Platform SUN MON TUE WED THU FRI SAT SUN (Alt)
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
POSIX / Linux crontab 0 1 2 3 4 5 6 7
Kubernetes CronJob 0 1 2 3 4 5 6 7
Node-cron (npm) 0 1 2 3 4 5 6 7
Quartz Scheduler 1 2 3 4 5 6 7 N/A
Spring @Scheduled 1 2 3 4 5 6 7 N/A
AWS EventBridge 1 2 3 4 5 6 7 N/A
ISO-8601 Standard N/A 1 2 3 4 5 6 7
Why This Triggers Outages
If you configure a backup job to run on Sunday using 0 0 * * 1 in Linux crontab, it will run on Monday (since Monday is 1).
If you then take that exact same string 0 0 ? * 1 and paste it into AWS EventBridge or a Spring Boot service, it will run on Sunday!
Best Practice: To make your cron expressions completely unambiguous and portable across cloud platforms, always use three-letter string abbreviations (
SUN,MON,TUE,WED,THU,FRI,SAT) instead of numeric indices whenever supported!
4. The Daylight Saving Time (DST) Nightmare
Daylight Saving Time transitions cause catastrophic silent failures for time-scheduled batch jobs:
SPRING FORWARD (March): Clocks skip from 02:00:00 directly to 03:00:00
ββββββββββββββββ [ 1-HOUR VOID ] ββββββββββββββββ
β 01:59:59 AM β βββββββββββββββββββββββββββββββΊ β 03:00:00 AM β
ββββββββββββββββ ββββββββββββββββ
Any job scheduled between 02:00 and 02:59 NEVER RUNS!
FALL BACK (November): Clocks repeat 01:00:00 to 01:59:59 twice
ββββββββββββββββ [ 1-HOUR REPEAT ] ββββββββββββββββ
β 01:59:59 AM β ββ(Clocks roll back to 1am)βββΊ β 01:00:00 AM β
ββββββββββββββββ ββββββββββββββββ
Any job scheduled between 01:00 and 01:59 RUNS TWICE!
How to Immunize Your Architecture Against DST
- The Golden Rule: Run All Schedulers in UTC
Servers, container hosts, and database timestamps must be configured strictly to
Etc/UTC(TZ=UTC). UTC is a monotonic time standard without seasonal jumps or duplicate hours. - If You Must Target Local Business Hours (e.g. 9:00 AM US Eastern):
- Use modern schedulers that natively accept explicit IANA Timezone identifiers (such as
CRON_TZ=America/New_Yorkor Kubernetesspec.timeZone: "America/New_York"). - Schedulers supporting IANA timezones (like Kubernetes 1.27+ and Node-cron) implement transition safety logic: during a Spring Forward gap, they fire the missed job immediately at the transition boundary (03:00:00) rather than dropping it entirely.
- Use modern schedulers that natively accept explicit IANA Timezone identifiers (such as
- Avoid the 02:00β03:00 AM Window: If your system operates on local server time and you cannot migrate to UTC immediately, schedule overnight maintenance at 03:30 AM or 04:00 AM, well outside the DST transition window.
5. The Overlapping Execution Trap (Concurrency Control)
What happens if a job scheduled to run every minute (* * * * *) takes 75 seconds to process due to a slow database query?
By default, standard cron daemons and naive timers spawn a brand new process at the 60-second mark regardless of whether the previous instance has finished.
Time: 00:00 00:60 01:15 01:20 02:00
Job 1: [=========================== DONE ]
Job 2: [=======================================> (Slows down further)
Job 3: [===> (Pile up!)
β²
CONCURRENCY OVERLAP!
Both jobs acquire database locks & exhaust connection pool.
This creates an exponential death spiral known as the thundering herd / scheduler cascade.
Production Concurrency Solutions
1. Linux Local File Locking (flock)
Wrap your crontab command with flock -n (non-blocking exclusive lock):
# If another instance is running, exit immediately with status 0 without blocking:
* * * * * /usr/bin/flock -n /var/lock/my_cron.lock /usr/local/bin/process_queue.sh
2. Kubernetes concurrencyPolicy
In Kubernetes CronJob specifications, explicitly set:
apiVersion: batch/v1
kind: CronJob
metadata:
name: queue-worker
spec:
schedule: "* * * * *"
concurrencyPolicy: Forbid # <--- Drops new executions if previous pod is still running
startingDeadlineSeconds: 30
3. Distributed Database Advisory Locks (PostgreSQL)
In distributed multi-instance environments, use transactional advisory locks:
-- Attempt to acquire an application-level advisory lock:
SELECT pg_try_advisory_lock(987654321);
-- If returns true, run batch processing. If false, terminate immediately!
6. Production Cron Patterns: 15 Copy-Paste Schedules
Below is a verified reference of common production schedules formatted for standard 5-field engines:
| Business Requirement | Cron Expression | Explanation |
|---|---|---|
| Every 5 minutes | */5 * * * * |
Minute divisible by 5 |
| Every 15 minutes | */15 * * * * |
At minutes 0, 15, 30, 45 |
| Every hour on the hour | 0 * * * * |
Top of every hour |
| Every 2 hours | 0 */2 * * * |
At 00:00, 02:00, 04:00... |
| Every weekday at 9:00 AM | 0 9 * * 1-5 |
Monday through Friday at 09:00 |
| Every Saturday at midnight | 0 0 * * 6 |
Once per week at Saturday 00:00 |
| Every Sunday at 3:30 AM | 30 3 * * 0 |
Safe from DST transition |
| Twice daily (08:00 & 20:00) | 0 8,20 * * * |
Morning and evening sync |
| First day of every month | 0 0 1 * * |
Monthly billing / invoice run |
| First weekday of every month | 0 0 1-3 * 1-5 |
Evaluates OR condition |
| Every quarter (Quarterly) | 0 0 1 1,4,7,10 * |
Jan 1, Apr 1, Jul 1, Oct 1 |
| Twice a month (1st and 15th) | 0 0 1,15 * * |
Mid-month and end-month payroll |
| Working hours only (9-5 M-F) | */30 9-17 * * 1-5 |
Every 30 mins during office hours |
| Midnight on New Year's Day | 0 0 1 1 * |
Once per year on January 1st |
| Every minute (High frequency) | * * * * * |
Continuous polling worker |
7. Zero-Dependency TypeScript Cron Evaluator & Next-Run Estimator
Here is a clean, dependency-free TypeScript module that parses standard 5-field cron expressions, expands step and range tokens, and computes the next scheduled execution timestamp:
export interface CronFields {
minutes: number[];
hours: number[];
daysOfMonth: number[];
months: number[];
daysOfWeek: number[];
}
export function parseCronField(field: string, min: number, max: number): number[] {
const values = new Set<number>();
const parts = field.split(",");
for (const part of parts) {
if (part.includes("/")) {
const [subRange, stepStr] = part.split("/");
const step = parseInt(stepStr, 10);
let start = min;
let end = max;
if (subRange !== "*") {
if (subRange.includes("-")) {
[start, end] = subRange.split("-").map(Number);
} else {
start = parseInt(subRange, 10);
}
}
for (let i = start; i <= end; i += step) {
values.add(i);
}
} else if (part.includes("-")) {
const [start, end] = part.split("-").map(Number);
for (let i = start; i <= end; i++) {
values.add(i);
}
} else if (part === "*") {
for (let i = min; i <= max; i++) {
values.add(i);
}
} else {
const num = parseInt(part, 10);
if (!isNaN(num) && num >= min && num <= max) {
values.add(num);
}
}
}
return Array.from(values).sort((a, b) => a - b);
}
export function parseCronExpression(expression: string): CronFields {
const tokens = expression.trim().split(/\s+/);
if (tokens.length !== 5) {
throw new Error(`Invalid cron expression. Expected 5 fields, got ${tokens.length}`);
}
return {
minutes: parseCronField(tokens[0], 0, 59),
hours: parseCronField(tokens[1], 0, 23),
daysOfMonth: parseCronField(tokens[2], 1, 31),
months: parseCronField(tokens[3], 1, 12),
// Standard POSIX: 0 and 7 are Sunday
daysOfWeek: parseCronField(tokens[4], 0, 7).map((d) => (d === 7 ? 0 : d)),
};
}
export function getNextRun(expression: string, fromDate = new Date()): Date {
const fields = parseCronExpression(expression);
const next = new Date(fromDate.getTime());
next.setSeconds(0, 0);
next.setMinutes(next.getMinutes() + 1); // Earliest possible next run is the next minute
// Scan up to 5 years into the future to find matching minute
const maxIterations = 60 * 24 * 365 * 5;
let iterations = 0;
while (iterations < maxIterations) {
const month = next.getMonth() + 1; // 1-12
if (!fields.months.includes(month)) {
next.setMonth(next.getMonth() + 1, 1);
next.setHours(0, 0, 0, 0);
continue;
}
const date = next.getDate();
const day = next.getDay(); // 0 (Sun) - 6 (Sat)
const dayMatch = fields.daysOfMonth.includes(date) && fields.daysOfWeek.includes(day);
if (!dayMatch) {
next.setDate(next.getDate() + 1);
next.setHours(0, 0, 0, 0);
continue;
}
const hour = next.getHours();
if (!fields.hours.includes(hour)) {
next.setHours(next.getHours() + 1, 0, 0, 0);
continue;
}
const minute = next.getMinutes();
if (!fields.minutes.includes(minute)) {
next.setMinutes(next.getMinutes() + 1);
continue;
}
// Found match
return next;
}
throw new Error("No upcoming execution found within 5 years");
}
8. Summary & Interactive Verification
Before deploying any scheduled task to production:
- Count the fields: Are you writing for a 5-field POSIX engine (Linux/K8s) or a 6-field Quartz/AWS engine?
- Clarify Sunday: Never use numeric
0or1if your engine supports three-letter strings (SUN). - Lock your server to UTC: Never let Daylight Saving Time decide whether your billing pipeline runs once, twice, or never.
- Implement concurrency guards: Always configure
concurrencyPolicy: Forbid,flock -n, or database advisory locks to prevent overlapping queue storms.
Try It in Your Browser
Need to inspect, debug, or generate a human-readable cron expression right now? Our in-browser Cron Expression Parser instantly validates 5-field syntax, describes the exact execution schedule in plain English, and calculates the next 5 upcoming run times directly on your device. You can also convert between timezones with our Timezone Converter or generate timestamps with our Unix Timestamp Converter.
π Open Free In-Browser Cron Expression Parser β
FAQ
Why did my cron job stop running on the 31st of the month?
If you wrote 0 0 31 * *, your job will only run in January, March, May, July, August, October, and December. In 30-day months and February, the 31st does not exist, so the job is silently skipped. In Quartz, use 0 0 L * ? to target the last day of every month dynamically.
What is the difference between * * * * * and */1 * * * *?
They are semantically identical in every standard parser. Both trigger at minutes 0, 1, 2, 3... 59 of every hour.
Can cron run a task every 30 seconds?
Standard POSIX cron cannot; its minimum granularity is 1 minute. Schedulers that support 6 fields (like Quartz or systemd timers) can achieve sub-minute scheduling. On standard Linux, developers either use systemd timers (OnUnitActiveSec=30s) or a wrapper script with a sleep 30 loop.
Why does crontab execute with different environment variables than my shell?
The cron daemon executes jobs in a minimal, non-interactive environment (/bin/sh). Your interactive shell configuration files (~/.bashrc, ~/.zshrc, and custom PATH exports) are not loaded. Always specify absolute binary paths (e.g., /usr/bin/node instead of node) or source your environment file explicitly at the start of your crontab line.