crontab
Schedule recurring commands and manage per-user crontabs
crontab edits and lists the scheduled jobs, the crontab, belonging to a user. cron,
the daemon that runs them, wakes up once a minute, checks every installed crontab, and
fires anything due. crontab -e opens your crontab in $EDITOR (falling back to vi), -l
lists it without opening an editor, and -r deletes it outright, with no confirmation and no
undo.
Each line is five time fields followed by the command to run:
* * * * * command
│ │ │ │ │
│ │ │ │ └── day of week (0–6, Sunday=0)
│ │ │ └───── month (1–12)
│ │ └──────── day of month (1–31)
│ └─────────── hour (0–23)
└────────────── minute (0–59)
A bare * means "every value." Narrow it with a step (*/15), a range (9-17), a list
(1,15,30), or combinations of those. @reboot, @daily, @hourly, @weekly, @monthly, and
@yearly replace the five fields with a shorthand for the obvious schedule.
The most common failure mode is environment rather than syntax. A job that works perfectly when
you type it yourself can fail silently under cron, because cron runs commands with a minimal
environment: no .bashrc, no interactive $PATH, none of the aliases or functions your shell
normally has. Always use full paths to scripts and binaries inside a crontab, and set PATH
explicitly at the top of the crontab if you rely on anything outside /usr/bin and /bin. See
environment variables and PATH for where the
interactive value you are comparing against comes from, and
A real script for a scheduled script written to survive this: it
resolves its own directory rather than trusting the one cron drops it in.
By default crontab edits your own crontab. Root can manage anyone's with -u <user>; anyone
else gets must be privileged to use -u. System-wide jobs that need to run as a specific user
live in /etc/crontab and /etc/cron.d/ instead, which carry an extra username field the
per-user crontab doesn't have.
Finding out whether a job ran
Cron mails a job's output to the owning user, and on a machine with no mail transfer agent installed, which most servers now are, that output is discarded. What survives is cron's own record of starting the job, which goes to the journal:
journalctl -u cron --since today # every job cron started today
That tells you a job fired and when, not what it printed, so a job you need to debug should
redirect its own output somewhere you can read it. See
journalctl for filtering that log down.
Or use a systemd timer instead
A timer unit does the same job as a crontab entry, with a real log, a recorded exit status, and
systemctl list-timers to show what is scheduled and when it next runs. The cost is two unit
files instead of one line. Cron is still the faster thing to write, but for anything whose
failure you would want to notice, systemctl covers the alternative, and
Managing services with systemd shows the timers Debian already runs
on your machine without a crontab anywhere.
cron vs systemd timers puts the two side by side on the same job, if what you want is to decide between them rather than to use one.
Sample files used on this page
Every example below was run against these files. Recreate them to follow along.
mycron.txt the file the install and syntax-check examples read - a comment header plus five jobs
# m h dom mon dow command
0 3 * * * /usr/bin/find /var/log -name '*.log' -mtime +7 -delete
*/15 * * * * /home/user/bin/check-disk.sh
0 9 * * 1-5 /home/user/bin/send-report.sh
@reboot /home/user/bin/startup.sh
@daily /home/user/bin/cleanup.sh
usercron.txt a one-job crontab, for the -u examples
0 2 * * * /home/user/bin/backup.sh
Managing your crontab
The three flags you'll use almost every time: -e to edit, -l to list, -r to remove.
Edit your crontab
crontab -e
Opens your crontab in $EDITOR (falls back to vi if unset). On a fresh account it starts as an empty file with a comment explaining the field format. Nothing is installed until you save and exit.
List your crontab
crontab -l
Prints your installed crontab as-is, unformatted, exactly what cron reads.
Show output
no crontab for root
List a crontab that has jobs installed
crontab mycron.txt
crontab -l
Comments and blank lines round-trip unchanged; this is the literal file cron reads.
Show output
# m h dom mon dow command
0 3 * * * /usr/bin/find /var/log -name '*.log' -mtime +7 -delete
*/15 * * * * /home/user/bin/check-disk.sh
0 9 * * 1-5 /home/user/bin/send-report.sh
@reboot /home/user/bin/startup.sh
@daily /home/user/bin/cleanup.sh
Remove your crontab entirely
crontab -r
Deletes the whole crontab in one step, no confirmation, no undo. Only removes jobs - never touches the scripts those jobs point to.
Confirm what -r just did
crontab -l
After -r, listing falls back to the same message as a never-configured crontab.
Show output
no crontab for root
Prompt before deleting
crontab mycron.txt
crontab -i -r
-i adds a y/n confirmation in front of -r, for when you don't fully trust the muscle memory that typed -r.
Show output
crontab: really delete root's crontab? (y/n)
Install a crontab from a file
crontab mycron.txt
Default behaviour with a bare filename argument: replace the current crontab with this file's contents wholesale, not merge.
Install a crontab from stdin
cat mycron.txt | crontab -
Same replace semantics as passing a filename, but reads from stdin - the shape you want when generating a crontab in a script.
Check a crontab file's syntax without installing it
crontab -n mycron.txt
-n parses the file and reports success or failure but leaves your actual crontab untouched. Useful before piping generated content straight into crontab -.
Show output
The syntax of the crontab file was successfully checked.
The five-field schedule syntax
minute hour day-of-month month day-of-week, in that order. * means every value; narrow it with steps, ranges, or lists.
Every day at 3am
0 3 * * * /usr/local/bin/nightly-backup.sh
A fixed minute and hour with every other field left as *: once a day, at 03:00.
Every 15 minutes
*/15 * * * * /usr/local/bin/check-disk-space.sh
A step value (/N) on the minute field: runs at :00, :15, :30, :45.
Only on weekday business hours
0 9-17 * * 1-5 /usr/local/bin/business-hours-check.sh
A range on the hour field (9 through 17) combined with a range on day-of-week (Monday–Friday), both inclusive.
Once a month, on the 1st
30 2 1 * * /usr/local/bin/monthly-report.sh
Day-of-month pinned to 1; runs 02:30 on the first of every month regardless of weekday.
Every Sunday at midnight
0 0 * * 0 /usr/local/bin/weekly-cleanup.sh
Day-of-week 0 is Sunday (7 also works, as an alias for the same day).
Three fixed times a day
0 8,12,18 * * * /usr/local/bin/three-times-daily.sh
A comma-separated list on the hour field: exactly those three hours, nothing in between.
Every Friday night
0 22 * * 5 /usr/local/bin/friday-night.sh
Day-of-week 5 is Friday. Combine a fixed hour with a single day-of-week value for a weekly job on a specific day.
Twice an hour, at :15 and :45
15,45 * * * * /usr/local/bin/twice-hourly.sh
Lists work on any field, not just hour - this fires at quarter-past and quarter-to every hour.
Once a year
0 0 1 1 * /usr/local/bin/new-year.sh
Minute, hour, day-of-month, and month all pinned: fires exactly once, on 1 January at midnight.
Day-of-month and day-of-week combine with OR, not AND
0 0 1 * 1 /usr/local/bin/first-or-monday.sh
When both day-of-month and day-of-week are restricted (not *), the job runs if either matches - here, the 1st of the month, or any Monday. Easy to misread as 'the first Monday.'
Use month or weekday names instead of numbers
0 6 * jan,jul * /usr/local/bin/biannual-check.sh
The three-letter English names (jan–dec, sun–sat) work anywhere a numeric range or list does and read more clearly in a diff.
Run a command directly, no wrapper script
0 4 * * * find /var/log -name "*.log" -mtime +30 -delete
The command field is handed to /bin/sh -c verbatim - pipes, redirects, and multiple commands all work exactly as they would in a shell script.
Special time strings
Shorthand that replaces all five fields for the schedules that come up constantly.
Run once at startup
@reboot /usr/local/bin/startup-check.sh
Fires once when cron itself starts - in practice, once per boot. Doesn't wait for other services; add your own readiness check if the job depends on one.
Run once a day
@daily /usr/local/bin/daily-digest.sh
Equivalent to 0 0 * * *: midnight, every day.
Run once an hour
@hourly /usr/local/bin/hourly-ping.sh
Equivalent to 0 * * * *: the top of every hour.
Run once a week
@weekly /usr/local/bin/weekly-report.sh
Equivalent to 0 0 * * 0: midnight at the start of Sunday.
Run once a month
@monthly /usr/local/bin/monthly-invoice.sh
Equivalent to 0 0 1 * *: midnight on the 1st.
Run once a year
@yearly /usr/local/bin/renew-certs.sh
Equivalent to 0 0 1 1 *. @annually is an accepted alias for the exact same schedule.
Running jobs as another user
Root can manage any user's crontab with -u; everyone else is restricted to their own.
List another user's crontab as root
crontab -u user -l
-u names the target user. Requires root (or the target user themself).
Show output
no crontab for user
Install a crontab for another user
crontab -u user usercron.txt
Loads a crontab file for user, not for whoever's actually running the command.
Confirm it landed in the right place
crontab -u user usercron.txt
crontab -u user -l
Read back what was just installed for the target user.
Show output
0 2 * * * /home/user/bin/backup.sh
Non-root trying to read someone else's crontab
crontab -u root -l
Run as an unprivileged user, this is refused outright rather than silently falling back to your own crontab.
Show output
must be privileged to use -u
Where per-user crontabs live on disk
crontab mycron.txt
sudo stat -c '%A %U:%G %n' /var/spool/cron/crontabs/*
Every user who has a crontab gets a file named after them, owned root:crontab, mode 600. Never edit these directly - always go through crontab so cron notices the change.
Show output
-rw------- root:crontab /var/spool/cron/crontabs/root
Environment and PATH inside cron
cron runs jobs with a deliberately minimal environment - not the one your interactive shell has.
See how thin cron's PATH really is
* * * * * env > /tmp/cron-env.txt
Schedule a job that dumps its own environment, then compare it with what an interactive shell sees. Captured from the same machine, one minute apart.
Show output
# interactive shell:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# inside the cron job:
HOME=/root
LOGNAME=root
PATH=/usr/bin:/bin
SHELL=/bin/sh
PWD=/root
Fix it by setting PATH at the top of the crontab
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 3 * * * mytool --backup
A variable assignment on its own line applies to every job below it in the file. Cheaper than hardcoding a full path into every command.
Or just use full paths and skip PATH entirely
0 3 * * * /usr/local/bin/mytool --backup
The more robust fix: don't rely on PATH resolution at all. Works the same regardless of what environment cron happens to construct.
Send job output to an address instead of the default mailbox
MAILTO=admin@example.com
0 5 * * * /usr/local/bin/report.sh
Any stdout/stderr a job produces gets mailed to MAILTO if it's set (requires a working local MTA). Unset or empty MAILTO discards it instead of mailing root.
Silence a noisy job's mail without touching the script
*/5 * * * * /usr/local/bin/heartbeat.sh > /dev/null 2>&1
Redirect both streams in the crontab line itself rather than editing the script - the fastest fix for 'why am I getting a mail every 5 minutes.'
The classic silent failure: an unescaped % in the command
0 6 * * * /usr/local/bin/report.sh --title Weekly%20Report
Inside a crontab command field, an unescaped % is read as a newline, splitting the line into 'command' and 'stdin fed to that command' at the %. Verified: a job written this way with a trailing redirect never created its output file, because the redirect ended up on the stdin side of the split, not the command side.
Escape % to use it literally
0 6 * * * date +\%d-\%m-\%Y >> /var/log/date-stamp.log
A backslash-escaped \% is passed through literally instead of being read as a line break. Needed anywhere a command argument or date format genuinely contains a percent sign.
Logging output and avoiding overlapping runs
These are the problems that only show up once a job has been running unattended for a while.
Append output to a dedicated log file
0 5 * * * /usr/local/bin/report.sh >> /var/log/report.log 2>&1
Keeps a running history you can tail, independent of whether mail delivery is even configured. 2>&1 must come after the >> redirect to also capture stderr.
Prevent a slow job from overlapping with its own next run
0 1 * * * flock -n /tmp/backup.lock /usr/local/bin/backup.sh
flock -n takes a lock and exits immediately (rather than waiting) if another instance already holds it - so if last night's backup is still running, tonight's invocation just skips instead of piling up.
Find where cron itself is logging
grep -i cron /var/log/syslog | tail -5
cron logs each job it starts, plus crontab install/remove events, to syslog - separate from whatever the job's own command writes.
Show output
2026-08-13T20:33:54+00:00 host cron[845]: (CRON) INFO (pidfile fd = 3)
2026-08-13T20:33:54+00:00 host cron[846]: (CRON) STARTUP (fork ok)
2026-08-13T20:33:54+00:00 host cron[846]: (CRON) INFO (Running @reboot jobs)
2026-08-13T20:33:54+00:00 host crontab[854]: (root) REPLACE (root)
2026-08-13T20:34:01+00:00 host CRON[864]: (root) CMD (/usr/bin/true)
Read a crontab change straight out of the log
journalctl -u cron --since "10 minutes ago"
On a systemd host, the same events are also queryable through the journal instead of grepping a flat file directly.
System-wide cron
/etc/crontab and /etc/cron.d/ run as root by default but, unlike a per-user crontab, name the user to run as in an extra field - and you edit them directly, not through crontab.
Read the system crontab
cat /etc/crontab
Ships pre-populated on Debian, wiring up the standard periodic directories below via anacron.
Show output
# /etc/crontab: system-wide crontab
# Unlike any other crontab you don't have to run the `crontab'
# command to install the new version when you edit this file
# and files in /etc/cron.d. These files also have username fields,
# that none of the other crontabs do.
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Example of job definition:
# .---------------- minute (0 - 59)
# | .------------- hour (0 - 23)
# | | .---------- day of month (1 - 31)
# | | | .------- month (1 - 12) OR jan,feb,mar,apr ...
# | | | | .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
# | | | | |
# * * * * * user-name command to be executed
17 * * * * root cd / && run-parts --report /etc/cron.hourly
25 6 * * * root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }
47 6 * * 7 root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.weekly; }
52 6 1 * * root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.monthly; }
#
Add a system job as a drop-in file
sudo tee /etc/cron.d/backup-nightly <<< '0 3 * * * root /usr/local/bin/backup.sh'
Each file in /etc/cron.d/ is a mini crontab in its own right - same six fields (five time fields plus user), no need to touch /etc/crontab directly. Picked up automatically, no reload required. tee is here rather than a redirect because sudo does not extend to the shell's >.
Drop a script into the daily run instead of scheduling it yourself
sudo install -m 755 cleanup.sh /etc/cron.daily/cleanup
run-parts executes every executable file in /etc/cron.daily/ once a day (via anacron on Debian, which also catches up on missed runs after downtime - a per-user crontab can't do that). No file extension, no scheduling syntax to get wrong.
See what's already scheduled to run daily
ls /etc/cron.daily/
Package installs commonly drop their own maintenance scripts here - log rotation, package index updates, man-db's search index.
Show output
Your output will differ: which jobs are here depends on what you have installed
apt-compat
dpkg
man-db
Troubleshooting
A job that runs fine by hand but silently does nothing on schedule has one of a small number of causes.
Confirm the cron daemon is actually running
systemctl status cron
No jobs fire at all if the daemon itself isn't up - worth ruling out first, before suspecting the crontab syntax.
Restart cron after editing a system crontab by hand
sudo systemctl reload cron
Per-user crontab changes via crontab -e take effect immediately; hand-edits to /etc/crontab or /etc/cron.d/ are picked up automatically too on modern cron, but a reload after a bulk edit costs nothing and rules out a caching issue.
Reproduce cron's exact environment for debugging
env -i HOME=$HOME LOGNAME=$LOGNAME PATH=/usr/bin:/bin SHELL=/bin/sh /usr/local/bin/mytool
env -i clears the environment before setting just the handful of variables cron itself provides, so you can run the failing command by hand under the same conditions instead of guessing.
Check whether cron.allow or cron.deny is blocking a user
cat /etc/cron.allow /etc/cron.deny 2>/dev/null
If cron.allow exists, only users listed in it may use crontab at all - everyone else gets a permission error before syntax is ever considered. Neither file exists by default on Debian, which allows all users.