A systemd timer separates the scheduling rule from the service that performs the work. The timer decides when to request activation; the service supplies the command, execution identity, environment, timeout, and result. This separation makes a maintenance job inspectable using normal unit tools. It does not turn a scheduled attempt into proof that a backup, cleanup, or report completed correctly.
When it breaks
An active timer can coexist with a failed service. Monitoring only the timer’s active state therefore misses failures in the actual work. Likewise, a recent trigger timestamp identifies scheduling activity, not successful business output. Check the service’s result and the artifact or state change the job was supposed to produce, with enough context to distinguish successive activations.
A timer normally activates the service with the matching basename, unless Unit selects another target. If that service is already active, the timer does not launch another independent copy. This prevents overlapping activations of that one unit, but it is not a distributed lock or protection against another service running the same command. A oneshot kept active by RemainAfterExit=yes can consequently prevent later scheduled executions.
Stopping a timer stops future scheduling. It does not automatically stop a service invocation already in progress. That distinction matters when pausing maintenance during an incident: inspect the target service and decide deliberately whether current work should finish or be interrupted. Interrupting a command also does not roll back its earlier filesystem or database changes.
Check it quickly
Run this Python script as an administrator in a disposable Linux system with systemd as PID 1. The recorded run used Debian 12 and systemd 252.39. It creates a unique timer/service pair and a temporary worker. The worker records real process entries and completion times; explicit release files hold successful jobs open so the scheduling behavior is observable:
from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,time,uuid
name='xbcatm-timer-'+uuid.uuid4().hex[:8];service=name+'.service';timer=name+'.timer'
paths={unit:Path('/run/systemd/system')/unit for unit in (service,timer)}
assert all(not p.exists() for p in paths.values())
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()
def value(unit,key):return run('systemctl','show',unit,'-p',key,'--value')
def wait_for(check):
deadline=time.monotonic()+5
while not check():
assert time.monotonic()<deadline,'transition timed out'
time.sleep(0.02)
with TemporaryDirectory(prefix=name+'-',dir='/run') as directory:
root=Path(directory);worker=root/'worker.py'
worker.write_text(r'''from pathlib import Path
import json,sys,time
root=Path(__file__).parent;number=len(list(root.glob('start-*.json')))+1
def record(kind):
temp=root/'record.tmp';temp.write_text(json.dumps(time.monotonic()))
temp.replace(root/(kind+'-'+str(number)+'.json'))
record('start')
if (root/'fail').exists():sys.exit(7)
while not (root/('release-'+str(number))).exists():time.sleep(0.01)
record('end')
''')
paths[service].write_text(f'[Service]\nType=oneshot\nTimeoutStartSec=10s\nExecStart=/usr/bin/python3 {worker}\n')
definition='[Timer]\nOnActiveSec=100ms\nOnUnitInactiveSec=10s\nAccuracySec=1ms\n'
paths[timer].write_text(definition);(root/'fail').touch()
try:
run('systemd-analyze','verify',*[str(p) for p in paths.values()])
run('systemctl','daemon-reload');run('systemctl','start',timer)
wait_for(lambda:value(service,'ActiveState')=='failed')
assert value(service,'ExecMainStatus')=='7' and value(timer,'ActiveState')=='active'
run('systemctl','stop',timer)
print('timer active while its first job failed with exit status 7')
(root/'fail').unlink();run('systemctl','reset-failed',service)
paths[timer].write_text(definition.replace('OnUnitInactiveSec=10s','OnUnitInactiveSec=250ms'))
run('systemctl','daemon-reload');run('systemctl','start',timer)
wait_for(lambda:(root/'start-2.json').exists())
time.sleep(0.65)
assert len(list(root.glob('start-*.json')))==2
(root/'release-2').touch();wait_for(lambda:(root/'start-3.json').exists())
gap=json.loads((root/'start-3.json').read_text())-json.loads((root/'end-2.json').read_text())
assert gap>=0.25,gap
print('no overlapping launch; next job starts at least 250ms after prior deactivation')
pid=value(service,'MainPID');assert pid!='0'
run('systemctl','stop',timer)
assert value(service,'MainPID')==pid and value(service,'ActiveState')=='activating'
print('stopping timer leaves its already running oneshot alive')
(root/'release-3').touch();wait_for(lambda:value(service,'ActiveState')=='inactive')
time.sleep(0.4)
assert len(list(root.glob('start-*.json')))==3 and value(service,'Result')=='success'
print('released job completes; stopped timer schedules no further run')
finally:
run('systemctl','stop',timer,service)
for unit,path in paths.items():
if value(unit,'ActiveState')=='failed':run('systemctl','reset-failed',unit)
path.unlink()
run('systemctl','daemon-reload')
The first timer activation launches a worker that deliberately exits with status seven. The service becomes failed while the timer remains active. Its next completion-relative interval is initially ten seconds, allowing the script to inspect this state and stop the timer before another attempt. The output confirms a scheduler can be functioning while the scheduled work is failing.
After correcting the fixture’s failure condition, the script clears the failed state, changes the interval to 250 milliseconds, and starts scheduling again. The second worker waits at its release gate. Even after more than two nominal intervals pass, no third worker has started. OnUnitInactiveSec is measured from deactivation, so unfinished work has not yet supplied that reference event.
Releasing the second worker lets it complete. The third worker then starts at least one configured interval after the recorded completion marker; actual unit deactivation occurs after that marker. The assertion verifies the completion-relative relationship without requiring a fragile exact launch timestamp. AccuracySec=1ms reduces coalescing in this short exercise, but it is not a real-time execution guarantee.
The script stops the timer while the third worker is still waiting. Its PID remains unchanged and the oneshot remains activating. Only releasing that worker lets the service complete successfully. After another interval has passed, the invocation count is unchanged, confirming that the stopped timer no longer schedules work. Both runtime unit files and the temporary worker are removed afterward.
Migrate the execution contract too
When moving a cron job, inspect more than its time expression. Use the intended working directory, explicit command paths, required environment, suitable user, and an appropriate runtime limit. systemd command lines are not implicitly shell programs. A pipeline, redirection, or shell expansion needs an intentional shell invocation or a separate script whose behavior is tested.
Installation and activation are also separate. Starting a runtime timer once does not enable it at boot, and enabling the service itself can introduce an unintended startup run. Install and enable the timer according to the deployment’s policy, then verify the loaded definitions and next activation. Keep the service independently runnable for controlled diagnosis.
Choose how failures should be retried and reported instead of assuming scheduling provides that policy. A later timer activation may try again, while service restart settings introduce their own rules and limits. Make repeated side effects safe and preserve a last-success signal tied to the actual result. The operational objective is completed useful work within its required window, not merely a timer that remains active.
Further reading
Sources
- systemd 252 systemd.timer, target selection, active-target behavior, and completion-relative intervals, checked 2026-09-12.
- systemd 252 systemd.service, oneshot lifecycle, retained state, execution failure, and timeouts, checked 2026-09-12.
- systemd 252 systemctl, starting, stopping, enabling, and inspecting timer and service units, checked 2026-09-12.