UnixTimestamp.app

How to Write a systemd Timer with OnCalendar Instead of Cron

A systemd timer is two small files working as a pair: a service unit that says what to run, and a timer unit that says when. Instead of cron's five fields you write an OnCalendar expression, and in exchange you get catch-up runs after downtime, randomised start times and journal logs without writing any extra code.

Sophie ParkerPublished ยท 9 min read
Two linked unit cards, one with a clock dial and one with a gear, beside a calendar wheel and a dimmed five-slot cron strip
Key takeaways The short version, before the details.
  1. A .timer unit activates a .service unit of the same name, so one scheduled job is normally two files.
  2. OnCalendar takes a calendar expression shaped like DayOfWeek Year-Month-Day Hour:Minute:Second, with two dots for ranges and a slash for steps.
  3. Persistent=true runs a job that was missed while the machine was off, and RandomizedDelaySec spreads identical timers across many machines.
  4. Enable the timer, never the service, then confirm the schedule with systemctl list-timers and systemd-analyze calendar.

What a systemd timer is and how it works

A systemd timer is a unit file ending in .timer. It contains no command at all. Its only job is to activate another unit at the right moment, and unless you say otherwise that unit is the service with the same base name: backup.timer starts backup.service.

That split is the whole idea. The service says what the work is: which binary, which user, which environment, which sandbox. The timer says when. You can run the service by hand without touching the schedule, and you can change the schedule without touching the job.

System-wide units live in /etc/systemd/system/. Per-user units live in ~/.config/systemd/user/ and are managed with systemctl --user. Both kinds accept exactly the same OnCalendar syntax.

Because the job is a real service unit, everything it writes to stdout and stderr lands in the journal automatically. There is no MAILTO line to configure and no output to redirect to a log file yourself.

A complete systemd timer example with both files

Here is a full systemd timer example: a nightly backup that runs at 03:00. First the service unit, which holds the command.

# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh

Type=oneshot tells systemd the process is expected to run once and exit, rather than stay alive like a daemon. There is no [Install] section, and that is deliberate: this unit is never enabled on its own.

Then the timer unit, which holds the schedule.

# /etc/systemd/system/backup.timer
[Unit]
Description=Run the nightly backup at 03:00

[Timer] 03:00:00
Persistent=true

[Install]
WantedBy=timers.target

Two commands switch it on. Reload so systemd sees the new files, then enable and start the timer in one step.

sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
Enable the .timer, not the .service. Enabling the service would make the backup run once at every boot, which is not what you asked for. The WantedBy=timers.target line in the timer is what makes the schedule survive a reboot.

The full list of directives that can appear in a [Timer] section is in the systemd.timer manual page.

How does systemd OnCalendar syntax work field by field

A systemd OnCalendar value is not five space-separated numbers. It is a calendar expression built from three groups:

DayOfWeek Year-Month-Day Hour:Minute:Second

Each group can be dropped. Omit the day of week and every weekday matches. Omit the date and it means every day. Omit the seconds and they default to zero, so 03:00 and *-*-* 03:00:00 are the same schedule. An asterisk in any position means any value, just as in cron.

So the shortest daily schedule is a single time, and a weekday schedule is a day name plus a time:

OnCalendar=03:00 03:00 12:30:00 04:00:00

Which operators can you use in OnCalendar?

OperatorMeaningExample
*Any value*-*-* 03:00:00
,List of valuesMon,Wed,Fri 07:00
..Range of values, two dots and not a hyphenMon..Fri 07:00
/Repetition step from a starting value*:0/15 is every 15 minutes
~Day counted back from the end of the month*-*~01 23:00 is the last day of every month

The two dots are the first thing that trips up people coming from cron, because a hyphen is already taken as the separator inside the date group. The ~ operator has no cron equivalent at all.

Can you use shorthand names like daily and weekly?

Yes. OnCalendar accepts a set of named schedules that expand to full expressions.

ShorthandExpands to
minutely*-*-* *:*:00
hourly*-*-* *:00:00
daily*-*-* 00:00:00
weeklyMon *-*-* 00:00:00
monthly*-*-01 00:00:00
yearly*-01-01 00:00:00

Every accepted form, including the ones not listed here, is specified in the systemd.time manual page.

Does OnCalendar use UTC or local time?

Local time, as configured on the host. That means a 03:00 job shifts with daylight saving, and two servers in different regions running the same unit file will not fire together.

systemd 252 and later let you pin the expression by appending a timezone name or UTC to it:

OnCalendar=Mon..Fri 09:00 UTC

On older releases the only lever is the host timezone itself, which our Linux date command generator will write out for you along with the command that verifies it took effect. If you need to know what a given local time is on another machine, the timezone converter does that conversion for any IANA zone.

Which cron expressions map to which OnCalendar values

Most migrations are mechanical. Read the cron fields in their usual order (minute, hour, day of month, month, day of week) and rebuild them as a calendar expression.

