Skip to content

systemd Timers: OnCalendar Expressions and Persistent Jobs

Write systemd timers that behave: OnCalendar syntax checked with systemd-analyze, Persistent catch-up after downtime, random delays, monotonic timers, cron equivalents.

●By 10 min read

Cron has one job and does it with five fields. systemd timers take two files to do the same thing, which is why many admins never switch. What the extra file buys you is real: a job that runs after the machine was off at its scheduled time, logs that land in the journal with an exit code, no overlapping runs, resource limits, and a command that tells you exactly when the next run will happen.

Our cron jobs guide compares the two at a high level. This article goes deep on the timer side: the OnCalendar grammar, how Persistent= really behaves, and the options that keep a fleet of timers from firing at the same second.

The Two-Unit Pattern

A timer does not run commands. It starts a service unit, by default the one with the same name. The service describes the work, the timer describes when:

# /etc/systemd/system/db-backup.service

[Unit]

Description=Nightly PostgreSQL dump

Wants=network-online.target

After=network-online.target

[Service]

Type=oneshot

User=postgres

ExecStart=/usr/local/bin/db-backup.sh

Nice=10

IOSchedulingClass=idle

TimeoutStartSec=2h

# /etc/systemd/system/db-backup.timer

[Unit]

Description=Run db-backup nightly

[Timer]

OnCalendar=*-*-* 02:30:00

Persistent=true

RandomizedDelaySec=15min

[Install]

WantedBy=timers.target

$ sudo systemctl daemon-reload

$ sudo systemctl enable --now db-backup.timer # enable the timer, not the service

$ sudo systemctl start db-backup.service # run the job once, right now, to test it

$ systemctl list-timers db-backup.timer

NEXT LEFT LAST PASSED UNIT ACTIVATES

Sat 2026-10-03 02:41:17 UTC 12h Fri 2026-10-02 02:36:52 UTC 11h ago db-backup.timer db-backup.service

1 timers listed.

Type=oneshot β€” the service counts as running until ExecStart exits, so the journal records the real duration and exit status. Oneshot services have no start timeout by default, so set TimeoutStartSec= to kill a hung job.

No [Install] on the service β€” the timer pulls the service in. Enabling the service as well would also start it at boot, which is rarely what you want for a backup.

No overlap β€” if the previous run is still active when the timer elapses, systemd does not start a second copy. Cron would happily start two, which is why cron scripts need flock.

Generate the units β€” our systemd generator produces both files with hardening options filled in.

OnCalendar Grammar, Tested Before You Deploy

The full form is DayOfWeek Year-Month-Day Hour:Minute:Second. Any part can be omitted or replaced with *, ranges use .., lists use commas, and / adds a repetition step. Never guess what an expression means: systemd-analyze calendar normalizes it and prints the next elapse times.

$ systemd-analyze calendar --iterations=3 "Mon..Fri 09:00"

Original form: Mon..Fri 09:00

Normalized form: Mon..Fri *-*-* 09:00:00

Next elapse: Mon 2026-10-05 09:00:00 UTC

From now: 2 days left

Iter. #2: Tue 2026-10-06 09:00:00 UTC

From now: 3 days left

Iter. #3: Wed 2026-10-07 09:00:00 UTC

From now: 4 days left

$ systemd-analyze calendar "*:0/15"

Original form: *:0/15

Normalized form: *-*-* *:00/15:00

Next elapse: Fri 2026-10-02 14:15:00 UTC

From now: 6min left

ExpressionMeaning
hourly*-*-* *:00:00, at the top of every hour
daily*-*-* 00:00:00, midnight
weeklyMon *-*-* 00:00:00, Monday at midnight
monthly*-*-01 00:00:00, first of the month
*:0/15Every 15 minutes: :00, :15, :30, :45
Mon..Fri 09:00Weekdays at 09:00
Sat,Sun 10:00Weekends at 10:00
*-*-1,15 04:00The 1st and 15th of each month at 04:00
*-*~01 23:00Last day of every month at 23:00 (~ counts from the end)
Mon *-*-01..07 02:00First Monday of the month: a Monday that falls on days 1 to 7
*-01,04,07,10-01 06:00Quarterly, at 06:00 on the first day of each quarter
Mon..Fri 09:00 Europe/Berlin09:00 Berlin time, regardless of the host time zone

