Cron Expressions Explained: Syntax, Examples & Gotchas (2026)
A cron expression is five fields that say when a job fires: minute, hour, day of month, month and day of week. That much fits on a sticky note. What doesn't fit is the stuff that actually breaks schedules: the day-field OR rule, steps that restart every hour, DST, and which clock the scheduler is reading. This guide covers the syntax, those gotchas, the main dialects, and how to read cron entries when you're auditing a box.
Have a schedule to check? Paste it into the free Cron Expression Parser and read the next run times before you trust it.
The outputs below were captured on 10 October 2026 (IST) on a Debian 13 box with Python 3.13 and croniter 6.2.4, systemd 257 and Debian's cron package (3.0pl1-197).
The five fields
From cronie's crontab(5) man page:
| Field | Allowed values |
|---|---|
| Minute | 0-59 |
| Hour | 0-23 |
| Day of month | 1-31 |
| Month | 1-12 or names (jan, feb ...) |
| Day of week | 0-7 (0 or 7 is Sunday) or names (mon, tue ...) |
So 30 2 * * 1 means 02:30 every Monday. In a user's crontab the rest of the line is the command. In /etc/crontab and /etc/cron.d/ a user name comes before the command, because those are system jobs.
The POSIX crontab spec is narrower than what Linux crons accept: day of week is 0-6 with 0 as Sunday, and it defines only *, lists and ranges. Steps (/) and names are extensions.
The four operators
| Operator | Meaning | Example | Reads as |
|---|---|---|---|
* | every value (first to last) | * * * * * | every minute |
, | list | 0 9,17 * * * | 09:00 and 17:00 |
- | inclusive range | 0 9 * * 1-5 | 09:00 Monday to Friday |
/ | step through a range | 0 8-18/2 * * 1-5 | every two hours from 08:00 to 18:00 on weekdays |
*/5 is a step over the whole field: minutes 0, 5, 10 and so on. Here's a library computing the next runs from 09:44 on a Saturday:
python3 -c "
from croniter import croniter
from datetime import datetime
for expr in ('*/5 * * * *', '*/7 * * * *', '0 8-18/2 * * 1-5'):
it = croniter(expr, datetime(2026, 10, 10, 9, 44))
print(expr, '->', ' '.join(it.get_next(datetime).strftime('%a %H:%M') for _ in range(5)))
"
*/5 * * * * -> Sat 09:45 Sat 09:50 Sat 09:55 Sat 10:00 Sat 10:05
*/7 * * * * -> Sat 09:49 Sat 09:56 Sat 10:00 Sat 10:07 Sat 10:14
0 8-18/2 * * 1-5 -> Mon 08:00 Mon 10:00 Mon 12:00 Mon 14:00 Mon 16:00
Look at the */7 line. More on that below.
Shortcuts: @reboot, @daily and friends
Instead of the five fields, cron accepts a nickname. Per crontab(5):
| Nickname | Same as |
|---|---|
@reboot | once, when the cron daemon starts |
@yearly / @annually | 0 0 1 1 * |
@monthly | 0 0 1 * * |
@weekly | 0 0 * * 0 |
@daily | 0 0 * * * |
@hourly | 0 * * * * |
Debian's cron also lists @midnight as the same as @daily (Debian crontab(5)). That page notes that @reboot means when the cron daemon starts, which can be before other services are up.
Gotchas that break schedules
1. Day of month and day of week combine with OR
If both day fields are restricted, the job runs when either matches. The man page's own example is 30 4 1,15 * 5: 04:30 on the 1st and 15th, plus every Friday. POSIX says the same thing. Here's that expression in croniter, which applies the same OR rule by default, from 1 October 2026:
python3 -c "
from croniter import croniter
from datetime import datetime
it = croniter('30 4 1,15 * 5', datetime(2026, 10, 1, 5, 0))
print([it.get_next(datetime).strftime('%a %d %b') for _ in range(6)])
"
['Fri 02 Oct', 'Fri 09 Oct', 'Thu 15 Oct', 'Fri 16 Oct', 'Fri 23 Oct', 'Fri 30 Oct']
If you wanted "the 15th, but only if it's a Friday", cron can't say that in the fields. Use one day field and test the other inside the command, or use a scheduler with #/L syntax.
Debian's man page adds a quirk: its cron only checks whether a day field starts with *. So 0 0 */2 * sun runs on Sundays that fall on odd dates there, where POSIX would run it every Sunday and every odd date.
2. Steps restart at the top of each range
Steps are evaluated within their own field. */7 in minutes means 0, 7, 14 ... 56, then 0 again. That's why the run above goes 09:56, then 10:00: a 4-minute gap. crontab(5) gives the same warning: */35 runs at :00 and :35, not every 35 minutes. If you need a true fixed interval that doesn't divide 60 (or 24), cron fields can't express it.
3. DST: skipped hours and doubled hours
crontab(5) is blunt: times that don't exist during a daylight-saving change "will never match," and times that occur twice "will cause matching jobs to be run twice." Quartz's docs give the same warning for early-morning fire times. Either run servers in UTC or keep critical jobs out of the changeover window.
4. Which timezone?
Cron reads the time zone the daemon runs in, not yours. cronie (the crontab(5) page above) supports a CRON_TZ variable for a per-crontab zone. Debian's cron says it does not support per-user timezones, and a TZ= line only affects the commands, not when they fire. Cloud schedulers have their own rules (see the dialect table). If you're working with timestamps alongside schedules, Unix epoch time explained and the Unix timestamp converter help.
5. % in the command is a newline
In the command part, an unescaped % becomes a newline and everything after the first one is sent as standard input. A date +%F in a crontab line needs \%.
Before you ship any expression, run it through the Cron Expression Parser and read the next few run times, especially around month ends and weekends.
Dialects: not every "cron" is the same
| Scheduler | Fields | Notable rules |
|---|---|---|
| Vixie/cronie/Debian cron | 5 | @ nicknames; 0 or 7 = Sunday; OR rule on day fields |
| Quartz | 6-7 (seconds first, optional year) | ?, L, W, #; day of week 1-7 = SUN-SAT; set ? in one day field |
| AWS EventBridge rules | 6 (cron(...), with year) | can't use * in both day fields; ? in one is required; nothing faster than 1 minute |
| Kubernetes CronJob | 5 | @ macros; .spec.timeZone for zone; CRON_TZ/TZ in the schedule fails validation |
GitHub Actions schedule | 5 (POSIX syntax) | UTC by default, optional IANA timezone; shortest interval 5 minutes; can be delayed at high load |
A few specifics from those docs:
- Quartz:
Lin day of month is the last day.6Lin day of week is the last Friday.15Wis the weekday nearest the 15th.6#3is the third Friday. Quartz says support for setting both day fields "is not complete", so you put?in one. - AWS:
cron(0 10 * * ? *)is 10:00 UTC daily. The newer EventBridge Scheduler takes a time zone and adjusts for DST. A run that falls in a skipped hour is skipped, and the repeated hour runs once. - Kubernetes: without
.spec.timeZone, the schedule follows the kube-controller-manager's local time zone. - GitHub Actions: scheduled workflows run on the default branch. GitHub says runs can be delayed, especially at the start of every hour, and queued jobs may be dropped under heavy load. In public repos, schedules are disabled after 60 days of no repository activity.
systemd timers aren't cron, but they solve the same problem with OnCalendar= (systemd.time(7)). systemd-analyze calendar normalizes an expression and shows the next elapses:
TZ=UTC systemd-analyze calendar --base-time="2026-10-10 09:44:00 UTC" --iterations=2 "*:0/5" "Mon..Fri 09:00" | grep -v "From now"
Original form: *:0/5
Normalized form: *-*-* *:00/5:00
Next elapse: Sat 2026-10-10 09:45:00 UTC
Iteration #2: Sat 2026-10-10 09:50:00 UTC
Original form: Mon..Fri 09:00
Normalized form: Mon..Fri *-*-* 09:00:00
Next elapse: Mon 2026-10-12 09:00:00 UTC
Iteration #2: Tue 2026-10-13 09:00:00 UTC
Auditing cron entries (defensive)
Cron is a common persistence spot, so it's on every host review. MITRE ATT&CK tracks it as T1053.003, Scheduled Task/Job: Cron. The goal here is reading and detecting, nothing else.
Where entries live on Debian-family systems, per the man pages:
/etc/crontab: the system crontab (has a user column)/etc/cron.d/: drop-in system crontabs (user column too)/var/spool/cron/crontabs/: per-user crontabs on Debian (cronie uses/var/spool/cron/)/etc/cron.hourly,.daily,.weekly,.monthly: scripts run byrun-parts
On the test box, the stock /etc/crontab looks like this:
-rw-r--r-- 1 root root 1042 Jun 13 2025 /etc/crontab
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
17 * * * * root cd / && run-parts --report /etc/cron.hourly
25 6 * * * root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }
47 6 * * 7 root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.weekly; }
52 6 1 * * root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.monthly; }
A scratch user crondemo was given one entry. Listing it, then finding it in the spool:
sudo crontab -u crondemo -l
sudo grep -rn backup-check /var/spool/cron/crontabs/
15 3 * * 1 /usr/local/bin/backup-check
/var/spool/cron/crontabs/crondemo:4:15 3 * * 1 /usr/local/bin/backup-check
A quick sweep for @reboot lines and every-minute entries across the system locations:
sudo grep -rnE '^[^#]*@reboot|^[^#]*(\*[[:space:]]+){4}\*' /etc/crontab /etc/cron.d /var/spool/cron || echo 'no matches'
no matches
On a clean box, that's what you want. When it does match, ask: who owns the entry, when did the file change, and does the command point at something odd (a hidden path, a download, a script in /tmp)? MITRE's mitigations include reviewing changes to cron schedules and limiting who can use cron with /etc/cron.allow and /etc/cron.deny. Record what you find. Don't "fix" a live host during an incident until the evidence is captured.
Next steps
For hands-on crontab -e, environment variables and job management, follow the cron sections in the Linux process management tutorial. If you're scheduling your own scripts, Bash scripting for hackers covers the script side.
FAQ
What does */5 * * * * mean?
Every five minutes, on minutes 0, 5, 10 ... 55 of every hour.
Is 0 or 7 Sunday?
On Vixie-derived crons, both. POSIX and GitHub Actions use 0-6. Quartz and AWS use 1-7 with 1 as Sunday.
Why did my job run twice, or not at all?
Usually DST (a repeated or missing local hour), the day-field OR rule, or a timezone mismatch. On GitHub Actions, load delays and dropped runs are documented too.
Does cron use UTC?
Classic cron uses the daemon's local time zone. GitHub Actions defaults to UTC and EventBridge scheduled rules run in UTC. Kubernetes uses the controller manager's zone unless .spec.timeZone is set.