A schedule must specify what time means. “At nine each morning in New York” refers to a civil-time calendar. “A quarter hour after this job finishes” refers to an elapsed interval and an event. systemd timers support both, but the two clocks and reference points have different behavior around time-zone changes, suspension, and periods when the timer is inactive.

When it breaks

OnCalendar follows wall-clock calendar expressions. Omitting a time zone generally uses the system’s local zone, which may differ between machines. A daily local time can move relative to UTC when daylight-saving rules change. Preview the intended expression with a fixed reference date instead of assuming that today’s UTC offset applies throughout the year.

Monotonic options name their reference event explicitly. OnActiveSec refers to activation of the timer itself, OnStartupSec to the service manager’s startup, and OnUnitInactiveSec to the target unit’s deactivation. These are not interchangeable ways to say “every few minutes.” Ordinary monotonic timers pause with system suspension; WakeSystem changes clock selection and depends on platform support.

Persistent=yes affects calendar timers. It records trigger timing and requests a catch-up activation when the timer becomes active after missing at least one event. It does not replay every missed occurrence or certify successful job completion. The catch-up can also be delayed by configured randomization. A trigger history is not a durable queue of all business work that should have happened.

Check it quickly

Run this Python script as an administrator in a disposable Linux system with systemd as PID 1 and the time-zone database installed. The recorded run used Debian 12 with systemd 252.39. It first previews a calendar expression, then installs two unique timer/service pairs using the same three near-future calendar events. It never adjusts the machine’s clock:

from pathlib import Path
from tempfile import TemporaryDirectory
from datetime import datetime,timezone
import subprocess,time,uuid

name='xbcatm-calendar-'+uuid.uuid4().hex[:8]
names=[name,name+'-plain'];paths=[]
def run(*args):
    result=subprocess.run(args,text=True,capture_output=True,timeout=10)
    assert result.returncode==0,(args,result.stderr)
    return result.stdout.strip()
preview=run('systemd-analyze','calendar','--base-time=2026-03-07 00:00:00 UTC',
            '--iterations=3','*-*-* 09:00:00 America/New_York')
for expected in ('2026-03-07 14:00:00 UTC','2026-03-08 13:00:00 UTC','2026-03-09 13:00:00 UTC'):
    assert expected in preview,preview
print('09:00 New York: 14:00 UTC before spring DST, 13:00 UTC after it')
with TemporaryDirectory(prefix=name+'-',dir='/run') as directory:
    root=Path(directory);worker=root/'worker.py'
    worker.write_text("from pathlib import Path\nimport sys\np=Path(sys.argv[1])\nwith p.open('a') as output:output.write('run\\n')\n")
    now=int(time.time());events=[now+3,now+4,now+5]
    calendar=''.join('OnCalendar='+datetime.fromtimestamp(t,timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')+'\n' for t in events)
    try:
        for index,prefix in enumerate(names):
            service=Path('/run/systemd/system')/(prefix+'.service');timer=service.with_suffix('.timer')
            assert not service.exists() and not timer.exists()
            service.write_text(f'[Service]\nType=oneshot\nExecStart=/usr/bin/python3 {worker} {root}/{index}.log\n');paths.append(service)
            timer.write_text('[Timer]\n'+calendar+'AccuracySec=1ms\nRandomizedDelaySec=0\nPersistent='+('yes' if index==0 else 'no')+'\n');paths.append(timer)
        run('systemd-analyze','verify',*[str(p) for p in paths]);run('systemctl','daemon-reload')
        run('systemctl','start',name+'.timer');run('systemctl','stop',name+'.timer')
        assert not (root/'0.log').exists() and time.time()<events[0]
        while time.time()<events[-1]+0.15:time.sleep(0.05)
        run('systemctl','start',name+'.timer',name+'-plain.timer')
        deadline=time.monotonic()+3
        while not (root/'0.log').exists():
            assert time.monotonic()<deadline,'catch-up did not run';time.sleep(0.02)
        time.sleep(0.15)
        assert (root/'0.log').read_text()=='run\n' and not (root/'1.log').exists()
        print('three elapsed calendar events: persistent timer catches up once; plain timer does not')
        run('systemctl','stop',name+'.timer');run('systemctl','start',name+'.timer')
        time.sleep(0.2);assert (root/'0.log').read_text()=='run\n'
        print('reactivating with no newly missed event does not replay the job')
        print('timer inactivity was tested without changing the system clock or rebooting')
    finally:
        if paths:
            run('systemctl','stop',*[p.name for p in paths])
            run('systemctl','clean','--what=state',name+'.timer')
            for path in paths:path.unlink(missing_ok=True)
            run('systemctl','daemon-reload')

The preview asks for nine in the morning in New York, starting from a fixed date before the March 2026 daylight-saving transition. The first occurrence is 14:00 UTC; the following two are 13:00 UTC. The local clock time remains nine. This is a schedule interpretation check, not a request to change the host’s time zone or travel to those dates.

The persistent timer is started and stopped before the first laboratory event. Starting it establishes its timestamp state. While it is inactive, all three specified events pass. On reactivation it launches its service exactly once, producing one line in the result file. The nonpersistent timer, activated after the same events, produces no result file.

This experiment observes an actual inactive period rather than editing a timestamp file to impersonate a missed run. It demonstrates the catch-up decision without rebooting the machine. Three elapsed events become one activation; an application that needs to process three distinct accounting periods must discover and track those periods itself.

Stopping and starting the persistent timer again produces no extra line because no new event was missed. Cleanup stops the runtime units and removes the persistent timer’s state through systemctl clean --what=state. The nonpersistent timer has no matching persistent state to clean. Configuration files and the harmless result directory are then removed.

AccuracySec=1ms and zero randomized delay keep the short experiment interpretable. Neither setting promises exact process-start timing under arbitrary load. The code waits for observable completion within a bounded interval and asserts the resulting invocation count, rather than equating a predicted deadline with actual execution.

Decide which missed work matters

For a backup, one fresh snapshot after downtime may satisfy the recovery policy. For billing, each missing period might require separate reconciliation. Put that distinction into application state and idempotency rules. A service already active when a timer elapses is not automatically duplicated, and retained active oneshot state can prevent the command from running again.

Use the timer’s loaded configuration and next/last trigger properties to inspect scheduling, and the target service’s result to inspect execution. Calendar ordering after time-related targets does not by itself prove that a remote time source has synchronized the clock; the actual time-service configuration must supply that promise when it is required.

Test representative dates and lifecycle transitions before relying on a schedule. Include time-zone boundaries, timer disablement, and the task’s expected duration. Choose a monotonic reference for elapsed-delay requirements and a named calendar zone for civil-time requirements. Preserve evidence of completed useful work separately from evidence that the manager issued a trigger.

Further reading

Sources