Writing .timer units

A reference for writing systemd timers, focused on user units in ~/.config/systemd/user.

Checked against systemd.timer(5) from systemd 262~devel. Examples assume systemd 258 or newer.

The two clocks a timer can follow A day from 06:00 to 22:00 with the laptop suspended from 13:00 to 15:00. A calendar timer set for 09:00, 14:00 and 20:00 runs at 09:00, catches up the 14:00 run at 15:00 on resume, then runs at 20:00. A monotonic timer with a 4 hour interval runs at 08:00 and 12:00, then at 18:00 instead of 16:00 because its clock stopped during suspend. laptop suspended OnCalendar= 09:00, 14:00, 20:00 14:00 due while asleep runs on resume OnUnitActiveSec=4h first run at 08:00 4 h of awake time not 16:00 06:00 08:00 10:00 12:00 14:00 16:00 18:00 20:00 22:00

A timer follows one of two clocks. The wall clock keeps running while the machine sleeps, so OnCalendar= runs the missed 14:00 job as soon as the machine wakes up. The monotonic clock stops during sleep, so a 4-hour interval only counts time the machine is awake. That pushes the third run from 16:00 to 18:00.

Mental model

A timer is an alarm clock that starts another unit. The timer never runs anything itself. When it elapses, systemd starts foo.service, exactly as if you had run systemctl --user start foo.service.

  1. The timer decides when. The service decides what, and under which conditions.Ordering such as After= or Wants=, conditions such as ConditionACPower=, and your environment all belong in the .service. The timer only holds the schedule. If a condition fails, systemd skips that run cleanly and does not mark it as failed.
  2. If the service is still running, the timer does nothing.Starting an active unit is a no-op. Runs never overlap and never queue up. The same logic means a service with RemainAfterExit=yes fires once and then stays "active" forever.
  3. There are two kinds of clock.OnCalendar= follows the wall clock. It keeps running during suspend, runs once on resume if it was due, and Persistent=true also remembers runs missed while powered off. The On…Sec= directives measure elapsed time from a reference point, and they pause during suspend. WakeSystem= changes that behavior, but it needs privileges, so user timers can't use it.
  4. A timer only counts while it is active.When your user manager starts, timers.target starts all enabled timers. The user manager starts at your first login, or at boot if lingering is enabled, and stops after your last logout.
  5. Multiple triggers combine with OR.You can list several OnCalendar= lines and mix them with On…Sec= lines, and the timer fires whenever any of them elapses. An empty assignment such as OnCalendar= clears every trigger defined before it, which is how drop-ins replace a schedule.

What each directive counts from

DirectiveCounts fromPauses in suspendIf the time already passed when the timer startsPersistent=
OnCalendar=Wall-clock timeNo, runs once on resumeWaits for the next occurrence, unless Persistent=true catches upApplies
OnActiveSec=The moment the timer itself was startedYesNot applicableIgnored
OnBootSec=Kernel bootYesFires immediatelyIgnored
OnStartupSec=Service manager start, which for user units means your loginYesFires immediatelyIgnored
OnUnitActiveSec=The last time the service was startedYesNever fires if the service has never runIgnored
OnUnitInactiveSec=The last time the service stoppedYesNever fires if the service has never runIgnored

The two-file skeleton

Name the timer and the service the same apart from the suffix, and backup.timer will start backup.service without extra configuration. Use Unit= only when the names must differ. Templates also work: sync@work.timer starts sync@work.service.

~/.config/systemd/user/backup.service
[Unit]
Description=Back up ~/Documents

[Service]
Type=oneshot
ExecStart=%h/bin/backup.sh
# No [Install] section: the timer starts this unit.
~/.config/systemd/user/backup.timer
[Unit]
Description=Back up ~/Documents every morning

[Timer]
OnCalendar=*-*-* 09:00
Persistent=true

[Install]
WantedBy=timers.target
Install, check, test
systemctl --user daemon-reload
systemctl --user enable --now backup.timer    # enable the TIMER, not the service
systemctl --user list-timers backup.timer     # shows NEXT and LEFT
systemctl --user start backup.service         # run the job once, right now
journalctl --user -u backup.service -e

Type=oneshot keeps the service in the "activating" state until the script exits. As a result, OnUnitInactiveSec= and DeferReactivation= measure from the real end of the run, and the journal shows one clean "Finished" line per run. %h expands to your home directory. To create a unit from the editor directly, use systemctl --user edit --full --force backup.timer.

