Cron Expressions Explained: A Complete Guide with Examples
Cron expressions explained field by field, with 10 worked examples, the day-of-month vs day-of-week trap, timezone pitfalls, and how to test before deploying.
Someone asks for a report to be emailed every Monday at 7am. “Just add a cron job,” they say. You open the crontab, and you are looking at five asterisks and a wall of comments written by a person who left the company in 2019.
Cron syntax looks cryptic, but it is genuinely small — five fields and four special characters. An hour spent understanding it properly pays for itself the first time a job silently runs 24 times a day instead of once.
What cron actually is
Cron is the scheduler that has shipped with Unix systems for decades. A background daemon wakes up every minute, checks every scheduled entry, and runs any command whose schedule matches the current time.
The schedule itself is a cron expression: a single line of five space-separated fields describing when to run. The same syntax has been adopted almost everywhere else — Kubernetes CronJobs, GitHub Actions, most CI systems, and many job libraries all speak it.
The five fields
Read left to right, smallest unit first:
┌───────────── minute (0–59)
│ ┌─────────── hour (0–23)
│ │ ┌───────── day of month (1–31)
│ │ │ ┌─────── month (1–12, or JAN–DEC)
│ │ │ │ ┌───── day of week (0–6, or SUN–SAT)
│ │ │ │ │
* * * * * command to run
Two notes on ranges. Hours use a 24-hour clock, so there is no AM/PM — 14 means 2pm. And day-of-week counts Sunday as 0; many implementations also accept 7 for Sunday, which is why you see both.
Some systems extend this. Quartz (common in the Java world) puts seconds in front, making six fields, and adds an optional year at the end. Always check whether your scheduler expects five or six fields before copying an expression from the internet — a six-field expression pasted into a five-field parser shifts every unit.
The four special characters
Everything expressive in cron comes from these:
*— every value. In the hour field it means “every hour”.,— a list.1,15in day-of-month means the 1st and the 15th.-— a range.1-5in day-of-week means Monday through Friday./— a step.*/15in the minute field means every 15 minutes: 0, 15, 30, 45.
Steps combine with ranges, which is where cron gets useful. 0-30/10 means “every 10 units from 0 to 30” — so 0, 10, 20, 30, and nothing after. Read */n as shorthand for “the whole range, every n”.
Many crons also accept shorthand strings in place of the whole expression: @hourly, @daily, @weekly, @monthly, @yearly, and @reboot. They are readable, but they hide the exact minute a job fires, which matters when several jobs are competing for the same machine.
Ten expressions worth knowing
| Expression | When it runs |
|---|---|
*/5 * * * * | Every 5 minutes |
0 * * * * | Every hour, on the hour |
30 2 * * * | Every day at 2:30 AM |
0 */6 * * * | Every 6 hours (00:00, 06:00, 12:00, 18:00) |
0 9 * * 1-5 | 9:00 AM, Monday to Friday |
*/15 9-17 * * 1-5 | Every 15 minutes from 9:00 to 17:45, weekdays |
0 3 * * 0 | 3:00 AM every Sunday |
0 0 1 * * | Midnight on the 1st of every month |
15 14 1,15 * * | 2:15 PM on the 1st and 15th of the month |
5 0 * 8 * | 00:05 every day during August |
Two things to notice. 0 */6 * * * runs at midnight, not at “six hours from now” — steps are anchored to the start of the range, not to when you deployed. And */15 9-17 * * 1-5 stops at 17:45, not 17:00, because hour 17 is included and each of its quarter-hours fires.
The gotcha that catches everyone: day-of-month vs day-of-week
Here is the rule that surprises even experienced people.
If both the day-of-month and day-of-week fields are restricted (neither is *), the job runs when either matches — not both. They are OR’d, not AND’ed.
So this expression:
0 0 13 * 5
does not mean “midnight on Friday the 13th”. It means “midnight on the 13th of every month, and also midnight every Friday” — which is roughly five times more often than intended.
If only one of the two fields is restricted and the other is *, the behaviour is the intuitive one. The OR only kicks in when both are set. If you genuinely need “the first Monday of the month”, plain cron cannot express it: either add a guard inside your script ([ "$(date +%d)" -le 7 ] || exit 0) or use a scheduler with the extended # syntax.
Time zones and server time
A cron expression carries no time zone. It is interpreted in whatever zone the scheduling daemon is running in — and on cloud servers and in containers, that is almost always UTC.
This is the source of a lot of confusion. A job written as 0 9 * * * by someone in Berlin who wanted 9am local will fire at 11am their time in summer. Check with date on the actual host rather than assuming.
Daylight saving is worse. When clocks jump forward, a job scheduled inside the skipped hour has no valid time to run; when clocks fall back, an hour repeats and a job can fire twice. Exactly what happens depends on the cron implementation, so the safest approach is:
- Run the daemon in UTC and do time-zone conversion inside your application.
- Avoid scheduling anything in the 1–3 AM window where transitions happen.
- Make jobs idempotent — running the same job twice should not produce two invoices.
Overlapping runs
Cron will happily start a new run while the previous one is still going. Schedule something */5 that occasionally takes seven minutes, and you now have two copies competing for the same database rows. Under sustained load this compounds until the box falls over.
Cron has no built-in guard. Add one with a lock file:
*/5 * * * * /usr/bin/flock -n /tmp/sync.lock /opt/app/sync.sh
flock -n acquires the lock or exits immediately, so a late-running job simply skips the next tick instead of stacking.
While you are in there, two more habits worth building. Cron runs with a minimal environment — a short PATH, no shell profile, no nvm or pyenv shims — so always use absolute paths to interpreters and scripts. And redirect output, because otherwise cron tries to email you stdout and stderr, which usually means it disappears:
30 2 * * * /usr/local/bin/python3 /opt/app/report.py >> /var/log/report.log 2>&1
One small syntax trap: inside a crontab, an unescaped % is treated as a newline. If your command contains a date +%Y-%m-%d, escape each percent as \% or move the command into a script.
Test before you deploy
The failure mode with cron is quiet. A wrong expression does not throw an error — it just runs at the wrong time, possibly for weeks, until someone notices the numbers look odd.
So verify the schedule before it ships. Build the expression in the Cron Expression Generator, which turns it back into plain English and shows the next several run times. If those timestamps do not match what you pictured, you found the bug in ten seconds instead of after the next billing cycle.
It also helps to log every run with a unique identifier so you can trace an individual execution through your logs — a value from the UUID Generator works well as a run ID.
Quick answers
How do I run a job every 5 minutes? */5 * * * *.
Why did my “Friday the 13th” job run every Friday? Because day-of-month and day-of-week are OR’d when both are set. Guard the date inside your script instead.
What time zone does cron use? The scheduling host’s zone, which on servers and containers is usually UTC. Nothing in the expression itself specifies a zone.
Does cron wait for the previous run to finish? No. Use flock or an application-level lock if overlap would cause problems.
Why does my script work in the terminal but not in cron? Almost always the environment: a different PATH, missing environment variables, or a relative path that resolved against a different working directory. Use absolute paths everywhere.
The takeaway
Cron is five fields and four symbols, and nearly every real-world bug comes from three things: the day-of-month/day-of-week OR rule, an unexpected server time zone, and runs that overlap. Get those right, log the output, and confirm the next run times before you commit — the syntax itself will stop being the hard part.