Tyblog

All the posts unfit for blogging
blog.tjll.net

« You Don't Love systemd Timers Enough

  • 5 May, 2026
  • 1,999 words
  • 8 minute read time

cave.png

Figure 1: Plato's Cave by Jan Pietersz Saenredam; 24 hour clock licensed under CC3 from Wikimedia; systemd logo by the systemd project licensed under CC-BY-SA 4.0

My favorite metonymic technology term is "cron job": even though cron may not literally be the daemon that executes actions on a schedule, we apply the term to anything that walks like a cron and quacks like a cron. As Patrick McKenzie likes to point out, cron jobs are one of the most eminently useful computing primitives. They offer utility that's almost immediately obvious for plenty of use cases that almost everybody has: do this every day; do that once a month.

And yet. You probably shouldn't use literal cron (or its more modern cousins) for scheduled tasks! In 2026 there are more modern options available, and my favorite is the humble systemd timer. I love systemd timers. If you don't love them yet, maybe I can show you the reasons why you should love them, too.

My cron? Cooked?

A systemd timer is a type of unit that schedules other units (usually a service) on a particular schedule. (How a systemd service unit works is another article, but you can logically consider the .service target of a systemd timer to be a script.) Timers are effectively a functional replacement for a traditional cron daemon (though you could conceivably run both), and timer calendar settings offer some similarities to help bridge the gap from traditional cron-like expressions.

