Cron Schedules Explained: Reading and Writing Time-Based Jobs

If you have ever stared at a line like 0 2 * * 1-5 and felt a quiet dread, you are not alone. Cron syntax is one of those things that looks cryptic until it clicks, and then you cannot quite remember why it ever seemed hard. But there is a second layer of complexity that most tutorials skip entirely: what actually happens to your scheduled jobs when timezones shift, when DST rolls in, and when the wall clock does something mathematically rude like jumping forward an hour or folding back on itself. That second layer is where production systems break, and where this article lives.

The Five Fields You Actually Need to Memorize

Classic cron gives you five positional fields, left to right: minute, hour, day-of-month, month, day-of-week. Every field accepts a number, a wildcard (*), a range (1-5), a step (*/15), or a comma-separated list (1,15,29).

┌─────────── minute (0–59)
│ ┌───────── hour (0–23)
│ │ ┌─────── day of month (1–31)
│ │ │ ┌───── month (1–12)
│ │ │ │ ┌─── day of week (0–7, both 0 and 7 = Sunday)
│ │ │ │ │
* * * * *

Some concrete examples worth committing to memory:

  • 0 * * * * — top of every hour
  • */15 * * * * — every fifteen minutes
  • 30 6 * * 1-5 — 6:30 AM on weekdays
  • 0 0 1 * * — midnight on the first of every month
  • 0 2 * * 0 — 2 AM every Sunday

The day-of-week quirk that trips people up: when you specify both day-of-month and day-of-week as non-wildcards, traditional cron treats them as a union, not an intersection. 0 9 1 * 1 fires at 9 AM on the first of the month and at 9 AM every Monday — not only on Mondays that happen to be the first. This is a well-known wart in the spec. If you want the intersection, you need to compute it externally or use a more expressive scheduler.

Extended Syntax: What Your Scheduler Actually Supports

Traditional cron, the one living at /etc/crontab on a Linux box, is deliberately spartan. But most modern job schedulers — Kubernetes CronJobs, GitHub Actions scheduled workflows, AWS EventBridge Scheduler, Quartz (Java), node-cron, APScheduler (Python) — support supersets of this syntax. The two most common additions are seconds and years.

Quartz, for instance, uses six required fields (adds seconds at the start) plus an optional seventh for year. So 0 30 10 * * ? means "10:30:00 AM every day" — the ? in the day-of-week position meaning "I do not care, I already specified day-of-month." Quartz also introduces L (last), W (nearest weekday), and # (nth weekday of month), which are genuinely useful once you know them. 0 0 0 L * ? runs at midnight on the last day of every month. 0 0 9 ? * 2#1 runs at 9 AM on the first Monday of every month.

Before you write a schedule expression, check your scheduler's documentation for which dialect it expects. Pasting a Quartz expression into Kubernetes will produce silent misfires or a validation error, depending on which field is wrong.

Timezone Handling: The Part That Actually Matters in Production

Here is the dirty secret: classic Unix cron runs in the system timezone of the machine. Full stop. Whatever timedatectl says, that is your cron timezone. If you deploy the same crontab to servers in different regions without accounting for this, your "run at midnight" job fires at different UTC times across your fleet.

The de facto modern fix is to standardize everything on UTC at the system level — set your servers to UTC, write your cron expressions in UTC, and convert back to human-readable local time only for display. This is the advice you will find in every serious ops handbook, and it is correct as far as it goes.

But it does not solve the stakeholder problem. When a product manager says "run the billing job at 2 AM Pacific," they mean 2 AM Pacific, not 10 AM UTC (which is what 2 AM Pacific Standard Time translates to). If you encode 0 10 * * * in UTC, that job runs at 2 AM PST in winter and 3 AM PDT in summer, because Pacific time shifts by an hour at DST transitions while UTC does not.

The cleaner solution, now supported by most modern schedulers, is to specify a timezone explicitly in the schedule definition itself. Kubernetes CronJobs gained timezone support in 1.27 via the spec.timeZone field. AWS EventBridge Scheduler lets you set a timezone per schedule. APScheduler accepts a timezone parameter on every job. When you use this, the scheduler handles the UTC math for you and the expression stays anchored to the human-meaningful local time.

