ENES
cronEngineering Guide

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)

AS
Published on 2026-10-14Β·14 min readΒ·Daily Toolbox Engineering

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 0 or 1.

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, pasting 0 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 to 0, 15, 30, 45.
  • 10/20 * * * * means starting from minute 10, step by 20: evaluates to 10, 30, 50.
  • The Zero-Minute Fallacy: Many engineers write * / 5 * * * * expecting it to run every 5 minutes. While this works, writing */5 * * * * is equivalent to 0,5,10...55. If you write 5/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 ? * 5L in 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 1W falls 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

  1. 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.
  2. 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_York or Kubernetes spec.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.
  3. 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:

  1. Count the fields: Are you writing for a 5-field POSIX engine (Linux/K8s) or a 6-field Quartz/AWS engine?
  2. Clarify Sunday: Never use numeric 0 or 1 if your engine supports three-letter strings (SUN).
  3. Lock your server to UTC: Never let Daylight Saving Time decide whether your billing pipeline runs once, twice, or never.
  4. 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.

#cron#crontab#scheduling#devops#kubernetes#linux#backend#system-design#node.js
AS
Written by Alex Sun

Alex Sun is the developer behind Daily Toolbox. He writes these guides while building the tools themselves β€” every claim tested against the real thing.