CronScheduleOnCalendar
*/5 * * * *Every 5 minutes*:0/5
0 * * * *Top of every hourhourly
0 3 * * *Every day at 03:00*-*-* 03:00:00
0 3 * * 1-5Weekdays at 03:00Mon..Fri 03:00
0 */6 * * *Every 6 hours*-*-* 00/6:00:00
0 8,20 * * *Twice a day*-*-* 08,20:00:00
30 2 1 * *First of the month at 02:30*-*-01 02:30:00
15 14 * * 0Sundays at 14:15Sun 14:15
0 0 1 1 *Once a year on 1 January*-01-01 00:00:00
One conversion is not mechanical. When a cron line restricts both the day of month and the day of week, cron fires if either matches. systemd requires both to match. A line like 0 0 13 * 5 runs on the 13th and on every Friday in cron, but the equivalent calendar expression only runs on Friday the 13th.

If you are not certain what the old cron line actually did, decode it before you translate it. Paste it into our cron builder and you get a plain English description, a per-field breakdown and the next 10 run times in your browser's local time.

Decode the cron line firstSee its plain English meaning and the next 10 runs before you rewrite it Open cron builder

What can a systemd timer do that cron cannot?

Two directives are the usual reason people move a job over in the first place.

Persistent=true makes systemd remember when the timer last fired by writing a stamp file on disk. If the machine was off or suspended at the scheduled moment, the job runs shortly after the next boot instead of being silently skipped. A cron job on a laptop that is closed at 03:00 simply never happens.

RandomizedDelaySec delays each activation by a random amount between zero and the value you give. Ship the same unit to 200 machines and their backups no longer all start on the same second and saturate the network.

[Timer] 03:00:00
Persistent=true
RandomizedDelaySec=30m
FixedRandomDelay=true

FixedRandomDelay=true keeps each machine's offset stable instead of rerolling it every day, so a host that starts at 03:17 keeps starting at 03:17.

Two more things cron has no answer for:

  • AccuracySec controls how much systemd is allowed to shift the activation in order to batch timers together and let the CPU stay idle longer. The default is one minute, so set AccuracySec=1s when a job really must start on the second.
  • Monotonic timers schedule relative to an event rather than a clock. OnBootSec=15min runs a quarter of an hour after boot, and OnUnitActiveSec=2h runs two hours after the last run finished, which never drifts into overlapping runs the way a fixed interval can.

How to check the next run with systemctl list timers

Never trust a schedule you have not read back. The command is systemctl list-timers, and it prints one row per active timer with the next elapse, how long is left, when it last ran, how long ago that was, the timer unit and the service it activates.

systemctl list-timers
systemctl list-timers --all
systemctl list-timers backup.timer

Add --all to include timers that are loaded but not currently active, which is how you notice the unit you thought you enabled. For a user timer, add --user.

To test an expression before it ever reaches a unit file, ask systemd to parse it directly:

systemd-analyze calendar "Mon..Fri 03:00"
systemd-analyze calendar --iterations=5 "*:0/15"

The output shows the form you typed, the normalized form systemd actually uses, and the next elapse. A typo is rejected here rather than quietly never firing. The --iterations flag prints several upcoming runs, which is the fastest way to confirm that a step expression steps the way you expected. Other systemd-analyze subcommands will also validate the unit files themselves.

Finally, check the job separately from the schedule. Run the service once by hand and read its output:

sudo systemctl start backup.service
journalctl -u backup.service -n 50

When should you use a systemd timer instead of cron?

Use a timer when the job matters: when a missed run needs to be caught up, when the job must wait for the network or the database to be up, when it should run under a restricted user with a sandbox, or when the same schedule ships to a fleet. The dependency directives and resource limits you already know from services all apply.

Stick with cron for a throwaway one-liner on a box you control, or when you are working on a system without systemd. Cron is one line in one file and that is a real advantage. If the cron job you already have is the problem, our guide on why a cron job is not running walks the usual causes before you rewrite anything. The Arch Wiki page on timers is a good second read once you have the basics down.

A schedule dial stepping into a list of ten upcoming run times
Cron expression builder

Get the schedule right before you write the unit file

Build the five cron fields visually or paste an existing expression to decode it, then read the next 10 run times in plain English and translate the result into OnCalendar.

Open the cron builderFree, runs in your browser

Frequently asked questions

A cron job is one line in a crontab that runs a command. A systemd timer is a unit file that activates a separate service unit, so the job inherits everything services get: journal logging, dependency ordering, sandboxing, catch-up runs after downtime and randomised start delays. Cron offers none of those.

Read the five cron fields in order, then rebuild them as DayOfWeek Year-Month-Day Hour:Minute:Second. Ranges become two dots, steps keep the slash, lists keep the comma. Watch the day-of-month and day-of-week rule, which systemd treats as and rather than or, and validate the result with systemd-analyze calendar.

Run systemctl list-timers. It lists each active timer with its next elapse, the time left, the last run and the unit it activates. Add --all to include timers that are loaded but inactive, and --user for per-user timers. To test a bare expression with no unit file, use systemd-analyze calendar instead.

Usually the timer was started but never enabled, so nothing pulls it in at boot. Enable the timer unit rather than the service, and keep WantedBy=timers.target in its Install section. If the machine was switched off at the scheduled moment, only Persistent=true makes systemd run the missed job once it comes back up.

By default yes, because a timer activates the unit with the same base name, so backup.timer needs backup.service. You can point several timers at one service by setting Unit= in the Timer section, or use a template service so a single file serves many instances with different arguments.