Recipes

Each recipe shows only the [Timer] section, or the files that differ from the skeleton.

Once, at a fixed date

reminder.timer
[Timer]
OnCalendar=2026-12-24 18:00
AccuracySec=1s
Persistent=true    # if I'm not logged in at 18:00, run at next login

The timer fires once. Afterwards it stays loaded with no next elapse, because RemainAfterElapse= defaults to yes. AccuracySec=1s overrides the default one-minute window, described in the gotchas. Persistent= only catches up on runs that fell due after you enabled the timer. If you enable a timer whose date is already in the past, it never runs.

For a one-off reminder, you usually don't need files. A transient timer is simpler, though it disappears when your user manager stops:

Transient timers with systemd-run
systemd-run --user --on-calendar='2026-12-24 18:00' --timer-property=AccuracySec=1s \
    notify-send 'Call grandma'
systemd-run --user --on-active=45min notify-send 'Tea is ready'   # 45 min from now
systemctl --user list-timers                                      # shows run-*.timer entries

Every day at a time

journal.timer
[Timer]
OnCalendar=*-*-* 07:30
Persistent=true

Avoid OnCalendar=daily on a laptop. It means midnight, when the lid is usually closed. With Persistent=true it still runs, but at an unpredictable time: whenever you next log in. Pick a time you are actually at the machine. To pin the time to a timezone other than the system one, append the zone: OnCalendar=*-*-* 07:30 Europe/Paris.

Specific days

standup-notes.timer
[Timer]
# weekdays at 09:00 and also Saturday at 11:00 (two lines are ORed)
OnCalendar=Mon..Fri *-*-* 09:00
OnCalendar=Sat 11:00
Persistent=true
You wantExpression
Weekends at 10:00Sat,Sun 10:00
First Monday of each monthMon *-*-01..07 10:00
Last day of each month*-*~01 18:00
Last Friday of each monthFri *-*~07/1 17:00
1st and 15th of the month*-*-01,15 08:00

Test each expression with systemd-analyze calendar --iterations=5 '…' before you trust it.

Every X minutes or hours

"Every 6 hours" can mean two different things, and they need different directives.

Aligned to the clock: at 00:00, 06:00, 12:00 and 18:00

fetch-mail.timer
[Timer]
OnCalendar=*-*-* 00/6:00
# other shapes:
#   OnCalendar=*:0/15            every 15 min on the quarter hour
#   OnCalendar=*-*-* 08..18:00   hourly, 08:00 to 18:00
#   OnCalendar=*:05              hourly at :05
Persistent=true
DeferReactivation=yes   # if a run overruns, wait for the next slot

DeferReactivation=v257 matters when a run can take longer than the interval. Without it, the missed slot fires again as soon as the long run finishes. With it, the timer waits for the next scheduled slot. It only works with OnCalendar=.

A repetition value restarts at each larger unit, so it must divide the range evenly. *:0/7 fires at :00, :07 … :56 and then :00 again, leaving a 4-minute gap. For a 90-minute rhythm, combine two expressions: OnCalendar=*-*-* 00/3:00 and OnCalendar=*-*-* 01/3:30.

Relative to the last run: 6 hours after the previous run started

index-photos.timer
[Timer]
OnStartupSec=5min      # the starter: first run 5 min after login
OnUnitActiveSec=6h     # then 6 h after each start

OnUnitActiveSec= needs a starter such as OnStartupSec= or OnActiveSec=. On its own it waits for a previous run that never happened. This style drifts, and it counts only awake time, as the diagram at the top shows. Persistent= has no effect on it. Use it for "not too often" jobs where the exact time doesn't matter. If a run can outlast the interval, use OnUnitInactiveSec=6h instead. It counts from the end of the previous run, so runs never follow each other back to back.

A few minutes after login

warm-cache.timer
[Timer]
OnStartupSec=3min

For a user timer, OnStartupSec= counts from the moment your user manager started, which is your first login. OnBootSec= counts from kernel boot. If you log in an hour after booting, a 3-minute OnBootSec= timer is already overdue and fires the moment you log in. With lingering enabled, the user manager starts at boot, and the two become nearly the same. A logout followed by a new login doesn't restart the manager while other sessions or lingering keep it alive, so this recipe fires once per manager start, not once per login.

N minutes after a target is reached

To make the timer count from a target, have the target start the timer, then use OnActiveSec=, which counts from the timer's own start.