Weekday AND date β€” unlike cron, where a day-of-month and a day-of-week in the same line are ORed, systemd requires both to match. That is what makes Mon *-*-01..07 mean the first Monday.

Seconds default to 00 β€” Mon..Fri 09:00 fires at 09:00:00. Omitting the time entirely, as in Mon, means midnight.

Several schedules β€” a timer may have more than one OnCalendar= line; it elapses at whichever comes first. In a drop-in, an empty OnCalendar= clears the list before you add new entries.

Time zones β€” without a suffix, expressions use the system time zone. On a fleet spread across regions, set servers to UTC or add an explicit zone to the expression.

Persistent=true: Catching Up Missed Runs

A laptop that is asleep at 02:30, or a server rebooted for patching during the backup window, simply misses a cron job. With Persistent=true, systemd records when the timer last fired on disk. At boot, or when the timer is next started, it compares that stamp with the schedule and, if an elapse was missed, runs the service once, immediately.

# The stamp files that make it work

$ ls -l --time-style=+%F\ %T /var/lib/systemd/timers/

-rw-r--r-- 1 root root 0 2026-10-02 02:36:52 stamp-db-backup.timer

-rw-r--r-- 1 root root 0 2026-09-28 00:04:11 stamp-fstrim.timer

# User timers keep theirs in ~/.local/share/systemd/timers/

# Simulate a missed run on a test box: power off past 02:30, boot, then

$ journalctl -u db-backup.service -b

Oct 03 08:01:12 db1 systemd[1]: Starting db-backup.service - Nightly PostgreSQL dump...

Oct 03 08:03:40 db1 systemd[1]: db-backup.service: Deactivated successfully.

Oct 03 08:03:40 db1 systemd[1]: Finished db-backup.service - Nightly PostgreSQL dump.

Only one catch-up run β€” if the machine was off for a week, a daily timer runs once at boot, not seven times.

Calendar timers only β€” Persistent= affects OnCalendar= timers. Monotonic timers (below) count from boot or activation and have nothing to catch up.

Boot storms β€” on a host with ten persistent timers, all missed jobs start together at boot. That is where RandomizedDelaySec= earns its place.

Reset it β€” systemctl clean --what=state db-backup.timer removes the stamp on recent systemd versions, so the timer behaves as though it never ran.

sponsored

Spreading Load: Accuracy and Random Delays

Put OnCalendar=daily on a hundred VMs sharing one storage array and they all start compressing logs at exactly midnight. Timers have three knobs for this:

OptionDefaultWhat it does
AccuracySec=1minLets systemd coalesce wake-ups within this window to save power; set 1s when the exact minute matters
RandomizedDelaySec=0Adds a random delay between 0 and this value each time the timer elapses
FixedRandomDelay=falseMakes the random delay stable per host and timer, so a machine always runs at the same offset (systemd 247+)
WakeSystem=falseResumes a suspended machine to run the job, on hardware that supports RTC wake
Unit=same nameActivates a differently named service, for example one template instance

# Fleet-friendly nightly job: somewhere between 01:00 and 03:00,

# but always at the same time on a given host

[Timer]

OnCalendar=*-*-* 01:00:00

RandomizedDelaySec=2h

FixedRandomDelay=true

Persistent=true

Many distribution timers already do this. systemctl cat apt-daily.timer on Debian and Ubuntu, or systemctl cat dnf-makecache.timer on Fedora and RHEL, are good references for real-world settings.

Monotonic and Transient Timers

Not every job belongs on the wall clock. Monotonic timers count from an event: boot, the timer being started, or the service last running. They are the right tool for "every 30 minutes after it last finished", which cron cannot express at all.

