Writing .timer units
A reference for writing systemd timers, focused on user units in ~/.config/systemd/user.
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.
- The timer decides when. The service decides what, and under which conditions.Ordering such as
After=orWants=, conditions such asConditionACPower=, 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. - 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=yesfires once and then stays "active" forever. - There are two kinds of clock.
OnCalendar=follows the wall clock. It keeps running during suspend, runs once on resume if it was due, andPersistent=truealso remembers runs missed while powered off. TheOn…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. - A timer only counts while it is active.When your user manager starts,
timers.targetstarts all enabled timers. The user manager starts at your first login, or at boot if lingering is enabled, and stops after your last logout. - Multiple triggers combine with OR.You can list several
OnCalendar=lines and mix them withOn…Sec=lines, and the timer fires whenever any of them elapses. An empty assignment such asOnCalendar=clears every trigger defined before it, which is how drop-ins replace a schedule.
What each directive counts from
| Directive | Counts from | Pauses in suspend | If the time already passed when the timer starts | Persistent= |
|---|---|---|---|---|
OnCalendar= | Wall-clock time | No, runs once on resume | Waits for the next occurrence, unless Persistent=true catches up | Applies |
OnActiveSec= | The moment the timer itself was started | Yes | Not applicable | Ignored |
OnBootSec= | Kernel boot | Yes | Fires immediately | Ignored |
OnStartupSec= | Service manager start, which for user units means your login | Yes | Fires immediately | Ignored |
OnUnitActiveSec= | The last time the service was started | Yes | Never fires if the service has never run | Ignored |
OnUnitInactiveSec= | The last time the service stopped | Yes | Never fires if the service has never run | Ignored |
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.
[Unit]
Description=Back up ~/Documents
[Service]
Type=oneshot
ExecStart=%h/bin/backup.sh
# No [Install] section: the timer starts this unit.[Unit]
Description=Back up ~/Documents every morning
[Timer]
OnCalendar=*-*-* 09:00
Persistent=true
[Install]
WantedBy=timers.targetsystemctl --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 -eType=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
[Timer]
OnCalendar=2026-12-24 18:00
AccuracySec=1s
Persistent=true # if I'm not logged in at 18:00, run at next loginThe 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:
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 entriesEvery day at a time
[Timer]
OnCalendar=*-*-* 07:30
Persistent=trueAvoid 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
[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 want | Expression |
|---|---|
| Weekends at 10:00 | Sat,Sun 10:00 |
| First Monday of each month | Mon *-*-01..07 10:00 |
| Last day of each month | *-*~01 18:00 |
| Last Friday of each month | Fri *-*~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
[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 slotDeferReactivation=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
[Timer]
OnStartupSec=5min # the starter: first run 5 min after login
OnUnitActiveSec=6h # then 6 h after each startOnUnitActiveSec= 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
[Timer]
OnStartupSec=3minFor 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.
[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.targetsystemctl --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.
[Unit]
PartOf=graphical-session.target
[Timer]
OnCalendar=*:0/30
# or monotonic, from session start:
# OnActiveSec=1min
# OnUnitActiveSec=30min
[Install]
WantedBy=graphical-session.target[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-screenshotsRequisite= 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.
[Unit]
Description=I'm at the office[Unit]
PartOf=at-office.target
[Timer]
OnActiveSec=30s
OnUnitActiveSec=20min
[Install]
WantedBy=at-office.targetsystemctl --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
| Directive | What it does | Use 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=yes | Keeps 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=v258 | Adds 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. |
[Timer]
OnCalendar=weekly # Mon 00:00
RandomizedOffsetSec=5d # this machine picks a stable day, Mon to Sat
Persistent=trueThis 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:
[Service]
Type=oneshot
ExecStartPre=/usr/bin/nm-online -q -t 60
ExecStart=%h/bin/fetch-feedsMake 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:
[Unit]
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=alice
ExecStart=/home/alice/bin/fetch-feeds[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
[Install]
WantedBy=timers.targetOnCalendar= 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).
| Expression | Normalized form | Meaning |
|---|---|---|
minutely | *-*-* *:*:00 | Every minute |
hourly | *-*-* *:00:00 | On the hour |
daily | *-*-* 00:00:00 | Midnight |
weekly | Mon *-*-* 00:00:00 | Monday midnight |
monthly | *-*-01 00:00:00 | First of the month, midnight |
quarterly | *-01,04,07,10-01 00:00:00 | Start of each quarter |
yearly | *-01-01 00:00:00 | New Year, midnight |
*:0/15 | *-*-* *:00/15:00 | :00, :15, :30, :45 |
*-*-* 00/6:00 | *-*-* 00/6:00:00 | 00:00, 06:00, 12:00, 18:00 |
Mon..Fri 09:00 | Mon..Fri *-*-* 09:00:00 | Weekdays at nine |
Mon *-*-01..07 10:00 | First Monday of the month | |
*-*~01 | *-*~01 00:00:00 | Last day of the month |
Fri *-*~07/1 17:00 | Last Friday of the month | |
2026-12-24 18:00 | 2026-12-24 18:00:00 | Once |
*-*-* 09:00 Europe/Paris | 09: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 target | Active when | Typical use for a timer |
|---|---|---|
timers.target | Early during user manager start | The default WantedBy= for a timer that should always be running |
default.target | User manager has finished starting up, at login or at boot with lingering | Rarely needed; OnStartupSec= is simpler |
graphical-session-pre.target | Just before the desktop session's services start | Setup that must happen before the session starts |
graphical-session.target | While a graphical session is running. It refuses manual start and stops when nothing needs it | Anything that needs a display, notifications or the clipboard |
xdg-desktop-autostart.target | When the session runs XDG autostart entries | Delays relative to autostart applications |
your-own.target | Whenever you start it, until you stop it | Manual 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
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= historyWhen 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).