~/.config/systemd/user/notes-sync.timer
[Unit]
Description=Sync notes 10 min into each desktop session
PartOf=graphical-session.target   # stop with the session, re-arm next time

[Timer]
OnActiveSec=10min
# add OnUnitActiveSec=1h for "then hourly while the session lasts"

[Install]
WantedBy=graphical-session.target

systemctl --user enable notes-sync.timer now links the timer into graphical-session.target.wants/ instead of timers.target.wants/. Each time the desktop session starts, the timer starts too, and it fires 10 minutes later. PartOf= stops the timer when the session ends. That resets it, so the next session gets its own 10-minute countdown. Without PartOf=, the timer would stay active with nothing left to trigger, and the next session wouldn't re-arm it.

Don't add After=graphical-session.target to the timer. Timers have an implicit Before=timers.target, and the session target is ordered after basic.target, which comes after timers.target. Adding it can create an ordering cycle. Put any ordering you need on the service instead.

The same shape works for any user target the manager actually reaches, such as default.target or xdg-desktop-autostart.target, or a target of your own (see below).

Only while a target is active

This builds on the previous recipe with a real schedule. The timer ticks only while the target is up. The service also refuses to run outside the target, in case something else starts it, and gets stopped if the target goes away mid-run.

screenshot-cleanup.timer
[Unit]
PartOf=graphical-session.target

[Timer]
OnCalendar=*:0/30
# or monotonic, from session start:
#   OnActiveSec=1min
#   OnUnitActiveSec=30min

[Install]
WantedBy=graphical-session.target
screenshot-cleanup.service
[Unit]
PartOf=graphical-session.target
After=graphical-session.target
# fail instead of starting the target:
Requisite=graphical-session.target

[Service]
Type=oneshot
ExecStart=%h/bin/clean-screenshots

Requisite= means the target must already be active, or this unit fails at once. Wants= would try to start the target, and graphical-session.target refuses manual starts anyway. Adding Persistent=true to the timer makes the next session catch up on a slot it missed.

GNOME and KDE Plasma activate graphical-session.target. Many standalone Wayland compositors and plain X window managers don't, unless you launch them through something that does, such as uwsm. Check with systemctl --user is-active graphical-session.target.

A target you switch yourself

A custom user target works as a switch: timers attached to it run only while it is on. Targets don't stop on their own, so the switch stays in whatever position you set.

~/.config/systemd/user/at-office.target
[Unit]
Description=I'm at the office
office-sync.timer
[Unit]
PartOf=at-office.target

[Timer]
OnActiveSec=30s
OnUnitActiveSec=20min

[Install]
WantedBy=at-office.target
Flip the switch from a script, a keybinding or a hook
systemctl --user enable office-sync.timer   # once
systemctl --user start at-office.target     # timers attached to it start
systemctl --user stop at-office.target      # ...and stop, via PartOf=

Spreading runs over a window

DirectiveWhat it doesUse it when
AccuracySec=Lets systemd move the run within a window after the scheduled time, so several timers can share one CPU wake-up. The default is 1min.Almost never, except to tighten it (1s) for exact times.
RandomizedDelaySec=Adds a random delay between 0 and N, re-rolled before every run. It is added to whichever is later: the scheduled time or the manager's startup time.Stagger jobs on this machine so they don't all start together.
FixedRandomDelay=yesKeeps the same random delay for every run of this timer. The value comes from the machine ID, your user and the timer name.You want the stagger but not the jitter.
RandomizedOffsetSec=v258Adds a stable offset between 0 and N to OnCalendar= runs, ignoring manager start, so the cadence stays the same.Spread a weekly or daily job across a long window on machines that reboot or log out often.
offsite-backup.timer
[Timer]
OnCalendar=weekly          # Mon 00:00
RandomizedOffsetSec=5d     # this machine picks a stable day, Mon to Sat
Persistent=true

This is the man page's own example. With RandomizedDelaySec=5d, the delay starts again at every boot or login. A laptop that never stays up for 4 days in a row would then never run the job. RandomizedOffsetSec= doesn't have this problem. To spread runs out, set AccuracySec=1us alongside a randomized delay, because accuracy coalescing pulls runs back together.

When you need network or another system target

Your user manager is a separate instance from the system manager. User units can't use Wants= or After= with system units, so network-online.target, time-sync.target and sleep.target don't exist from a user unit's point of view. You have two options.

Wait inside the user service. The timer fires on schedule and the service waits up to a limit before giving up. This is good enough for a laptop:

