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
| Character | Meaning | Example | Result |
|---|---|---|---|
* | Every value | * * * * * | Every minute |
, | List of values | 0 6,18 * * * | 6 a.m. and 6 p.m. |
- | Range | 0 9-17 * * * | Hourly, 9 a.m.–5 p.m. |
/ | Step | */10 * * * * | Every 10 minutes |
? | No specific value | 0 0 ? * MON | Quartz-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.
Related guides
How AI Token Counting Works
A token is not a word, and the difference between the two is where most LLM cost estimates go wrong. Here is how tokenizers actually split your text.
LLM API Cost Compared
Published per-million-token prices are only half the equation. Tokenizer differences, caching, batching and reasoning tokens change the real number.
JWT Explained
A JWT is three Base64 segments and a signature. Understanding what the signature does — and does not — prevent is the whole game.