# Kubernetes CronJob with explicit timezone (1.27+)
spec:
  schedule: "0 2 * * *"
  timeZone: "America/Los_Angeles"

Use IANA timezone names (America/Los_Angeles, Europe/Berlin, Asia/Kolkata), never abbreviations like PST or IST. Abbreviations are ambiguous — IST is simultaneously India Standard Time, Irish Standard Time, and Israel Standard Time — and many libraries will either reject them or silently pick the wrong one.

DST Transitions: The Two Failure Modes

Daylight Saving Time creates exactly two classes of problem for schedulers, and they are opposite in character.

The spring-forward gap. Clocks jump from 1:59 AM to 3:00 AM. Any job scheduled for 2:00–2:59 AM in that timezone simply does not exist on that wall clock for that one night. Schedulers that work in local time will either skip it silently or fire it immediately before or after, depending on implementation. A missed nightly backup because nobody thought about DST is a real and recurring incident in the industry.

The fall-back duplication. Clocks fall from 2:00 AM back to 1:00 AM. Now 1:00–1:59 AM happens twice. A job scheduled for 1:30 AM may fire twice — once in DST and once in standard time — or only once, again depending on the scheduler. Duplicate billing runs, double-sent emails, idempotency violations: all real consequences.

There is no universal answer that works for every use case, but here are the practical mitigations:

  • Avoid the ambiguous window entirely. If you have flexibility, schedule jobs outside 1:00–3:00 AM local time in regions that observe DST. 3:30 AM, 4 AM, or any off-peak hour outside that band is safe.
  • Run in UTC, convert for display. UTC does not observe DST. A UTC-expressed schedule will fire the same number of times per day, every day, with no surprises. The tradeoff is that it drifts relative to local midnight twice a year.
  • Build idempotent jobs. This is not a scheduling fix, but it is the most robust defense. If your job can safely run twice without producing double effects — idempotency keys, deduplication checks, state machines — then a duplicate fire becomes an annoyance rather than a catastrophe.
  • Check your scheduler's stated DST behavior. Quartz, for instance, documents its behavior explicitly: by default it will fire a job once during fall-back and skip it during spring-forward. Knowing what your tool does lets you compensate appropriately.

Common Mistakes That Show Up in Code Review

A few patterns worth flagging when you see them:

Using */1 instead of *. They are functionally identical, but the former suggests the author was not confident in the syntax. It reads as noise.

Writing 0 0 * * 7 to mean Sunday. Valid on most systems (7 is accepted as Sunday), but 0 is the canonical value. Mixing them in a team codebase creates inconsistency and confusion.

Scheduling something for 0 0 31 * * and expecting monthly behavior. Not every month has 31 days. February, April, June, September, and November will silently skip it. If you want end-of-month, use the scheduler's L syntax if available, or use an application-level check inside a daily job.

Omitting the CRON_TZ variable in crontabs where it matters. Some crontab implementations support CRON_TZ=America/Chicago as a directive at the top of the file. If your system supports it, use it — it makes the file self-documenting about timezone intent rather than depending on system-level configuration that can change silently.

Testing Before You Deploy

Never trust a cron expression you have not verified against a reference implementation. crontab.guru is the standard browser-based tool for classic five-field expressions and shows the next several fire times clearly. For Quartz-syntax expressions, the Quartz CronTrigger tutorial and online Quartz validators exist and are useful.

For timezone-aware verification, write a quick script in whatever language your scheduler uses and print the next ten fire times with full timezone offset information. Five minutes of this can prevent a midnight incident call three months from now when DST rolls over and the billing job runs at 3 AM instead of 2 AM and someone notices.

Cron is one of the oldest abstractions in Unix, and its core syntax has survived essentially unchanged for fifty years because it solves its core problem well. The complexity comes not from the syntax itself but from the gap between how humans think about time — zones, seasons, business hours — and how computers track it. Bridge that gap deliberately, document your timezone assumptions explicitly, and build your jobs to survive duplicate or missed invocations. That is the whole discipline.