fetch-feeds.service (user, NetworkManager systems)
[Service]
Type=oneshot
ExecStartPre=/usr/bin/nm-online -q -t 60
ExecStart=%h/bin/fetch-feeds

Make it a system timer that runs as you. This option gets real ordering, lets the timer wake the machine with WakeSystem=, and runs whether or not you're logged in:

/etc/systemd/system/fetch-feeds.service
[Unit]
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=alice
ExecStart=/home/alice/bin/fetch-feeds
/etc/systemd/system/fetch-feeds.timer
[Timer]
OnBootSec=5min
OnUnitActiveSec=1h

[Install]
WantedBy=timers.target

OnCalendar= syntax

The full form is DayOfWeek Year-Month-Day Hour:Minute:Second Timezone, and every part is optional. A missing date means every day (*-*-*), a missing time means 00:00:00, and missing seconds mean :00. Within each field, , separates a list, .. gives a range, /N repeats every N, * matches anything, and ~ counts days back from the end of the month. The full grammar is in systemd.time(7).

ExpressionNormalized formMeaning
minutely*-*-* *:*:00Every minute
hourly*-*-* *:00:00On the hour
daily*-*-* 00:00:00Midnight
weeklyMon *-*-* 00:00:00Monday midnight
monthly*-*-01 00:00:00First of the month, midnight
quarterly*-01,04,07,10-01 00:00:00Start of each quarter
yearly*-01-01 00:00:00New Year, midnight
*:0/15*-*-* *:00/15:00:00, :15, :30, :45
*-*-* 00/6:00*-*-* 00/6:00:0000:00, 06:00, 12:00, 18:00
Mon..Fri 09:00Mon..Fri *-*-* 09:00:00Weekdays at nine
Mon *-*-01..07 10:00First Monday of the month
*-*~01*-*~01 00:00:00Last day of the month
Fri *-*~07/1 17:00Last Friday of the month
2026-12-24 18:002026-12-24 18:00:00Once
*-*-* 09:00 Europe/Paris09:00 Paris time, whatever the system timezone

Time spans, for the …Sec= directives

A bare number means seconds. Units can be combined, as in OnBootSec=1h 30min. The available units are us, ms, s, min (also m), h, d, w, M (a month, 30.44 days) and y. Be careful with case: m means minutes and M means months. Write min to avoid mistakes, and check with systemd-analyze timespan '90m'.

Targets you can hook

These are the targets inside your user manager. A timer can use WantedBy= and PartOf= with any of them. The authoritative list is in systemd.special(7).

User targetActive whenTypical use for a timer
timers.targetEarly during user manager startThe default WantedBy= for a timer that should always be running
default.targetUser manager has finished starting up, at login or at boot with lingeringRarely needed; OnStartupSec= is simpler
graphical-session-pre.targetJust before the desktop session's services startSetup that must happen before the session starts
graphical-session.targetWhile a graphical session is running. It refuses manual start and stops when nothing needs itAnything that needs a display, notifications or the clipboard
xdg-desktop-autostart.targetWhen the session runs XDG autostart entriesDelays relative to autostart applications
your-own.targetWhenever you start it, until you stop itManual or scripted switches (see above)

System targets for system timers include multi-user.target, graphical.target, network-online.target, time-sync.target and sleep.target. System timers that use OnCalendar= are ordered after time-set.target and time-sync.target automatically, so a machine without a battery-backed clock doesn't fire them at a wrong 1970 time.

Gotchas

Each entry is listed by the symptom you'd notice. Open one to see the cause and the fix.

The timer is enabled but the job never runs

You enabled the service instead of the timer, forgot daemon-reload, or the timer is enabled but was never started.

Run systemctl --user enable --now foo.timer, then check that list-timers shows a NEXT time. Remove any [Install] section from the service. If the service is enabled, it also runs at every login.

OnUnitActiveSec= never fires

It counts from the service's last start. If the service has never started, there is nothing to count from.

Add a starter, such as OnStartupSec=5min or OnActiveSec=1min.

A user timer with OnBootSec= fires the moment I log in

It counts from kernel boot. If you log in after that time has passed, it is already overdue, and overdue OnBootSec= and OnStartupSec= timers fire immediately.

Use OnStartupSec= for user timers.

Nothing runs while I'm logged out

Your user manager, and every timer in it, stops after your last session ends.

