Cron Expressions Explained

Cron is five fields and a set of rules most people learn by trial and error. Here is the whole thing, including the OR rule that causes real outages.

Cron has been scheduling jobs on Unix systems since the 1970s, which means it is both battle-tested and full of behaviours that made sense in a context nobody remembers. Most people learn it by copying an expression and editing it until it works. That is fine until it isn't — usually at the point where a job runs twice, or not at all, and nobody can say why.

The five fields

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

Fields are separated by whitespace, and every field must be present. In a crontab file, the command follows the five fields; in most managed schedulers (Kubernetes CronJob, GitHub Actions, cloud scheduler services) only the expression itself is given. Some systems — Quartz, Spring, certain Kubernetes variants — use six or seven fields by adding seconds and/or year. Standard Unix cron is five.

Special characters

CharacterMeaningExampleResult
*Every value* * * * *Every minute
,List of values0 6,18 * * *6 a.m. and 6 p.m.
-Range0 9-17 * * *Hourly, 9 a.m.–5 p.m.
/Step*/10 * * * *Every 10 minutes
?No specific value0 0 ? * MONQuartz-style; day fields

Ranges and steps compose. 9-17/2 in the hour field means 9, 11, 13, 15, 17. Lists and ranges compose too: 0 0 1,15 1,4,7,10 * runs at midnight on the 1st and 15th of January, April, July and October.

Steps count from the start of the range

This is a small detail with a large behavioural consequence. */15 in the minute field means minutes 0, 15, 30 and 45 — always. It does not mean "fifteen minutes after the job was created", and it does not drift. Cron computes whether the current time satisfies the expression; it never schedules relative to install time.

Two expressions that are commonly confused:

30 * * * *    # once per hour, at 30 minutes past  (24 runs/day)
*/30 * * * *  # every 30 minutes, at :00 and :30    (48 runs/day)

Similarly, a step with an explicit start is legal and occasionally useful: 5/15 in the minute field means 5, 20, 35, 50.

The OR rule that trips everyone up

Here is the behaviour responsible for the largest number of "why did my job run on the wrong day" incidents.

When both day-of-month and day-of-week are restricted (that is, neither is *), standard cron treats them as a logical OR. The job runs when either field matches.

0 0 1 * 1
# Reads like: "midnight on the 1st, if it's a Monday"
# Actually:   midnight on the 1st of the month
#             AND midnight every Monday

So this expression runs roughly five times a month, not once. If you genuinely want "the first Monday of the month", standard cron cannot express it. Your options:

  • Put the condition in the script. Run every Monday and check the date: 0 0 * * 1 [ "$(date +\%d)" -le 7 ] && /opt/scripts/report.sh
  • Use systemd timers, which support calendar expressions such as OnCalendar=Mon *-*-1..7 03:00:00.
  • Use a job framework with richer syntax — Quartz, Celery Beat, or a workflow engine.

Note the escaping in the first option: in a crontab, % has special meaning and must be written as \%. Forgetting that is its own classic bug — the command runs but the date format string is truncated, so the condition silently evaluates differently than you intended.

Timezones and DST

Cron runs in the system's local timezone. On a cloud server that is usually UTC, which is almost never what the person writing the schedule meant.

CRON_TZ=America/New_York
0 9 * * 1-5 /opt/scripts/digest.sh

Set CRON_TZ explicitly whenever the wall-clock time matters. Do not inherit the server default, because somebody will eventually rebuild the host in a different region and your 9 a.m. job will move.

Daylight saving transitions cause two distinct problems:

  • In spring, the clock jumps forward. A job scheduled at 02:30 on the transition day does not run — that time does not exist.
  • In autumn, the clock falls back. That time occurs twice, so a job may run twice.

For anything that must run exactly once per day — backups, billing, report generation — avoid scheduling between 01:00 and 03:00 local time. If you can, run the host in UTC and convert at the edge; UTC has no DST and this class of bug disappears entirely.

A cookbook of expressions

