Developer
Cron expressions explained, including the day-of-month trap
A cron line is short enough to read at a glance and subtle enough to be wrong in ways that only show up weeks later: a job that fires every Monday when the intent was the 15th, a step that leaves an uneven gap at the top of the hour, a schedule that runs twice on one night in autumn. This guide walks through the standard five-field format, marks which parts come from POSIX and which are extensions, explains the rule that catches people most often, and shows how to check a schedule against a fixed start date before it goes live.
By the MaffeiTools team · Updated · 8 min read
The five fields, and what the POSIX baseline actually allows
A standard crontab schedule is five space-separated fields: minute (0-59), hour (0-23), day of month (1-31), month (1-12) and day of week. The command follows. Each field takes an asterisk for every value, a single number, a range such as 9-17, or a comma-separated list of those. That is close to everything the POSIX specification defines. POSIX gives day of week as 0 to 6 with 0 as Sunday, and it has no step syntax, no month or weekday names, and no at-sign shortcuts.
Everything else you are used to is an extension. The crontab(5) page distributed with the cronie project, which carries Paul Vixie's copyright notice, adds step values such as */2 and 0-23/2, three-letter names for months and weekdays (case does not matter, and ranges and lists of names work), and 7 as a second spelling of Sunday. It also adds shortcuts in place of the five fields: @yearly and @annually for 0 0 1 1 *, @monthly for 0 0 1 * *, @weekly for 0 0 * * 0, @daily and @midnight for 0 0 * * *, @hourly for 0 * * * *, and @reboot to run once at startup.
A portable schedule sticks to numbers, ranges and lists. A schedule for one known machine can use the extensions, provided you have confirmed that machine's cron accepts them. The cron expression parser on this site accepts the extensions above except @reboot, which has no clock time to preview.
Steps restart inside each field, so uneven gaps are normal
A step is evaluated only within the field it appears in. Cron does not carry a running count from one hour into the next. */7 in the minute field means minutes 0, 7, 14 and so on up to 56, and then the next hour begins again at 0, so the gap across the hour boundary is four minutes, not seven. The same page uses */23 in the hour field to make the point: it fires at hours 0 and 23 of each day, not every 23 hours.
When you need a true interval that does not divide the field evenly, do not stretch a step. Either choose a step that divides evenly (every 5, 10, 15 or 30 minutes; every 2, 3, 4, 6 or 12 hours), or run the job on a shorter fixed schedule and let the script decide whether enough time has passed. The day-of-month field has the same behavior: */2 means the odd days 1 through 31, so on a 31-day month the job runs on the 31st and again on the 1st.
minute */7 0,7,14,21,28,35,42,49,56 hour */23 0,23 hour 0-23/2 0,2,4,6,8,10,12,14,16,18,20,22 day of month */2 1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31 day of week 7 0 day of week mon-fri 1,2,3,4,5
The day-of-month and day-of-week trap: two restrictions mean OR
Most people read a cron line as a conjunction: every field must match. That holds for minute, hour and month, but not for the two day fields. The crontab(5) text says that when both are restricted, meaning neither contains an asterisk, the command runs when either field matches. The example on that page, 30 4 1,15 * 5, runs at 4:30 on the 1st and the 15th and also on every Friday. POSIX states the same rule in its own words: if both are given as an element or list, a day matching either one is matched.
So 0 9 15 * 1 does not mean the 15th when it is a Monday. It means every Monday, plus the 15th of every month. If only one day field is restricted and the other is an asterisk, that one field decides alone, so 0 9 15 * * runs only on the 15th, and 0 9 * * 1 runs only on Mondays. Anyone who wants the intersection has to schedule daily or on the wider set and test the date inside the script.
There is one implementation detail worth knowing. In the cronie source, a day field counts as unrestricted whenever its first character is an asterisk, which includes a step like */2. Its scheduler then uses AND instead of OR when either day field starts with an asterisk. The previewer on this site follows the same rule, so 0 0 */2 * 1 is shown as odd days of the month that are also a Monday, not as every Monday plus every odd day. Other cron implementations may differ, so confirm on your own server before relying on that shape, or write the days out.
0 9 15 * * => Thu 2026-10-15 09:00 | Sun 2026-11-15 09:00 | Tue 2026-12-15 09:00
0 9 * * 1 => Mon 2026-09-21 09:00 | Mon 2026-09-28 09:00 | Mon 2026-10-05 09:00
0 9 15 * 1 => Mon 2026-09-21 | Mon 2026-09-28 | Mon 2026-10-05 | Mon 2026-10-12
Thu 2026-10-15 | Mon 2026-10-19 (all at 09:00)
0 0 */2 * 1 => Mon 2026-09-21 | Mon 2026-10-05 | Mon 2026-10-19 (all at 00:00)Worth knowing. Both day fields set to a value that does not begin with an asterisk is the only combination that produces OR. If a schedule that says 15th and Monday has ever fired on unexpected days, this rule is the first place to look.
Quartz and other dialects are not the same format
Cron is a family of formats, and pasting a line from one into another is a common source of silent errors. The Quartz Scheduler CronTrigger uses six mandatory fields plus an optional seventh: seconds, minutes, hours, day of month, month, day of week, and an optional year from 1970 to 2099. Its day-of-week numbers run from 1 to 7 or SUN to SAT, and its day fields accept extra characters: L for last, W for nearest weekday and the pound sign for the nth weekday of a month.
The most important difference is the question mark. Quartz's own documentation says its support for specifying both a day of week and a day of month is not complete: you must put ? in one of those two fields to mean no specific value. In other words, Quartz has no equivalent of the OR rule above. A Quartz expression such as 0 0 12 ? * MON-FRI would be rejected by a five-field parser twice over, once for the field count and once for the question mark.
The parser and the crontab generator here take five fields only. They reject six-field lines, question marks and the L character with a specific error, so a Quartz expression will not be silently reinterpreted as something else. Treat that as a signal to convert by hand: drop the seconds and year, replace ? with an asterisk, and rewrite any L, W or pound-sign rules.
0 0 ? * * "?" is not a valid day of month value. 0 0 L * * "L" is not a valid day of month value. 0 0 0 * * * A cron expression needs exactly 5 fields, got 6. 0 0 * * 8 day of week value 8 is outside 0-7. 0 0 * * 7 valid
Time zones and daylight saving: whose midnight is it
A crontab line has no time zone of its own. Cron matches the fields against the clock of the machine running it, so 0 9 * * * on a server set to UTC is 9 AM UTC. The cronie crontab(5) page describes a CRON_TZ variable that sets a zone for one crontab, and notes that log timestamps still use the daemon's local zone. CRON_TZ is an extension, so verify that the cron you deploy to supports it. On a cloud host, the simplest robust choice is to run the host in UTC and convert business hours yourself.
Daylight saving is where local-time schedules break. The same project's documentation is not entirely consistent about it. Its crontab(5) page says that times skipped by the clock change never match and repeated times run twice. Its cron(8) page says changes under three hours are handled specially: jobs that run at a specific time and were skipped are run immediately, a repeated hour is not run twice, and jobs that run more often than hourly are scheduled normally. Read the page for the exact version you run, and keep schedules out of the hours around the local switch, which in many places means roughly 1 to 3 AM.
The previewer works on wall-clock time in your browser's zone, and it converts each result to a real time through the JavaScript Date object. For a nonexistent time, Date moves forward past the gap, so the preview shows the job at 3:30 on the spring change day. For a repeated time it lists one run, not two. Neither matches what a real daemon might do, so treat previews near the change as approximate.
30 2 * * * from 2026-03-06 12:00 local Sat Mar 07 2026 02:30 GMT-0500 Sun Mar 08 2026 03:30 GMT-0400 (02:30 does not exist that day) Mon Mar 09 2026 02:30 GMT-0400 30 1 * * * from 2026-10-30 12:00 local Sat Oct 31 2026 01:30 GMT-0400 Sun Nov 01 2026 01:30 GMT-0400 (01:30 happens twice; one run listed) Mon Nov 02 2026 01:30 GMT-0500
Overlapping runs and the command half of the line
Cron checks the clock each minute and starts whatever matches. It does not know whether the previous instance of the same job is still running. A job scheduled every five minutes that sometimes takes seven will overlap itself, and if it writes to a shared file or holds a database transaction, the overlap is a bug. A lock the job takes at start is the standard fix. The flock utility from util-linux does this on the command line, and its -n option fails immediately rather than waiting when the lock is held, which turns an overlapping run into a skipped one.
The command part has its own rules. The crontab(5) page says the command runs under /bin/sh unless SHELL is set, with a minimal environment of HOME, LOGNAME and SHELL. A percent sign in the command, unless escaped with a backslash, is turned into a newline, and everything after the first one is sent to the command as standard input. That is why date +%Y-%m-%d inside a crontab line fails until each percent sign is escaped or the command is moved into a script.
Testing a schedule against a fixed start date
The reliable way to test a schedule is to list its next runs from a start date you chose, not from now, and read the dates rather than the pattern. Boundary cases are where surprises hide. A rule for the 31st skips every 30-day month and February. A rule for February 29 waits for a leap year. A step in the month field of */5 lands on January, June and November, not evenly across the year.
The output below comes from the schedule walker behind the cron parser and crontab generator, starting from Saturday 2026-09-19 at 12:00 in wall-clock time. After previewing, use the time zone converter to translate the local run times into the zone of the people who depend on the job, and the Unix timestamp converter to check exact instants when the job feeds a system that stores epoch seconds. The cronie crontab(5) page also mentions a -T option on the crontab command for checking file syntax before installation.
0 0 31 * * => Sat 2026-10-31 | Thu 2026-12-31 | Sun 2027-01-31 | Wed 2027-03-31 | Mon 2027-05-31 0 0 29 2 * => Tue 2028-02-29 00:00 0 0 1 */5 * => Sun 2026-11-01 | Fri 2027-01-01 | Tue 2027-06-01 | Mon 2027-11-01
Sources
- crontab(5), from the cronie project: crontab file format (Linux man-pages)
- cron(8), from the cronie project: daemon behavior including time changes (Linux man-pages)
- POSIX.1-2024 crontab utility specification (The Open Group)
- Quartz Scheduler 2.3.0: CronTrigger tutorial
- cronie source, src/entry.c: how the day-of-month and day-of-week star flags are set