toolhq.io

All posts
7 min readby Jameel Haider

Cron, timezones and daylight saving: the jobs that run twice or not at all

Twice a year, a scheduled job produces a result nobody can explain. The nightly report has two copies for one date, or none. The invoices for one day are missing. The reconciliation totals are doubled.

The cause is almost always the same: a cron expression written in local time, crossing a daylight saving boundary.

Cron has no idea what a timezone is

A cron expression is five fields of wall clock numbers. It carries no timezone. The daemon compares those numbers against the system clock, in whatever zone the system is configured for, and runs the job when they match.

So 30 2 * * * means "run when the local clock reads 02:30". If the local clock never reads 02:30 on a given day, the job does not run. If it reads 02:30 twice, the job runs twice.

In a zone that shifts at 02:00, spring forward jumps straight from 01:59:59 to 03:00:00. The 02:30 slot does not exist. Autumn runs 02:00 to 02:59 twice, once in the old offset and once in the new, so the slot occurs twice.

ExpressionSpring forwardAutumn back
30 1 * * *Runs normallyRuns once
30 2 * * *Skipped entirelyRuns twice
30 3 * * *Runs onceRuns once
*/15 * * * *Loses 4 runsGains 4 runs
0 0 * * *Fine in most zonesFine in most zones

The interval expression is the one people assume is safe. It is not: */15 produces 92 runs on the long day and 88 on the short one, which breaks any job whose logic assumes 96 runs per day.

Implementations disagree about the fix

Vixie cron, the lineage behind most Linux cron daemons, added special handling. For jobs with a fixed time that falls in a skipped interval, it runs them once after the shift. For jobs that would repeat within a duplicated interval, it tries to run them only once. The intent is to do the least surprising thing.

The problem is that this behaviour is not portable. cronie, busybox cron, the cron in Alpine images, macOS launchd, Kubernetes CronJob and every language level scheduler handle the boundary differently, and some do nothing at all. Container images in particular often ship a minimal cron with no special casing, so a job that behaved correctly on a full Debian host misbehaves after being containerised, with no code change to explain it.

Do not rely on the daemon. Arrange for the question never to arise.

Rule one: run the scheduler in UTC

Set the machine, container or scheduler to UTC and write every expression in UTC. UTC has no daylight saving, so every hour occurs exactly once, every day has exactly 24 hours, and every interval expression yields the count you expect.

ENV TZ=UTC

If a job genuinely must happen at 09:00 local time for a user, do the conversion inside the application, not in the schedule. Run the job hourly in UTC, and have it select the users whose local time is currently 09:00. That handles daylight saving, users in different zones, and zones with 30 or 45 minute offsets, none of which a cron expression can express.

Most cron daemons support a CRON_TZ= prefix or a TZ= variable in the crontab, and it is tempting. It solves the "which zone" question without solving the "this hour happens twice" question, and it reintroduces all the ambiguity above. Use it only where a legal or business requirement fixes the local time and you have handled the boundary explicitly. The cron expression generator will show you the next run times for an expression, which is the fastest way to sanity check one before it goes live.

Rule two: avoid 00:00 to 03:00 local

If you cannot move to UTC, at least move the schedule out of the window where shifts happen. Most zones shift between 01:00 and 03:00 local. A job at 04:30 is unaffected in both directions.

Midnight has a separate problem: everybody schedules there. Every cron on the host fires at once, every backup starts, every API you call sees its daily peak. Spreading jobs across off hours costs nothing and removes a class of timeout.

Rule three: make the job idempotent

The scheduling rules reduce the chance of a double run. They do not eliminate it, because retries, manual triggers, failovers and Kubernetes rescheduling can all produce one.

Give each run a deterministic identity, usually the logical date it covers, and make a second run with the same identity a no op. Insert with a unique constraint on that date, or check for an existing record first inside a transaction. A job that can safely run twice is a job you never have to reason about at a DST boundary again. The same principle applied to APIs is covered in idempotency keys.

Pass the logical date in explicitly rather than reading the clock inside the job. A job that computes "yesterday" from now() produces a different answer when it runs late, when it is retried the next morning, or when it is backfilled. A job that takes a date argument produces the same answer every time.

Rule four: prevent overlap

Cron does not check whether the previous run is still going. A job scheduled every five minutes that starts taking six will accumulate processes until the machine dies, and the first symptom is usually database lock contention rather than anything that points at cron.

*/5 * * * * /usr/bin/flock -n /tmp/sync.lock /opt/app/sync.sh

flock -n fails immediately if the lock is held, so the overlapping run exits instead of queueing. For a job spread across several hosts, the lock has to be shared, which means a row in the database or a key in Redis with a TTL, and the TTL must exceed the longest plausible run.

Missed runs when the machine is off

Cron does not catch up. If the host is down at 03:00, the 03:00 job simply did not happen, and nothing records that.

anacron covers this for daily and longer periods on machines that are not always on. systemd timers handle it properly with Persistent=true, which runs the unit on the next boot if the previous trigger was missed:

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=300

systemd timers are worth preferring on a modern Linux host for other reasons too. They log to the journal with the unit, they support OnCalendar expressions that include seconds and years, they can declare dependencies on other units, and RandomizedDelaySec spreads load across a fleet. systemd-analyze calendar "*-*-* 03:30:00" prints the next elapse time so you can verify before deploying.

Kubernetes CronJob

The same expression syntax, different failure modes:

  • spec.timeZone sets an IANA zone for the schedule. Without it, the schedule follows the controller manager's zone, which is usually UTC but is not guaranteed to be. Set it explicitly even when you want UTC.
  • concurrencyPolicy: Forbid is the built in overlap guard. The default, Allow, will happily run ten copies.
  • startingDeadlineSeconds decides how late a missed run may still start. Leaving it unset means the controller looks back a long way and can fire a burst of catch up jobs after an outage. Setting it very low means a brief control plane hiccup silently skips the run.
  • If more than 100 schedule times are missed, the controller stops scheduling entirely and logs an error. A cluster down over a weekend with a five minute job hits that limit and stays stopped until someone notices.

The day of month and day of week trap

Unrelated to timezones, but it catches everyone once. When both the day of month field and the day of week field are restricted, cron treats them as OR, not AND.

0 9 13 * 5    # 09:00 on the 13th, AND on every Friday

That is not "Friday the 13th". It is every 13th plus every Friday, roughly nine runs a month. Only one of the two fields may be restricted if you want a single meaning; matching a compound condition needs a check inside the job. Cron expression examples covers the field syntax in full.

A short checklist

  1. Scheduler runs in UTC. Application converts to local time where a user needs it.
  2. No job scheduled between 00:00 and 03:00 in any zone that observes daylight saving.
  3. Every job takes its logical date as an argument and is safe to run twice with the same one.
  4. Every job that could outlast its interval is wrapped in a lock.
  5. Missed runs are either caught up deliberately or alerted on, never silently dropped.
  6. Both day fields are never restricted at once unless OR is what you meant.

Related reading: cron expression examples covers the syntax itself, timezones and UTC offsets explained covers why local time is hard, and Unix timestamp and epoch time explained covers the representation that avoids all of this.