five fields, endless ways to get it wrong
The five fields of a crontab line look simple at first glance, yet lists, ranges, steps, and above all the notorious OR logic between day of month and weekday regularly produce jobs that run at the wrong time or not at all. This guide explains crontab syntax completely, including special characters, environment variables, and timezones.
Table of Contents
- 1. The Five Fields of a Crontab
- 2. Combining Lists, Ranges, and Steps
- 3. The Special Strings @reboot, @daily, and Friends
- 4. Day of Month and Weekday Together: the OR-Logic Trap
- 5. Environment Variables in Crontab Files
- 6. crontab -e vs. /etc/cron.d vs. /etc/cron.daily
- 7. Timezones in Crontabs: CRON_TZ and System Timezone
- 8. Common Syntax Pitfalls and How to Test Them
- 9. Crontab Syntax Elements at a Glance
- 10. Summary
- 11. FAQ
1. The Five Fields of a Crontab
Every line in a crontab consists of five time fields, followed by the actual command: minute, hour, day of month, month, and day of week, in exactly this order. The first field accepts values from zero to 59, the second zero to 23, the third one to 31, the fourth one to twelve, and the fifth zero to seven, where both zero and seven represent Sunday. An asterisk in a field means every possible value of that field matches, so * * * * * runs a job every single minute.
Understanding correct crontab syntax mostly means memorizing the order of these five fields, since swapping the minute and hour fields does not produce an error, but a job that runs at the wrong time, often unnoticed for weeks. A job meant to run at 2:30pm needs the line 30 14 * * *, not 14 30 * * *, which would mean minute 14 and hour 30, and is either ignored or produces a parser error depending on the cron implementation, due to the invalid hour value.
Another fundamental detail of crontab syntax: every user has their own personal crontab, edited with crontab -e and stored under /var/spool/cron/crontabs/username. This personal crontab always runs with the permissions of that particular user, which requires a deliberate decision for system tasks needing root privileges between sudo crontab -e for root and dedicated system directories like /etc/cron.d.
2. Combining Lists, Ranges, and Steps
Besides single values and the asterisk, crontab syntax knows three further operators that can be combined arbitrarily. A comma creates a list of individual values, for example 0,15,30,45 for four fixed minutes within every hour. A hyphen defines a contiguous range, for example 9-17 for the hours between 9am and 5pm. A slash defines a step, where */15 in the minute field means exactly every 15 minutes, starting at minute zero.
These operators can be nested, which considerably extends the expressive power of crontab syntax: 9-17/2 in the hour field means every second hour within the range from 9am to 5pm, that is 9, 11, 13, 15, and 17 hours. Important to understand: a step without a preceding range, that is */15, always starts at the smallest possible value of the field, not at the current moment of editing. A common misconception is assuming */15 counts from the time the crontab line was created, but it always counts from minute zero.
# Combining lists, ranges, and steps in crontab time fields
# Format: minute hour day-of-month month day-of-week command
# Every 15 minutes, on the hour, quarter, half, and three-quarter mark
*/15 * * * * /usr/local/bin/health-check.sh
# At minute 0 and 30 of every hour between 9 and 17 (business hours)
0,30 9-17 * * * /usr/local/bin/sync-inventory.sh
# Every second hour within a range, e.g. 9, 11, 13, 15, 17
0 9-17/2 * * * /usr/local/bin/generate-report.sh
# Combined list and range: run at minute 5 on Mon, Wed, and Fri
5 0 * * 1,3,5 /usr/local/bin/weekly-partial-backup.sh
# Every 5th day of the month, at 03:00
0 3 */5 * * /usr/local/bin/monthly-cleanup.sh
3. The Special Strings @reboot, @daily, and Friends
For the most common schedules, crontab syntax offers a more readable shorthand in the form of special strings that stand in place of the five time fields. @reboot runs a command exactly once after every system boot, independent of any specific clock time, which is excellent for restoring a defined system state, for example re-establishing iptables rules or starting a background service not managed via systemd.
@daily is equivalent to 0 0 * * * and runs daily at midnight, @weekly is equivalent to 0 0 * * 0 and runs on Sundays at midnight, @monthly is equivalent to 0 0 1 * * and runs on the first day of every month, @hourly is equivalent to 0 * * * * and runs every full hour. These shorthands considerably improve readability, but hide the exact execution time behind a name, which is why they suit uncritical, clearly named maintenance jobs best, and less so tasks with an exact, operationally relevant time window.
# Special strings replace the five time fields entirely
@reboot /usr/local/bin/restore-iptables-rules.sh
@daily /usr/local/bin/rotate-application-logs.sh
@weekly /usr/local/bin/full-backup.sh
@monthly /usr/local/bin/generate-monthly-invoice-report.sh
@hourly /usr/local/bin/check-disk-usage.sh
# Equivalent explicit five-field expressions, for comparison
# @daily == 0 0 * * *
# @weekly == 0 0 * * 0
# @monthly == 0 0 1 * *
# @hourly == 0 * * * *
4. Day of Month and Weekday Together: the OR-Logic Trap
By far the most common mix-up in crontab syntax concerns setting both day of month and day of week to concrete, non-asterisk values at the same time. Intuitively one would expect both conditions to be combined with AND, running only when both conditions apply simultaneously. In fact, cron combines these two fields with OR as soon as neither of them is an asterisk: the job runs if either the day of month OR the day of week matches, not only if both match at the same time.
A concrete example makes the trap visible: 0 0 15 * 1 is meant to run on the 15th of a month, but only if that day happens to be a Monday. In reality, however, this job runs on every 15th of the month AND on every Monday, independent of the calendar day. Anyone who needs a true AND relationship between day and weekday must leave both fields as an asterisk and instead check the condition inside the invoked script itself, for example with [[ "$(date +%d)" == "15" && "$(date +%u)" == "1" ]] as a guard clause at the start of the script.
# The OR trap: day-of-month and day-of-week combine with OR,
# not AND, whenever BOTH fields are restricted (not "*")
# WRONG assumption: "runs on the 15th, only if it's a Monday"
# ACTUAL behavior: runs on every 15th AND every Monday
0 0 15 * 1 /usr/local/bin/misleading-job.sh
# CORRECT approach for true AND logic: leave both fields as "*"
# and check the condition explicitly inside the script
0 0 * * * /usr/local/bin/guarded-job.sh
# guarded-job.sh contents:
#!/usr/bin/env bash
set -euo pipefail
if [[ "$(date +%d)" == "15" && "$(date +%u)" == "1" ]]; then
/usr/local/bin/actual-task.sh
fi
5. Environment Variables in Crontab Files
By default, cron starts jobs with a heavily reduced environment that differs fundamentally from an interactive shell session. The PATH value often contains only /usr/bin:/bin, which means even self-installed tools in /usr/local/bin or user-specific directories are not found unless the full path is set explicitly inside the script or the crontab's own PATH. This minimal environment is the most common reason a script that runs perfectly in a terminal fails as a cron job with command not found.
Crontab syntax allows custom variable definitions as separate lines above the actual time fields, for example PATH=/usr/local/bin:/usr/bin:/bin, SHELL=/bin/bash, or MAILTO=admin@example.com. These variables apply to all subsequent lines of the same crontab file, but can be overwritten multiple times if different jobs need different environments. Important: these variables only take effect within the crontab itself, they are not automatically passed on to invoked scripts if those scripts load their own, more restrictive shell configuration.
# crontab -e content: variable definitions above the time fields
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/sbin
MAILTO=admin@example.com
# Jobs below inherit the PATH, SHELL, and MAILTO defined above
0 3 * * * /usr/local/bin/nightly-backup.sh
*/10 * * * * /usr/local/bin/check-queue-depth.sh
# Override MAILTO for one noisy job only
MAILTO=""
* * * * * /usr/local/bin/high-frequency-heartbeat.sh
# Restore default MAILTO for jobs below this line
MAILTO=admin@example.com
0 0 * * 0 /usr/local/bin/weekly-summary.sh
6. crontab -e vs. /etc/cron.d vs. /etc/cron.daily
Besides the personal crontab via crontab -e, Linux knows several other locations for time-controlled tasks, each with its own properties. The directory /etc/cron.d contains standalone files in the same five-field format, but with an additional column for the executing username directly after the time fields, which considerably simplifies root-only management by package managers and configuration management tools like Ansible, without touching any user's personal crontab.
The directories /etc/cron.daily, /etc/cron.weekly, and /etc/cron.monthly follow a different principle: instead of a crontab file with time fields, they hold executable scripts that get invoked at the appropriate interval by an overarching mechanism, typically controlled via /etc/crontab. The exact execution time for these directories is configured via /etc/crontab or, on modern systems, via the systemd timer anacron or cron.daily.timer, and is not visible in the individual scripts themselves.
7. Timezones in Crontabs: CRON_TZ and System Timezone
By default, cron interprets all time fields in the system timezone as configured via timedatectl. For servers that operate across multiple regions, or whose system timezone is deliberately set to UTC while individual jobs still need to align with a local business time, modern cron implementations like Vixie cron support the variable CRON_TZ, set as its own line inside the crontab, similar to PATH or MAILTO.
CRON_TZ=Europe/Berlin above a group of lines ensures these jobs always run according to Central European time regardless of the system timezone, even if the server itself keeps UTC as its system timezone. This is especially relevant for tasks tied to real, human business hours, for example a reminder at 9am local time, while purely technical maintenance jobs can usually stay timezone-independent in UTC to avoid daylight saving transitions entirely.
# Server system timezone is UTC, but these specific jobs
# should always fire at 09:00 local Berlin time, DST-aware
CRON_TZ=Europe/Berlin
0 9 * * 1-5 /usr/local/bin/send-morning-standup-reminder.sh
# Reset to system default (UTC) for the remaining, timezone-agnostic jobs
CRON_TZ=UTC
0 2 * * * /usr/local/bin/nightly-maintenance.sh
8. Common Syntax Pitfalls and How to Test Them
Beyond the already covered OR-logic trap, there are further recurring sources of error in crontab syntax. A missing newline character at the end of the crontab file causes some cron implementations to ignore the last line entirely, a particularly sneaky bug because it is barely noticeable in a text editor. An overly narrow window such as 59 23 31 12 *, intended for New Year's Eve, only fires if December 31st actually exists and falls on a valid weekday, which, when a weekday field is mistakenly added as well, leads right back into the OR trap.
For testing complex time expressions before they go into production, an external cron parser like crontab.guru is indispensable: entering a five-field expression immediately delivers a human readable description as well as a list of upcoming execution times, catching misunderstandings about ranges, steps, or the OR logic before production use. It is also worth checking journalctl -u cron or grep CRON /var/log/syslog after every change, to confirm a job actually fired at the expected time.
9. Crontab Syntax Elements at a Glance
The following table summarizes the key elements of crontab syntax and their respective meaning.
| Element | Example | Meaning | Pitfall |
|---|---|---|---|
| Asterisk | * |
Every possible value of the field | None, the basic building block |
| List | 0,15,30,45 |
Exactly these values | No spaces allowed after the comma |
| Range | 9-17 |
Contiguous range of values | Boundaries are included themselves |
| Step | */15 |
Every 15 units from the field minimum | Counts from zero, not from edit time |
| Day + Weekday | 15 * 1 |
OR relationship, not AND | The single most common misconception |
Once these elements of crontab syntax are internalized, especially the OR logic between day of month and weekday, the most common mistakes are avoided, the kind that otherwise only surface weeks later when a job runs at an unexpected time or not at all.
Mironsoft
Cron audits, deployment automation, and Linux system administration
Cron jobs that run at exactly the right time?
We review existing crontab entries for typical syntax pitfalls, especially the OR logic between day and weekday, and document timezone dependencies cleanly using CRON_TZ.
Crontab Audit
Systematic review of all crontab lines for syntax pitfalls
Migration to cron.d
Cleanly moving personal crontabs into versioned /etc/cron.d files
Timezone Concept
Consistently using CRON_TZ for locally bound jobs
10. Summary
Crontab syntax consists of five time fields, minute, hour, day of month, month, and day of week, combinable with lists, ranges, and steps. Special strings like @reboot and @daily improve readability for common schedules. The biggest pitfall remains the OR relationship between day of month and day of week once both fields are restricted, which requires an explicit check inside the script itself if true AND logic is desired.
Environment variables like PATH, SHELL, and MAILTO belong at the top of the crontab file, CRON_TZ controls the timezone of individual job groups independent of the system timezone. External tools like crontab.guru help verify complex time expressions before production use and catch the most common syntax pitfalls early.
Crontab Syntax, the Essentials at a Glance
Five Fields
Minute, hour, day of month, month, day of week, in exactly this order.
Lists, Ranges, Steps
Comma for lists, hyphen for ranges, slash for steps, combinable arbitrarily.
OR-Logic Trap
Day of month and day of week combine with OR once both are restricted.
CRON_TZ
Controls the timezone of individual job groups independent of the system timezone.