cron Cheatsheet
at and Common Recipes
Use this cron reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
at, for a job that runs once
cron repeats. at queues a command to run one time at a given moment and then
forgets it. The command is read from stdin, and the environment plus the working
directory are captured at submit time.
at 22:00 <<'EOF' /srv/app/bin/deploy.sh --tag v2.1.0 EOF echo '/usr/bin/systemctl restart nginx' | at now + 5 minutes at -f /srv/app/bin/migrate.sh 03:00 tomorrow at -t 202609160300 -f /srv/app/bin/migrate.sh
| Command | Does |
|---|---|
at TIME | read commands from stdin, queue them for TIME |
at -f FILE TIME | queue a script file instead of stdin |
at -t YYYYMMDDhhmm | give the time in a fixed numeric format |
at -q Q | put the job in queue letter Q, a to z |
at -m | mail the owner even when there is no output |
at -M | never mail |
atq (at -l) | list your pending jobs, root sees everyone's |
atrm N (at -d N) | delete job number N |
at -c N | print job N, including the whole captured environment |
batch (at -b) | run as soon as load average drops below 1.5 |
Time expressions are generous. All of these parse:
at 16:20 at noon at midnight at teatime # 16:00 at now + 30 minutes at now + 3 days at 10:00 Jul 31 at 2027-01-01 09:00 at 4pm + 2 days at tomorrow at next week
atq output is jobnumber date hour queue owner. Higher queue letters run at a
higher nice value, and batch is queue b.
- The atd daemon must be running or nothing fires. Check with
systemctl status atd (atd on Debian, atd from the at package on
Fedora and Arch).
- Jobs live in /var/spool/cron/atjobs (Debian) or /var/spool/at, and they
survive a reboot. A job whose time passed while the machine was off runs at
the next atd start.
- Access is controlled by /etc/at.allow and /etc/at.deny, with the same
precedence rule as cron: if at.allow exists, only the users listed in it may
submit, and at.deny is ignored.
- at captures the environment, so at -c is the fastest way to see exactly
what a queued job will run with. This is one of the few places where the
environment is not stripped the way cron strips it.
- On macOS atd ships disabled. sudo launchctl load -w
/System/Library/LaunchDaemons/com.apple.atrun.plist enables it, and on a
recent macOS that path is protected, so a launchd job or a timer is the better
answer there.
Preventing overlapping runs with flock
A five minute job that sometimes takes seven minutes will eventually run twice at once, corrupt something, and be very hard to diagnose. Wrap it.
*/5 * * * * /usr/bin/flock -n /tmp/sync.lock \ /srv/app/bin/sync.sh >> /var/log/sync.log 2>&1
| Flag | Behavior when the lock is held |
|---|---|
-n | exit immediately with status 1, skip this run |
-w 30 | wait up to 30 seconds, then give up |
| (neither) | wait forever, so runs pile up |
-E 0 | exit 0 instead of 1 when -n or -w gives up, so cron stays quiet |
-n is almost always what you want, with -E 0 added once you are sure a
skipped run is not worth an alert. The lock file is just a path flock can
open, and it is never deleted, which is correct. Do not put it somewhere
systemd-tmpfiles will clean mid-run, use /var/lock or /run/lock on a
long-lived box.
The self-locking form, useful inside a script that anyone might run by hand:
#!/bin/bash set -euo pipefail exec 9> /var/lock/sync.lock flock -n 9 || { echo "already running"; exit 0; } # real work below, lock is released when the script exits
Recipes
Every line below is a complete crontab entry. Copy, adjust the path, and
remember that % must be escaped as \% in a crontab and that the whole line
runs under /bin/sh with a bare PATH.
| Schedule | Line |
|---|---|
| Every 5 minutes | */5 * * * * /srv/app/bin/sync.sh |
| Weekdays at 09:00 | 0 9 * * 1-5 /srv/app/bin/report.sh |
| First of the month at 04:00 | 0 4 1 * * /srv/app/bin/invoice.sh |
| Every 15 min, business hours | */15 9-17 * * 1-5 /srv/app/bin/poll.sh |
| Locked, no overlap | */5 * * * * flock -n /var/lock/s.lock /srv/app/bin/sync.sh |
| Every hour on the half hour | 30 * * * * /srv/app/bin/rotate.sh |
| Twice a day | 0 6,18 * * * /srv/app/bin/refresh.sh |
| Every 2 hours | 0 */2 * * * /srv/app/bin/check.sh |
| Every Sunday at 03:15 | 15 3 * * 0 /srv/app/bin/weekly.sh |
| Last minute of the year | 59 23 31 12 * /srv/app/bin/yearend.sh |
| At boot | @reboot /srv/app/bin/warm-cache.sh |
| With a dated log file | 0 2 * * * /srv/app/bin/b.sh >> /var/log/b-$(date +\%F).log 2>&1 |
The same set, written out with logging and locking the way it would actually ship:
SHELL=/bin/bash PATH=/usr/local/bin:/usr/bin:/bin MAILTO=ops@example.com # Every 5 minutes, skipped rather than doubled up. */5 * * * * flock -n /var/lock/sync.lock \ /srv/app/bin/sync.sh >> /var/log/app/sync.log 2>&1 # Weekdays at 09:00, mail only on failure. 0 9 * * 1-5 /srv/app/bin/report.sh > /dev/null # First of the month at 04:00, day-of-week left as * on purpose. 0 4 1 * * /srv/app/bin/invoice.sh >> /var/log/app/invoice.log 2>&1 # Every 15 minutes during business hours, weekdays only. */15 9-17 * * 1-5 /srv/app/bin/poll.sh >> /var/log/app/poll.log 2>&1 # Nightly, with a timestamped log. The percent must be escaped. 0 2 * * * /srv/app/bin/backup.sh \ >> /var/log/app/backup-$(date +\%Y\%m\%d).log 2>&1
Recipes that look right and are not
| Written | Actually means | Wanted |
|---|---|---|
*/5 9-17 * * * on a job taking 8 min | overlapping runs forever | add flock -n |
0 0 1 * 1 | the 1st or any Monday | 0 0 1 * * |
*/35 * * * * | :00 and :35, then a 25 min gap | 0,35 * * * * if that is truly wanted |
0 9 * * 7 | Sunday, only where 7 is accepted | 0 9 * * 0 |
@daily for a report at 09:00 | midnight | 0 9 * * * |
0 2 * * * date +%F | truncated at the % | date +\%F |
*/10 * * * * ./run.sh | relative to $HOME, usually not found | an absolute path |
0 3 * * * python app.py | wrong interpreter or none found | /usr/bin/python3 /srv/app/app.py |
Choosing the tool
| Need | Use |
|---|---|
| Repeating job on any Unix | crontab |
| Repeating job, needs logs and limits | systemd timer |
| One shot, at a specific time | at |
| One shot, whenever the box is idle | batch |
| Must catch up after downtime | anacron, or Persistent=true |
| Must never overlap | flock -n, or a systemd service |
| Sub-minute interval | a systemd timer with OnUnitActiveSec=, or a daemon |
Verify anything on this page against crontab(5) and at(1) on the box you are targeting, because cron implementations differ in exactly the places that bite.