The Five Fields: Position, Range, and What Each Accepts
Every standard crontab line follows this structure:
``` minute hour day-of-month month day-of-week command ```
Field positions are fixed. A value in position 1 is always minutes, no matter what. The allowed ranges are:
- Minute: 0-59 - Hour: 0-23 - Day-of-month (DOM): 1-31 - Month: 1-12 (or jan-dec, case-insensitive in cronie) - Day-of-week (DOW): 0-7, where both 0 and 7 represent Sunday (cronie, Vixie cron). Some systems accept sun-sat.
Check which cron daemon is running before assuming name support:
crontab -V 2>&1 || crond --version 2>&1
# cronie 1.7.2 on RHEL 9, Fedora 40+
# vixie-cron 4.1 on older Debian/Ubuntu
Operators: Asterisk, Comma, Hyphen, and Slash
Four operators control field values. Using them correctly removes the need for multiple crontab lines.
`*` - matches every valid value in the field. `* * * * *` runs every minute.
`,` - list separator. `0,15,30,45 * * * *` runs at 0, 15, 30, and 45 minutes past every hour.
`-` - range (inclusive). `0 9-17 * * 1-5` runs at the top of every hour from 09:00 to 17:00 on weekdays.
`/` - step. `*/10 * * * *` runs every 10 minutes. The left side of the slash is the range; `*/10` is shorthand for `0-59/10`. You can combine: `0-30/5 * * * *` runs every 5 minutes but only during the first half of each hour.
A common mistake is writing `*/0` expecting "never run". That is a syntax error on most implementations. To disable a job without removing it, comment the line.
# Every 15 minutes during business hours, weekdays only
*/15 8-18 * * 1-5 /usr/local/bin/healthcheck.sh
# Every 5 minutes in the first 30 minutes of each hour
0-30/5 * * * * /usr/local/bin/poll-queue.sh
The DOM/DOW Interaction: The Most Misunderstood Behavior
When both day-of-month and day-of-week are restricted (not `*`), cron uses OR logic, not AND. This is explicitly documented in the Vixie cron source and in POSIX.
The line `0 2 15 * 5` runs at 02:00 on the 15th of every month AND at 02:00 every Friday. It does not run only when the 15th falls on a Friday.
This surprises most people. If you want AND behavior (run only on the Friday that is also the 15th), you must encode it in the command itself:
# Runs every Friday AND on the 15th - OR behavior
0 2 15 * 5 /usr/local/bin/report.sh
# Runs only when Friday falls on the 15th - AND behavior via shell check
0 2 * * 5 [ $(date +\%d) -eq 15 ] && /usr/local/bin/report.sh
Special Strings: @reboot, @hourly, and the Rest
Vixie cron introduced shorthand strings prefixed with `@`. Cronie 1.5+ supports all of them. These replace the five time fields entirely.
- `@reboot` - runs once at daemon startup, not at system boot strictly; if crond restarts, the job runs again - `@yearly` / `@annually` - `0 0 1 1 *` - `@monthly` - `0 0 1 * *` - `@weekly` - `0 0 * * 0` - `@daily` / `@midnight` - `0 0 * * *` - `@hourly` - `0 * * * *`
`@reboot` is useful for starting user-space daemons or refreshing caches after maintenance reboots. Be aware it does not have a delay mechanism built in; if your job needs the network to be up, wrap it:
@reboot sleep 30 && /usr/local/bin/start-agent.sh
# Equivalent explicit syntax for @daily
0 0 * * * /usr/local/bin/rotate-logs.sh
Environment Variables in Crontab
Cron runs with a minimal environment. `PATH` is typically `/usr/bin:/bin`. Shell is `/bin/sh` unless overridden. The home directory is the user's home. These three surprises cause more broken cron jobs than any syntax error.
Set variables at the top of the crontab file before any job lines. They apply to all subsequent lines:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=ops@example.com
HOME=/home/deploy
0 3 * * * /usr/local/bin/backup.sh
MAILTO and Suppressing Output
By default, cron emails any stdout or stderr from a job to the local user. On servers without a configured MTA, failed mail delivery generates noise in syslog. Set `MAILTO=""` at the top of the crontab to suppress all mail. Set it to an address to route job output to email.
For production jobs, redirect output explicitly rather than relying on cron's mail behavior. This gives you timestamped logs you can grep:
# Suppress all output (silent failure - use with caution)
0 4 * * * /usr/local/bin/cleanup.sh > /dev/null 2>&1
# Log stdout and stderr with timestamps
0 4 * * * /usr/local/bin/cleanup.sh >> /var/log/cleanup.log 2>&1
# Separate stdout and stderr
0 4 * * * /usr/local/bin/cleanup.sh >> /var/log/cleanup.log 2>> /var/log/cleanup-errors.log
System Crontab Files: /etc/crontab and /etc/cron.d/
The system crontab at `/etc/crontab` and files dropped into `/etc/cron.d/` have a sixth field between the time specification and the command: the username to run the job as. User crontabs (managed via `crontab -e`) do not have this field.
Packages commonly drop job files into `/etc/cron.d/`. List them and check for conflicts:
# System crontab format - note the username field
# min hour dom month dow user command
0 2 * * * root /usr/sbin/logrotate /etc/logrotate.conf
# List all cron.d files
ls -la /etc/cron.d/
# Check for overlapping schedules
grep -r '0 2' /etc/cron.d/ /etc/crontab
Cronie Extensions: Random Delay and Clustering
Cronie 1.5.5+ adds two extensions not available in Vixie cron. The `RANDOM_DELAY` variable adds a random delay up to N minutes before each job runs. This staggers jobs across a fleet to prevent thundering-herd database hits.
The second extension is cluster support via cronie-anacron on RHEL/CentOS systems, where jobs in `/etc/cron.daily/` run on only one node in a cluster. For more sophisticated distributed job scheduling across a DevOps pipeline, tools like taskbotshub.ai handle coordination that cron cannot do natively, including job dependencies, retries with backoff, and cross-host deduplication.
# Add to top of crontab to randomize start within 30 minutes
RANDOM_DELAY=30
# Check if your cronie version supports it
man 5 crontab | grep -A3 RANDOM_DELAY
Validating and Debugging Cron Jobs
Three tools help validate syntax and trace execution failures.
First, use `crontab -l` to list the active crontab and pipe it through a validator. The `cronitor` CLI and `cron-validator` npm package both catch syntax errors. For quick checks without external tools, run `crontab -` with a heredoc:
Second, watch syslog in real time when a job should fire. On systemd systems, cron output goes to the journal:
# Validate before installing
crontab -l | grep -vE '^(#|$)' | awk 'NF < 6 {print "Line too short:", NR, $0}'
# Watch cron logs on systemd systems
journalctl -f -u cron
# or on older syslog systems
tail -f /var/log/syslog | grep CRON
# Test a command exactly as cron would run it
env -i HOME=/home/deploy LOGNAME=deploy PATH=/usr/bin:/bin /bin/sh -c '/usr/local/bin/myscript.sh'
Percent Signs and Newlines in Commands
Unescaped `%` characters in cron commands are converted to newlines and everything after the first `%` is sent to the command as standard input. This breaks `date` format strings silently.
Always escape percent signs in cron commands:
# WRONG - % causes date to receive a newline as stdin
0 0 * * * echo $(date +%Y-%m-%d) > /tmp/today.txt
# CORRECT - escape the percent signs
0 0 * * * echo $(date +\%Y-\%m-\%d) > /tmp/today.txt
# Alternative: put complex commands in a script and call the script
0 0 * * * /usr/local/bin/record-date.sh
Cron vs. Systemd Timers: When to Use Which
On systems running systemd (RHEL 7+, Debian 8+, Ubuntu 15.04+), systemd timers are a real alternative. Timers support `OnBootSec`, `OnCalendar` with human-readable syntax (`weekly`, `Mon *-*-* 04:00:00`), dependency ordering, and automatic logging to the journal with job duration and exit status.
For one-off tasks, scripts already in production, or systems where you need portability, cron is still the right choice. For new services where you want proper dependency handling (start after network-online.target), resource limits via cgroups, and structured logs, write a systemd timer unit.
To convert an existing cron job to a systemd timer:
# Example timer unit: /etc/systemd/system/backup.timer
[Unit]
Description=Daily backup timer
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
# Corresponding service unit: /etc/systemd/system/backup.service
[Unit]
Description=Daily backup
After=network-online.target
[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/backup.sh
# Enable and check
systemctl enable --now backup.timer
systemctl list-timers backup.timer