Cron Jobs Explained: Crontab Syntax, Expression Examples and Why Scheduled Jobs Silently Fail
Cron runs commands on a schedule written as five fields: minute, hour, day of month, month and day of week. The syntax is small enough to learn in five minutes, and cron jobs still fail more quietly than almost anything else in operations. The expression is usually right. The environment, the time zone or an overlapping run is what breaks, and nothing reports it. This guide covers both halves.
The five fields
┌──────── minute 0-59
│ ┌────── hour 0-23
│ │ ┌──── day of month 1-31
│ │ │ ┌── month 1-12 (or JAN-DEC)
│ │ │ │ ┌ day of week 0-6 (0 = Sunday; 7 is also Sunday in most crons; or SUN-SAT)
│ │ │ │ │
* * * * * command
Each field accepts * (every value), a single value, a list (1,15), a range (1-5) and a step (*/10, or 0-30/10). * * * * * therefore runs every minute. Cron checks the schedule once a minute, so there is no way to express seconds in a standard crontab.
Cron expression examples
*/5 * * * * every 5 minutes
0 * * * * every hour, on the hour
30 2 * * * every day at 02:30
0 9 * * 1-5 09:00 Monday to Friday
0 0 1 * * midnight on the 1st of every month
0 0 * * 0 midnight every Sunday
15 14 1 * * 14:15 on the 1st of each month
0 */6 * * * every 6 hours (00:00, 06:00, 12:00, 18:00)
0 22 * * 1-5 22:00 on weekdays
0 0 1 1 * once a year, 1 January
@reboot once at startup @hourly = 0 * * * *
@daily = @midnight = 0 0 * * * @weekly = 0 0 * * 0
@monthly = 0 0 1 * * @yearly = 0 0 1 1 *
Steps count from the start of the range, not from when you saved the crontab. */7 in the minute field fires at minutes 0, 7, 14 … 56 and then 0 again, so the gap across the hour is four minutes, not seven. Intervals that do not divide 60 (or 24 for hours) are never truly even.
The day-of-month and day-of-week trap
When both day fields are restricted, not *, classic cron runs the job if either matches. It is an OR, not an AND.
0 9 1 * 1 runs on the 1st of the month AND on every Monday
(not "on the 1st, if it is a Monday")
To run on the first Monday of the month, restrict one field and test the other in the command: 0 9 1-7 * * [ "$(date +\%u)" = 1 ] && /path/job.sh. Quartz-style schedulers avoid the ambiguity with a ? placeholder and # syntax (MON#1).
Editing the crontab, and the % sign
crontab -e # edit your user's crontab
crontab -l # list it
sudo crontab -u www-data -e
# /etc/crontab and /etc/cron.d/* have an extra user field:
30 2 * * * root /usr/local/bin/backup.sh
In a crontab, an unescaped % means "newline", and everything after the first one is passed to the command as standard input. date +%F in a cron line therefore breaks in a confusing way. Escape each percent sign as \%, or move the command into a script.
# wrong: cron cuts the line at the first %
0 3 * * * tar czf /backups/site-$(date +%F).tgz /var/www
# right
0 3 * * * tar czf /backups/site-$(date +\%F).tgz /var/www
Why a job works in the shell but not in cron
Cron runs commands with a minimal environment: a short PATH (often just /usr/bin:/bin), /bin/sh rather than your login shell, your home directory as the working directory, and none of the variables from .bashrc or .profile. A script that calls node, php, aws or docker by name works in your terminal and fails under cron with "command not found", and you only find out if you read the mail cron tried to send.
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=ops@example.com
# absolute paths, explicit working directory, output captured
*/15 * * * * cd /srv/app && /usr/bin/php artisan schedule:run >> /var/log/app-cron.log 2>&1
Redirect output to a log file, and always include 2>&1. Without it, errors go to local mail that nobody reads, or nowhere if no mail system is installed. To reproduce cron's environment when debugging, run the command with env -i HOME=$HOME PATH=/usr/bin:/bin sh -c '…'.
Time zones, overlap and missed runs
Cron uses the system time zone, which on servers is often UTC. A job set for 0 9 * * * on a UTC server runs at 09:00 UTC, not 09:00 in your office. Around daylight-saving changes, jobs in local time can run twice or be skipped. Some crons support a CRON_TZ variable, but keeping servers on UTC and converting times yourself is more predictable.
Cron does not care whether the previous run has finished. A job every 5 minutes that sometimes takes 7 will overlap with itself and can corrupt data or exhaust connections. Use a lock:
*/5 * * * * /usr/bin/flock -n /tmp/import.lock /srv/app/import.sh
-n makes the second run exit immediately instead of queueing. Cron also does not catch up: if the machine was off at 02:30, the 02:30 job simply does not run. anacron and systemd timers with Persistent=true do run missed jobs after boot.
Checking that cron ran
grep CRON /var/log/syslog # Debian/Ubuntu
journalctl -u cron --since today # systemd (service is crond on RHEL/Fedora)
systemctl status cron
Those logs prove the job started, not that it succeeded. For anything important, add a heartbeat: have the job ping a monitoring URL when it completes, and alert when the ping does not arrive on schedule.
Cron syntax in other schedulers
Kubernetes CronJob uses the standard five fields and has a timeZone field (stable since Kubernetes 1.27), plus concurrencyPolicy: Forbid to prevent overlap. GitHub Actions schedule triggers use five-field cron in UTC, cannot run more often than every five minutes, and may start late when load is high. Spring's @Scheduled(cron = …) and Quartz use six fields, adding seconds at the front: 0 0 9 * * MON-FRI is 09:00 on weekdays. AWS EventBridge uses six fields with a year at the end, and requires ? in one of the day fields.
Always check which dialect a scheduler expects before pasting an expression. A five-field expression in a six-field scheduler shifts every field one position, and usually still parses.
Sources and further reading
The crontab(5) manual page documents the fields, the % rule, the day-field OR behaviour and the @ shortcuts. The flock(1) and anacron(8) manual pages cover locking and missed runs. The Kubernetes CronJob documentation, the GitHub Actions workflow syntax reference, the Spring Framework CronExpression Javadoc and the Amazon EventBridge schedule-expression reference describe their dialects.
Frequently asked questions
What does * * * * * mean in cron?
Every minute of every hour of every day. The five asterisks stand for minute, hour, day of month, month and day of week, each matching every value.
How do I run a cron job every 5 minutes?
Use `*/5 * * * *`. It fires at minutes 0, 5, 10 … 55 of every hour. Add `flock -n` if a run might take longer than five minutes.
Why does my cron job not run?
Most often the command fails because cron's `PATH` and environment are minimal, a `%` was not escaped, or output is discarded so the error is invisible. Use absolute paths, redirect output with `>> log 2>&1`, and check `grep CRON /var/log/syslog`.
What time zone does cron use?
The system time zone of the machine, often UTC on servers. Some implementations support `CRON_TZ`, and Kubernetes CronJobs have a `timeZone` field.
How do I run a cron job on the first Monday of the month?
Standard cron treats the two day fields as OR, so `0 9 1-7 * 1` runs on days 1–7 and on every Monday. Use `0 9 1-7 * *` and check the weekday in the command, for example with `[ "$(date +\%u)" = 1 ]`.
What is the difference between cron and crontab?
Cron is the daemon that runs scheduled commands. A crontab is the table of schedules it reads, edited with `crontab -e`, plus system files in `/etc/crontab` and `/etc/cron.d/`.