← Back to Cron DST Drift Check

Why cron jobs break across daylight saving

A cron schedule that names a wall-clock time — 30 2 * * *, "run at 02:30" — is only unambiguous in a timezone that never changes its UTC offset. Most timezones people actually live in do change it, twice a year, and a cron expression has no way to say so. That gap produces two separate, genuinely different failure modes, and both are invisible from reading the expression itself.

Failure mode 1: the local time doesn't exist, or exists twice

When clocks spring forward, the clock jumps directly from 01:59:59 to 03:00:00 — 02:00 through 02:59 never happen that day in that timezone. A job scheduled for 02:30 has nothing to run at. When clocks fall back, 01:00 through 01:59 happen twice — a job scheduled for 01:30 has two real, equally valid instants that both match. Neither case is a bug in the cron expression; both are genuine facts about the calendar that most scheduling tools never surface, because most cron parsers only ever reason about which fields match, never about whether the resulting wall-clock time is real.

Failure mode 2: a fixed-UTC scheduler silently drifts

Plenty of schedulers sidestep timezone ambiguity by not having a timezone at all — they always run in UTC. GitHub Actions' own schedule: cron is exactly this, by its own documentation. That is not itself a bug; the bug is assuming a UTC-only schedule keeps hitting the same local time year-round. It does not: 09:00 America/New_York is 14:00 UTC in January (EST, UTC-5) and 13:00 UTC in July (EDT, UTC-4). A workflow scheduled at a single fixed UTC hour is exactly right for one DST segment of the year and an hour off for the other — a defect nobody notices until someone asks "why did this run an hour late" in March or November.

What this tool checks, and what it deliberately doesn't

CheckSourceOut of scope for v0
Spring-forward skip / fall-back overlapResolving a cron occurrence's local wall-clock time against the real IANA timezone database (via Intl) and checking whether it names zero, one, or two real UTC instantsWhat your specific scheduler actually does with a skipped or ambiguous occurrence — that varies by implementation and is out of this tool's reach
Fixed-UTC-cron driftConverting a target local time to UTC once per DST segment of a year and comparing the resultsTimezones with sub-hour or unusual DST offsets are computed correctly (the underlying arithmetic is general), but the report's language assumes the common one-hour case
Cron field parsingStandard 5-field cron (minute hour dom month dow), with the standard OR rule when both day-of-month and day-of-week are restricted; month and day-of-week also accept the standard 3-letter names (JAN-DEC, SUN-SAT), case-insensitiveSeconds fields and non-standard extensions (@daily, L, W)

Wiring it into CI before a scheduled job ships

Check a new or changed schedule before it merges:

- name: Check the deploy cron for DST risk
  run: node ventures/cron-dst-drift-check/cli.mjs next "30 2 * * *" --tz America/New_York

- name: Check the GitHub Actions schedule for UTC drift
  run: node ventures/cron-dst-drift-check/cli.mjs drift 09:00 --tz America/New_York

Both subcommands exit non-zero when they find DST risk, so a schedule that would skip, double-fire, or drift is caught in the same review that introduces it, rather than in an incident channel six months later.

Common questions

Isn't this just what crontab.guru or a cron parser library already does?

A cron expression parser (crontab.guru, node-cron, croniter) tells you which minutes and hours a schedule matches. None of the popular ones surface whether a specific upcoming occurrence's local wall-clock time actually exists that day, or whether a fixed-UTC-only scheduler needs a different UTC time in summer than in winter to hit the same local hour — both are timezone-arithmetic questions the field-matching layer doesn't ask.

Does this accept named months and weekdays, like JAN or MON?

Yes. Month and day-of-week accept the standard 3-letter names (JAN-DEC, SUN-SAT), case-insensitive, anywhere a number is allowed — singly ("MON"), in a list ("MON,WED,FRI"), or as a range ("JAN-MAR"). They are converted to their numeric value with a lookup table before the same numeric parser runs, so every existing rule — lists, ranges, steps, the dom/dow OR rule — applies identically either way. Minute, hour, and day-of-month stay numeric, matching every real cron implementation; a full name ("January") or a typo throws, naming the exact token it did not recognize, rather than guessing.

Can this tell me what my scheduler will actually do at a DST transition?

No, and this is a named limitation rather than a hidden one. Different cron daemons, systemd timers, and language schedulers genuinely disagree on spring-forward and fall-back behavior — some skip, some fire once, some fire twice. This tool computes the timezone fact that is true regardless of implementation (does the local time exist, not exist, or exist twice) and leaves the scheduler-specific behavior to that scheduler's own documentation.