crontab

Schedule recurring commands and manage per-user crontabs

Updated 2026-08-17

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
13 outputs, collapsed by default

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.

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

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.

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)

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.

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.