Cron expressions explained: fields, steps and time zones
Cron is the scheduler that runs commands at set times on Unix-like systems, and its five-field schedule format has spread far beyond it: GitHub Actions, Kubernetes and many hosting panels use the same notation. A line such as 30 2 * * 1 reads “at 02:30 every Monday”. Most of the format is easy; a few rules are not, and those are the ones behind jobs that run at the wrong time, twice, or never. This guide follows the POSIX standard and cronie, a widely used Linux cron, and notes where other schedulers differ.
The five fields
A crontab line has five time fields separated by spaces, followed by the command. The job runs in every minute that matches all of them, with one exception for the two day fields, explained below. The standard numbers the days of the week from 0 to 6, with 0 for Sunday; cronie and most Linux crons also accept 7 for Sunday, and three-letter English names such as mon or jan, in any case.
| Field | Values | Example | Means |
|---|---|---|---|
| Minute | 0–59 | 30 | at minute 30 |
| Hour | 0–23 | 2 | at 02:00–02:59 |
| Day of month | 1–31 | 1,15 | on the 1st and the 15th |
| Month | 1–12 or jan–dec | * | every month |
| Day of week | 0–7 or sun–sat (0 and 7 are Sunday) | 1-5 | Monday to Friday |
An asterisk means every value of the field. Times use the 24-hour clock, so 9 is 09:00 and 21 is 21:00.
Lists, ranges and steps
A field can hold a list (1,15), a range (9-17) or a step. A step divides a range: 0-30/10 in the minute field means minutes 0, 10, 20 and 30, and */15 means every value from the first, so minutes 0, 15, 30 and 45. Steps are counted from the start of the range and start again at every new hour, day or month; they do not run on from the previous one.
*/15 * * * *Every 15 minutes: at minutes 0, 15, 30 and 45.0 9-17 * * 1-5On the hour from 09:00 to 17:00, Monday to Friday.0 0 */7 * *At midnight on days 1, 8, 15, 22 and 29: the count restarts every month.*/25 * * * *At minutes 0, 25 and 50, so the gap from :50 to the next :00 is 10 minutes, not 25.
Only intervals that divide 60 evenly (1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30) give equal gaps in the minute field. Every 90 minutes needs two lines: 0 0-21/3 * * * runs at 00:00, 03:00 … 21:00, and 30 1-22/3 * * * at 01:30, 04:30 … 22:30.
cronie also accepts shortcuts: @hourly, @daily (0 0 * * *), @weekly (0 0 * * 0), @monthly (0 0 1 * *), @yearly or @annually (0 0 1 1 *) and @reboot, which runs once at startup. They are not part of the standard: GitHub Actions, for example, does not accept them.
Day of month or day of week
When both the day-of-month and the day-of-week fields hold something other than an asterisk, the standard says a day matches if either one matches, not both. 0 9 13 * 5 therefore runs at 09:00 on every 13th and on every Friday, not only on Friday the 13th. In November 2026 that is the 6th, the 13th (a Friday, which runs once), the 20th and the 27th.
To combine the two, schedule one field and let the command check the other. In a crontab, a % in the command is turned into a new line, so it has to be written \%:
0 9 13 * * [ "$(date +\%u)" = 5 ] && /usr/local/bin/jobFriday the 13th only: runs every 13th, and the job starts when the day of the week is 5 (Friday).0 18 28-31 * * [ "$(date -d tomorrow +\%d)" = 01 ] && /usr/local/bin/reportThe last day of the month, with GNU date (Linux): runs on the 28th to the 31st, and the job starts when tomorrow is the 1st.
Time zones and daylight saving
Cron reads the schedule in the time zone of the machine or service that runs it, which is often UTC on servers and hosted platforms. 0 9 * * * on a UTC server runs at 09:00 or 10:00 in Lisbon and London, and at 10:00 or 11:00 in Paris, Berlin, Madrid or Rome, depending on the season. Brazil has no daylight saving, so in São Paulo it is always 06:00. cronie lets a crontab set its own zone with the CRON_TZ variable; GitHub Actions takes a timezone key next to cron; Kubernetes has the .spec.timeZone field, stable since version 1.27, and refuses CRON_TZ or TZ written inside the schedule.
Daylight saving makes some local times disappear or repeat. On 29 March 2026 the clocks in Paris, Berlin, Madrid and Rome jump from 02:00 to 03:00, so 30 2 * * * names a time that does not exist; on 25 October 2026 the hour from 02:00 to 03:00 happens twice. In Lisbon and London it is the hour from 01:00 to 02:00.
| Scheduler | Job in the skipped hour | Job in the repeated hour |
|---|---|---|
| cronie (changes under 3 hours, jobs at a fixed time or less often than hourly) | runs immediately after the change | runs once |
| GitHub Actions (with timezone) | moves to the next valid time: 02:30 becomes 03:00 | not stated |
| AWS EventBridge Scheduler (with a time zone) | skipped | runs once |
| Schedules kept in UTC | not affected | not affected |
Jobs that run more often than once an hour are scheduled normally by cronie, and its crontab manual warns in general that missing times never match and repeated ones can run twice, so the behaviour differs between crons. The safe choices are to keep the schedule in UTC, or to avoid the hours between 01:00 and 03:00 local time for jobs that must run exactly once a day.
The environment a job runs in
A cron job does not get the environment of your terminal. The standard runs the command with sh and a minimal set of variables (HOME, LOGNAME, PATH and SHELL), so a command that works when you type it can fail in cron because a program is not in PATH or a variable is missing. Use full paths, or set PATH at the top of the crontab.
Anything the command prints is mailed to the owner of the crontab; cronie sends it to the address in MAILTO instead, and sends nothing when MAILTO is empty. On a server without mail set up, that output is easy to lose, so send it to a log file instead.
0 3 * * * /usr/local/bin/backup >> /var/log/backup.log 2>&1Appends both normal output and errors to a log.5 0 * * * tar czf /srv/backup-$(date +\%F).tgz /srv/dataWrites a dated archive: the % signs are escaped.*/5 * * * * flock -n /tmp/sync.lock /usr/local/bin/syncSkips a run while the previous one still holds the lock.
Cron starts each run on time whether or not the previous one has finished, so a job that sometimes takes longer than its interval can end up running twice at once. flock from util-linux holds a lock file while the command runs; with -n it gives up at once, with exit status 1, if the lock is taken. Kubernetes has the same choice built in: concurrencyPolicy is Allow by default, and can be set to Forbid (skip the new run) or Replace (stop the old one).
The same schedule on other platforms
The five-field format is not universal. Some schedulers add seconds or a year, some require a question mark in one of the two day fields because they do not support the either-or rule, and some number the days of the week from 1. “Every weekday at 09:00” in each:
| Scheduler | Expression | Notes |
|---|---|---|
| Linux cron (cronie) | 0 9 * * 1-5 | Machine's time zone, or CRON_TZ. |
| GitHub Actions | 0 9 * * 1-5 | UTC unless timezone is set; at most once every 5 minutes; no @ shortcuts. |
| Kubernetes CronJob | 0 9 * * 1-5 | Controller's time zone, or .spec.timeZone. |
| AWS EventBridge | cron(0 9 ? * MON-FRI *) | Six fields with a year; ? in one day field; days 1–7 start on Sunday; scheduled rules use UTC. |
| Quartz (Java) | 0 0 9 ? * MON-FRI | Seconds first, optional year; ? in one day field; days 1–7 start on Sunday. |
| systemd timer | OnCalendar=Mon..Fri *-*-* 09:00:00 | Weekday and date must both match; *-*~01 is the last day of the month. |
When you copy a schedule between systems, check the number of fields and how the days of the week are numbered: 1 is Monday in cron but Sunday in Quartz and EventBridge.
Why a job did not run
- The machine was off or asleep at the time: cron does not catch up on missed runs. A systemd timer with Persistent=true does, at the next start.
- The schedule is in another time zone, usually UTC.
- The command depends on PATH, a variable or the current folder of your terminal.
- An unescaped % cut the command at that point.
- Day of month and day of week were both set, and the either-or rule ran it on other days, or the job checked for a day that never came.
- On a hosted platform, a run was delayed or dropped: GitHub warns that scheduled runs can be delayed at busy times such as the start of every hour, and disables schedules in public repositories after 60 days without activity.
Before relying on a schedule, read it back field by field, and on Linux check the next runs: systemd-analyze calendar shows them for a timer expression.
Frequently asked questions
Is Sunday 0 or 7?
Both, in cronie and most Linux crons. The POSIX standard only defines 0 to 6, with 0 for Sunday, so 0 is the portable choice.
Can cron run a job every 30 seconds?
No: the smallest unit is the minute. Schedulers with a seconds field, such as Quartz, can; on Linux a systemd timer can too.
How do I run a job every 90 minutes?
With two lines: 0 0-21/3 * * * and 30 1-22/3 * * *. Together they run every hour and a half, from 00:00 to 22:30.
What does 0 0 * * 0 mean?
Midnight at the start of every Sunday. It is the same as @weekly in cronie.
Why does my job run on days I did not choose?
Probably because both day fields are set. Standard cron runs the job when either the day of the month or the day of the week matches.
References
- The Open Group Base Specifications (POSIX.1-2024) — crontab
- cronie — crontab(5): tables for driving cron
- cronie — cron(8): daylight saving time and other time changes
- util-linux — flock(1)
- GitHub Docs — Events that trigger workflows: schedule
- Kubernetes documentation — CronJob
- Amazon EventBridge — Cron expressions for scheduled rules
- Amazon EventBridge Scheduler — Schedule types (time zones and daylight saving)
- Quartz Scheduler — CronTrigger tutorial
- systemd — systemd.timer(5) and systemd.time(7): calendar events
Related guides
- IP addresses explained: IPv4, IPv6 and the reserved ranges
- Subnets and CIDR explained: prefixes, masks and usable hosts
- DNS records explained: A, AAAA, CNAME, MX, TXT and NS
- Is a website down, or is it just you? How to check, step by step
- HTTP status codes and Cloudflare errors: what 403, 404, 429, 500, 503 and 521 mean
- Unix timestamps and time zones explained
- Counting dates: days between dates, leap years and ISO weeks
- Time zones and daylight saving time: meetings across cities
- Image formats explained: JPEG, PNG, WebP, AVIF and HEIC
- Image size explained: pixels, DPI, file size and quality
- Phone photos explained: HEIC, EXIF metadata and your privacy
- Password strength explained: length, entropy and passphrases
- Hash functions explained: SHA-256, checksums and password hashing
- JSON Web Tokens explained: claims, signatures and safe validation
- API keys explained: length, format, storage and rotation
- UUIDs explained: v4, v7 and choosing the right version
- QR codes explained: capacity, error correction and print size
- Percentages and VAT explained: adding, removing and stacking
- Common JSON errors and how to fix them
- CSV files explained: delimiters, quotes, encoding and Excel
- Text encodings explained: UTF-8, Base64 and URL encoding
- Regular expressions explained: classes, quantifiers and groups
- MB or MiB, Mbps or MB/s: file sizes and speeds explained
- UTM parameters explained: tagging links that analytics can read
- Break-even, margin and ROAS explained: the numbers behind a small business
- Freelance rates explained: hourly rate, day rate and billable hours
- Editing PDFs in the browser: what merging, splitting and converting keep