Run loginctl enable-linger once. Your manager then starts at boot and keeps running. Timers hooked to graphical-session.target still only run inside a session.

It runs up to a minute late, and not at the same second each day

AccuracySec= defaults to 1min. systemd picks a stable, host-specific point inside that window so it can group wake-ups.

Set AccuracySec=1s or 1us when exact timing matters.

Runs missed while the laptop was off never happen

Persistent=true is missing, or the timer uses a monotonic directive, where Persistent= is ignored.

Use OnCalendar= with Persistent=true. Only one catch-up run happens, however many slots were missed, and it is still subject to RandomizedDelaySec=. The catch-up only covers time after the timer was first enabled.

My "every 4 hours" drifts later and later on a laptop

Monotonic timers pause during suspend and count from the previous run, as shown in the diagram at the top.

If you want wall-clock slots, use OnCalendar=*-*-* 00/4:00.

*:0/7 doesn't run every 7 minutes

Repetition restarts inside each larger unit: :00, :07 … :56, then :00 again.

Use a value that divides 60 or 24 evenly, combine several expressions, or use OnUnitActiveSec=7min. Check with systemd-analyze calendar --iterations=12.

A slow run is followed immediately by another run

The next slot came due while the service was still running. Runs never overlap, so the slot fires as soon as the service stops.

Use DeferReactivation=yes with OnCalendar=, or OnUnitInactiveSec= for interval timers.

The service ran once and never again

The service has RemainAfterExit=yes, or it is a long-running service that never exits. A still-active unit isn't restarted.

Use Type=oneshot without RemainAfterExit=. If you must trigger a target repeatedly, give it StopWhenUnneeded=yes.

The script works in my shell but fails from the timer

The user manager's environment isn't your shell's environment. PATH changes from .bashrc are missing, as are the ssh-agent socket and sometimes DISPLAY/WAYLAND_DISPLAY. The working directory is $HOME or /.

Use absolute paths and %h. Put persistent variables in ~/.config/environment.d/*.conf. Inspect the environment with systemctl --user show-environment. Always test with systemctl --user start foo.service, never by running the script from your shell.

OnBootSec=90m seems to wait for months

Your file probably says 90M. In time spans, m means minutes and M means months.

Write min, and check with systemd-analyze timespan.

An ordering cycle appears after tying the timer to a target

After=some.target on the timer conflicts with its implicit Before=timers.target.

Give the timer only WantedBy= and PartOf=. Put After= and Requisite= on the service. See journalctl --user -b for "ordering cycle" messages.

A timer tied to graphical-session.target never fires

Your compositor or window manager never activates that target.

Check with systemctl --user is-active graphical-session.target. Use a session launcher that activates it, such as uwsm, or hook the timer to default.target or your own target.

I edited the schedule but NEXT didn't change

systemd keeps using the unit it has in memory until you reload.

Run systemctl --user daemon-reload, then check list-timers. If NEXT still shows the old time, run systemctl --user restart foo.timer.

A weekly job with a long random delay never runs

RandomizedDelaySec= is re-rolled and restarted at every manager start. A machine that restarts more often than the delay never reaches it.

Use RandomizedOffsetSec=, available since v258.

A removed Persistent timer comes back with a stale "last run"

The timestamp file stays on disk, under ~/.local/share/systemd/timers/ for user timers.

Run systemctl --user clean --what=state foo.timer before you delete the unit.

Runs around 02:00 behave strangely twice a year

On the days clocks change, some local times occur twice and others don't exist at all.

Avoid scheduling between 01:00 and 03:00 local time, or append UTC to the expression.

Inspect and debug

Commands worth remembering
systemctl --user list-timers --all                     # NEXT / LEFT / LAST / PASSED for every timer
systemctl --user status foo.timer                      # Trigger: line = next elapse
systemd-analyze calendar --iterations=5 'Mon..Fri 09:00'
systemd-analyze timespan '1h 30min'
systemd-analyze --user verify ~/.config/systemd/user/foo.timer
systemctl --user start foo.service                     # run the job now, in its real environment
journalctl --user -u foo.service -u foo.timer -e
systemctl --user show-environment
systemctl --user clean --what=state foo.timer          # forget Persistent= history

When a timer starts a service, systemd passes it environment variables, including $TRIGGER_UNIT, $TRIGGER_TIMER_REALTIME_USEC and $TRIGGER_TIMER_MONOTONIC_USEC. A script can check them to tell a scheduled run from a manual systemctl start. The details are in systemd.exec(5).