[Timer]

OnBootSec=5min # first run 5 minutes after boot

OnUnitInactiveSec=30min # then 30 minutes after each run finishes

# One-off and ad-hoc timers without writing files

$ sudo systemd-run --on-calendar="*-*-* 03:00" --unit=tmp-cleanup /usr/local/bin/cleanup.sh

Running timer as unit: tmp-cleanup.timer

Will run service as unit: tmp-cleanup.service

$ sudo systemd-run --on-active=2h --unit=revert-fw /usr/sbin/nft -f /etc/nftables.conf

$ systemctl list-timers revert-fw.timer

OptionCounts from
OnBootSec=System boot
OnStartupSec=Start of the service manager; differs from boot for user timers
OnActiveSec=The moment the timer itself was activated
OnUnitActiveSec=The last time the target service was started
OnUnitInactiveSec=The last time the target service stopped

Give it a starting point β€” a timer with only OnUnitActiveSec= waits for the service to run once before it ever fires. Combine it with OnBootSec= or OnActiveSec=.

Transient means temporary β€” timers created with systemd-run live in /run and disappear at reboot. The --on-active pattern is a handy safety net: schedule a firewall rollback before you change rules remotely, then stop the timer once you confirm you can still connect.

User timers β€” put units in ~/.config/systemd/user/ and use systemctl --user. Run loginctl enable-linger username so they keep firing when the user is logged out.

Cron Equivalents and Failure Alerts

CronOnCalendar / Timer
*/15 * * * **:0/15
30 2 * * **-*-* 02:30
0 9 * * 1-5Mon..Fri 09:00
0 0 * * 0Sun 00:00
0 4 1,15 * **-*-1,15 04:00
(not possible)*-*~01 23:00
@rebootOnBootSec=1min

Converting an existing crontab line? Our cron generator decodes the five fields so you can translate them reliably. Cron mails output to the owner on failure; with timers, wire alerts up explicitly with OnFailure= and a template unit:

# /etc/systemd/system/[email protected]

[Unit]

Description=Failure alert for %i

[Service]

Type=oneshot

ExecStart=/usr/local/bin/alert.sh %i

# In db-backup.service, under [Unit]:

OnFailure=notify-failure@%n.service

# Check results and logs

$ systemctl status db-backup.timer # shows the Trigger: line with the next run

$ systemctl list-timers --all # includes inactive timers

$ journalctl -u db-backup.service --since yesterday

$ systemd-analyze verify /etc/systemd/system/db-backup.timer

After editing a timer, run systemctl daemon-reload and restart the timer so the next elapse is recalculated. When a unit misbehaves in ways the journal does not explain, our guide to debugging systemd units walks through the dependency and status tools.

Frequently Asked Questions

What does Persistent=true do in a systemd timer?

It stores the time the timer last elapsed in /var/lib/systemd/timers. When the system boots, or the timer is started again, systemd checks whether a scheduled run was missed while the machine was off and, if so, starts the service once immediately. It applies only to OnCalendar timers.

How do I test an OnCalendar expression?

Run systemd-analyze calendar followed by the expression in quotes. It prints the normalized form and the next elapse time, and --iterations=N shows the next N runs. For an installed timer, systemctl list-timers shows when it will fire next.

Why does my timer run a few seconds or a minute late?

AccuracySec defaults to one minute, which lets systemd group wake-ups together to save power, so a timer can fire anywhere within that minute. Set AccuracySec=1s if the exact time matters. RandomizedDelaySec, if set, adds a further deliberate random delay.

Should I enable the timer or the service?

Enable and start the timer with systemctl enable --now name.timer. The service should normally have no [Install] section at all; the timer activates it. You can still start the service manually with systemctl start name.service to test the job.

Can a systemd timer run while a previous run is still going?

No. A timer starts its service unit, and starting a unit that is already active does nothing, so a long run is never duplicated. The next run happens at the next elapse after the service has finished. Cron has no such protection and needs flock for the same guarantee.

sponsored

Related Tools

See also