* * * * *            Every minute
*/5 * * * *          Every 5 minutes
0 * * * *            Top of every hour
0 */2 * * *          Every 2 hours, on the hour
30 3 * * *           03:30 daily
0 9 * * 1-5          09:00 on weekdays
0 0 * * 0            Midnight every Sunday
0 0 1 * *            Midnight on the 1st of each month
0 0 1 1 *            Midnight on 1 January
0 0 1,15 * *         Midnight on the 1st and 15th
0 2 * * 1            02:00 every Monday
0 9-17 * * 1-5       Hourly during business hours, weekdays
0 0 1 1,4,7,10 *     Quarterly, on the first of the quarter
15 14 1 * *          14:15 on the first of the month
0 22 * * 1-5         22:00 on weekdays
*/10 9-17 * * 1-5    Every 10 minutes during business hours

Use our Cron Expression Generator to verify any of these in plain English and preview the actual next run times before you commit them to a crontab.

Production patterns

Stagger your schedules

Enormous numbers of cron jobs worldwide are scheduled at exactly midnight or exactly on the hour. If your job calls a third-party API, you are competing for it at precisely the moment of highest contention — and you will see timeouts that look random but are not. Use an offset: 17 3 * * * instead of 0 3 * * *. This costs nothing and removes a whole category of flaky failure.

Guard against overlap with a lock

Cron has no memory of whether the previous run finished. If a job takes longer than its interval, instances pile up — dangerous for anything that moves money or mutates data. flock is the standard fix:

*/5 * * * * /usr/bin/flock -n /tmp/myjob.lock /opt/scripts/myjob.sh

The -n flag makes it exit immediately if the lock is held, so a slow run skips the next tick rather than queueing behind it.

Always redirect output

By default cron emails stdout and stderr to the crontab owner. On most servers mail is not configured, so output is silently discarded and you have no idea why a job failed. Always log:

0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

Set PATH and SHELL explicitly

Cron runs with a minimal environment — typically PATH=/usr/bin:/bin and SHELL=/bin/sh. Scripts that work in your interactive shell fail under cron because they call binaries in /usr/local/bin, ~/bin, or a language version manager, or because they reference environment variables from your profile. Either use absolute paths throughout, or declare what you need at the top of the crontab:

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

Make jobs idempotent

Assume every job will occasionally run twice or be interrupted mid-way. Design so that re-running is safe: write to a temporary file and atomically rename, use upserts rather than blind inserts, and check a state marker before doing work. Idempotence turns a whole class of incident into a non-event.

Alert on absence, not just failure

A job that crashes can be caught by its exit code. A job that never runs at all — because the crontab was lost in a redeploy, or the host was replaced — produces silence. For anything important, emit a heartbeat and alert when the heartbeat is missing.

When cron is the wrong tool

Cron is excellent for periodic, stateless, idempotent work. It is a poor fit when you need any of the following:

  • Retries with backoff. Cron has no retry semantics. A failed run is simply lost until the next tick.
  • Dependencies between steps. "Run B only if A succeeded" requires chaining in the script, and the failure modes get ugly fast.
  • Distributed execution. Cron runs on one host. If that host dies, the job does not run, and running the same crontab on several hosts causes duplicate execution unless you add coordination.
  • Observability. Cron gives you no history, no metrics and no dashboard. You build that yourself.
  • Second-level or calendar-aware scheduling. Cron resolution is one minute, and it cannot express "last Friday of the month".

The usual successors are systemd timers (better logging, calendar syntax, still simple), a job queue (Celery, Sidekiq, BullMQ) for application-level work with retries, or a workflow engine (Airflow, Temporal, Prefect) for pipelines with dependencies and history.

Frequently asked questions

The day-of-month and day-of-week fields are combined with OR when both are restricted. Use * in one of them, or move the day condition into your script.

Standard cron cannot — its resolution is one minute. Use two entries with a sleep, put the loop in your script, or switch to systemd timers, which support second-level scheduling.

Jobs scheduled between roughly 01:00 and 03:00 local time may be skipped in spring or run twice in autumn. Schedule outside that window, or run the host in UTC.

Almost always the environment: cron uses a minimal PATH and does not load your shell profile. Use absolute paths for every binary, or declare PATH and SHELL at the top of the crontab.

In a crontab, % marks the start of stdin data for the command and must be escaped as \%. This commonly breaks date +%d, which silently produces wrong output rather than an error.

Some implementations add seconds or a year field, but standard Unix cron is five. Check your platform: Kubernetes CronJob is five, Quartz is six or seven, and GitHub Actions is five.