At this point the systemd haters peer out of the woodwork in anticipation of torpedoing timers because they are part of the systemd project and because they replace mature (if clunky) technology. I'd rather not spend our time arguing about cron, so briefly consider why newer solutions like systemd timers that benefit from years of hindsight are better:

  • Ambiguous $PATH settings make cron script execution difficult to predict.
  • stdout and stderr output often ends up in a black hole (and, often, sent to the host's mail system, which is usually not what you want to happen.)
  • Execution history is difficult to follow and interrogate.
  • You might feel cool knowing the scheduling grammar by heart, but 01,31 04,05 1-15 1,6 * isn't easy or intuitive for humans to read.

Incidentally, timers solve all these problems (and more.)

Prime Time for a Timer Primer

We can cover the basics without a lot of ceremony. First you need a target for a timer to execute. On a Linux host with systemd operational, placing the following unit contents at /etc/systemd/system/roulette.service installs a service with a 1 in 10 chance to be free (i.e., shut down your computer):

Systemd
  • Font used to highlight strings.
  • Font used to highlight keywords.
  • Font used to highlight type and class names.
[Unit]
Description=1 in 10 chance to break your chains

[Service]
ExecStart=/usr/bin/env bash -c '[[ $(($RANDOM % 10)) == 0 ]] && systemctl poweroff || echo LIVE ANOTHER DAY'

Associate that service with a timer by placing a file with the same file stem (roulette) at /etc/systemd/system/roulette.timer:

Systemd
  • Font used to highlight keywords.
  • Font used to highlight type and class names.
[Unit]
Description=impending destruction

[Timer]
OnCalendar=10:00

[Install]
WantedBy=timers.target

What I mean by associate is that, by default, a timer's Unit= setting will choose a service unit with a matching stem suffixed by .service. In this case, roulette.service. You can always change this if you want to execute a service with a different unit name.

I want to call out a few things right away:

  • Per normal service unit semantics, the ExecStart= target does not run as a shell command by default. You should treat the absolute path target like a script or, in our case, an interpreter that expects a script as a string argument. For example, ExecStart=/usr/bin/echo Hello | /usr/bin/awk straight-up won't work; the pipe makes no sense in context here.
  • The ExecStart= argument does not inherit any environment variables by default (outside of some system manager defaults), so we begin with a pretty bare $PATH by default. Executing /usr/bin/env is a shortcut to ensure things like systemctl are available, but out of the box, you get a clean state to begin with. If we had used a bare ExecStart=/usr/bin/bash, we'd have the basics in $PATH, but using env here is an extra safeguard.

You can roll the dice without the aid of the timer at all:

shell
systemctl start roulette

Although note that you cannot enable this service without any usable [Install] section: our timer is the canonical way to make the service run in a consistent way. Also useful to highlight that systemctl operates on roulette.service by default without any explicit suffix.

When applied to a .timer unit, the systemctl start subcommand puts it on the clock, per se, but does not actually execute the Unit= target.

shell
systemctl start roulette.timer

The timer is now active, but not the service.

Depending on the moment in time, status will tell you when to next expect the timer to decide your fate:

shell
systemctl status roulette.timer

You'll see plenty of information about the timer on its status page, including the next time it'll fire:

Trigger: Sat 2026-04-18 10:00:00 MDT; 35min left

That's the simplest timer onboarding: create a target, place the target service file alongside a timer with a schedule, and start the timer (not the target) to get the schedule started. Because the .timer defines an WantedBy= within [Install], we can ensure the timer comes up at boot time too, not just when we start it:

shell
systemctl enable roulette.timer

Let's move on past the basics.

Time Lord

Arguably the most important bit of information about timers is how to express a schedule, whether a repeating period of time (which the manual usually refers to as a time span) versus a calendar event (or a timestamp). Fortunately, I think the man page for this under systemd.time(7) is actually very good with plenty of examples. You should use it as the first resource when writing timers; it's good (or better) than, uh, casual blog posts by casual writers.

systemd also ships with a command-line tool called systemd-analyze which includes the ability to validate and explain time expressions from the command line directly in an imperative way to help understand them. You can even disambiguate the classic wildcard cron expression which systemd-analyzer can parse and then explain to you, complete with the expected execution times:

shell
systemd-analyze calendar '*-*-* *:*:*'
Normalized form: *-*-* *:*:*
    Next elapse: Sat 2026-04-18 16:44:26 MDT
       (in UTC): Sat 2026-04-18 22:44:26 UTC
       From now: 431ms left

This blog post is not the place to reproduce the entirety of systemd.time(7) verbatim, so I encourage you to Read The Helpful Manual (RTHM). Writ small, you can pretty simply define either a recurring wallclock period or, in contrast to plain old cron, a recurring period of time against some previous event.

The first category of time expressions is easy to envision. For example, in fully-qualified form, daily means:

*-*-* 00:00:00
│ │ │ │  │  ╰── at second 00
│ │ │ │  ╰───── at minute 00
│ │ │ ╰──────── at hour 00
│ │ ╰────────── every day
│ ╰──────────── every month
╰────────────── every year

You can use shorthand terms like daily, write out the complete form, or use any other supported value listed out in systemd.time(7) and subsequently validate your assumptions against systemd-analyze.

The second category of time expressions apply to "run this relative to some other event." This distinction from "run at the same time very day" is very often what you actually want. Consider a job that clears out a temporary directory, for example: if a cron expression lapsed right after boot, there probably isn't much to clean out of /tmp at all. But if you encode "execute an hour after my computer has started and then every hour after that", the schedule logic is meaningful for what the related service is actually doing.

This is easy to do in a timer:

Systemd
  • Font used to highlight keywords.
  • Font used to highlight type and class names.
[Timer]
OnBootSec=1h
OnUnitActiveSec=1h

That is: "run an hour after the machine starts" (which will execute once) and also "run one hour after my Unit= runs" (which implicitly makes the timer repeat indefinitely.)

Periodic time spans like this fit the "every once in a while" use case surprisingly more often than "run at this minute every hour" and similar expressions. Another good example is a timer I use every December to poll the Advent of Code API for a Slack bot I wrote for some friends. The */15 cron expression honors the "every 15 minutes" policy that their API requests, but since that's the easiest way to express it in cron language, I'm sure it makes spiky traffic alongside everyone else polling the API! Starting my timer when I've made a code fix that runs whenever 15 minutes has lapsed is all I care about, and probably creates less of a thundering herd problem.

Calendar versus time span units is probably the biggest conceptual leap from a traditional cron job, but timers offer more, too.

Bird's-Eye Countdown

My favorite high-level command to get a picture of a machine's timer situation is the list-timers subcommand. Here's my host's summary:

shell
systemctl list-timers
NEXT                                 LEFT LAST                                  PASSED UNIT                         ACTIVATES
Mon 2026-04-20 15:15:00 MDT      1min 40s Mon 2026-04-20 15:00:05 MDT        13min ago zfs-snapshot-frequent.timer  zfs-snapshot-frequent.service
Mon 2026-04-20 15:32:16 MDT         18min Mon 2026-04-20 14:22:15 MDT        51min ago fwupd-refresh.timer          fwupd-refresh.service
Mon 2026-04-20 16:00:00 MDT         46min Mon 2026-04-20 15:00:05 MDT        13min ago logrotate.timer              logrotate.service
Mon 2026-04-20 16:00:00 MDT         46min Mon 2026-04-20 15:00:05 MDT        13min ago zfs-snapshot-hourly.timer    zfs-snapshot-hourly.service
Tue 2026-04-21 00:00:00 MDT            8h Mon 2026-04-20 09:43:22 MDT     5h 29min ago zfs-snapshot-daily.timer     zfs-snapshot-daily.service
Tue 2026-04-21 07:31:28 MDT           16h Sun 2026-04-19 20:15:47 MDT           7h ago systemd-tmpfiles-clean.timer systemd-tmpfiles-clean.service
Mon 2026-04-27 00:00:00 MDT        6 days Mon 2026-04-20 09:43:22 MDT     5h 29min ago zfs-snapshot-weekly.timer    zfs-snapshot-weekly.service
Mon 2026-04-27 01:09:27 MDT        6 days Mon 2026-04-20 09:43:22 MDT     5h 29min ago fstrim.timer                 fstrim.service
Mon 2026-04-27 04:28:38 MDT        6 days Mon 2026-04-20 09:43:22 MDT     5h 29min ago zpool-trim.timer             zpool-trim.service
Fri 2026-05-01 00:00:00 MDT 1 week 3 days Wed 2026-04-01 10:07:51 MDT 1 week 1 day ago zfs-snapshot-monthly.timer   zfs-snapshot-monthly.service
Fri 2026-05-01 03:17:17 MDT 1 week 3 days Wed 2026-04-01 10:07:51 MDT 1 week 1 day ago zfs-scrub.timer              zfs-scrub.service

11 timers listed.
Pass --all to see loaded but inactive timers, too.

From one command you glean a total picture of anything executing on a timer schedule. Very useful.

list-timers is part of a family of systemd subcommands that I use fairly often. Others that are useful include list-units and list-paths (the latter is a more recent addition to systemctl.)

Suspended Reanimation

Waking a suspended system to run an important script even if you're not around to perform the physical action of, say, lifting a laptop lid sounds like a daunting feat until you find WakeSystem=:

WakeSystem=
    Takes a boolean argument. If true, an elapsing timer will
    cause the system to resume from suspend, should it be
    suspended and if the system supports this.
...

You can imagine the utility for something like this. On a distribution that supports downloading package updates before using them (like Arch or NixOS, for example), you can pre-fetch update packages late a night for morning updates when you're at a keyboard, and there are plenty of other ideas you could apply this too as well. The man page highlights that you'll need to manually re-suspend if you intend for that to happen after your .service is done.

Splay-away

I touched on the thundering herd problem a few paragraphs ago, which is the systems problem of, "what happens when a set of processes all wake up at the same time?" If every Debian system in the world were hard-coded to apt update at 00:00:00, midnight would be a bad, spiky time for everyone.

Two timer options called FixedRandomDelay= and RandomizedOffsetSec= help:

FixedRandomDelay=
    Takes a boolean argument. When enabled, the randomized delay
    specified by RandomizedDelaySec= is chosen deterministically,
    and remains stable between all firings of the same timer,
    even if the manager is restarted. ...

RandomizedOffsetSec=
    Offsets the timer by a stable, randomly-selected, and evenly
    distributed amount of time between 0 and the specified time
    value. ...

I've used this for real systems that check in to update software. Not only does it help with thundering herd problems, but spreading out execution along a uniform distribution ensures that the behavior is consistent and avoids disruptive activities like restarting daemons that may be coordinating distributed services.

In general, timing options are very configurable and expose a great deal of granularity (again, all of which are explained in the man page.)

Insistent Persistence

This option is particularly well-suited for scripts on a schedule that shouldn't ever be skipped due to a suspended laptop but may not warrant WakeSystem=:

Persistent=
    Takes a boolean argument. If true, the time when the service
    unit was last triggered is stored on disk. When the timer is
    activated, the service unit is triggered immediately if it
    would have been triggered at least once during the time when
    the timer was inactive. ...

If you're scheduling a system to checkin to configuration management but the host has undergone downtime, slapping Persistent= on the .timer can mean the difference between converging onto correct state immediately after coming online versus whenever the timer may normally fire (which could be a long time.) There are other good examples of service activations you don't want to wait for if the timer detects a missed activation: system updates, checking for batch jobs, that kind of thing.

A Quick Recap

If you start writing timers in earnest, bear the following in mind:

  • Timers that run in the context of a user manager (the kind you interact with via systemctl --user) are totally valid, but keep an eye on the target you use for [Install]. Sometimes the appropriate target to use for this is default.target depending on the distribution.
  • The usual caveats about keeping a correct system clock apply like they always did with cron. Fellow systemd cult members can rely on timedatectl timesync-status to peek at their synchronization status.
  • Many editors natively support the systemd unit file format which helps when unit files become large. I use the emacs systemd package.