Writing Precise Timer Expressions
An OnCalendar expression looks like a simple date at first glance but can combine weekdays, ranges, lists and repeats in a single, compact line. Anyone who does not really understand the syntax copies examples from the internet and then wonders why the timer never fires or fires at the wrong time. This article explains the OnCalendar syntax systematically, from simple shorthands to complex expressions, and shows how to reliably test every expression before deployment with systemd-analyze calendar.
Table of contents
- 1. Why the OnCalendar syntax deserves its own attention
- 2. The structure of an OnCalendar expression in detail
- 3. Wildcards, lists, and ranges in practice
- 4. Repeats with the slash operator and shorthands
- 5. Using Persistent and RandomizedDelaySec correctly
- 6. Testing expressions with systemd-analyze calendar
- 7. Understanding timezone behavior of timers
- 8. Practical examples for typical operational scenarios
- 9. OnCalendar shorthands compared to cron expressions
- 10. Summary
- 11. FAQ
1. Why the OnCalendar syntax deserves its own attention
Many administrators switch from classic cron to systemd timers but carry over the wrong expectations about the syntax. While a crontab line has five fixed fields in a fixed order, an OnCalendar expression is a much more flexible notation that describes weekday, date, and time in a single, freely combinable string. This very flexibility is why small typos in OnCalendar syntax do not always show up immediately, only once the timer fires at the wrong time or not at all.
The good news: systemd ships with systemd-analyze calendar, a built in tool that instantly evaluates any OnCalendar expression and shows the next execution times, without actually activating the timer. Anyone who works through this syntax systematically once can transfer almost all their cron knowledge to systemd and additionally gains capabilities plain cron simply does not offer, such as expressions for the last day of a month or for the second Tuesday.
2. The structure of an OnCalendar expression in detail
A complete OnCalendar expression follows the pattern Weekday Year-Month-Day Hour:Minute:Second, where each of these parts can optionally be omitted. If the weekday is missing, the expression applies to every weekday. If the time is missing, midnight is implied. The expression Mon 2026-08-03 09:00:00 thus describes a single, exact point in time, while *-*-* 03:00:00 means every day at three in the morning, regardless of weekday.
The asterisk * stands for any possible value at that position, similar to the wildcard in cron, but with the difference that OnCalendar syntax can also treat year, month, and day as separate wildcards. This granularity allows expressions like 2026-*-01 for the first day of every month in 2026, something that would only be possible in classic cron through workarounds or additional script logic.
# backup.timer — a minimal systemd timer unit
[Unit]
Description=Nightly backup timer
[Timer]
# Fixed calendar expression: every day at 03:00:00
OnCalendar=*-*-* 03:00:00
Unit=backup.service
[Install]
WantedBy=timers.target
3. Wildcards, lists, and ranges in practice
Besides the simple wildcard, OnCalendar syntax also supports lists and ranges. A list is written with commas, for example Mon,Wed,Fri for Monday, Wednesday, and Friday. A range uses two dots, for example Mon..Fri for all weekdays from Monday to Friday. Both notations can be combined, so Mon..Fri 08,12,17:00:00 fires three times a day on every weekday, at eight, twelve, and five.
The same logic applies to calendar days: 01,15 *-*-* 06:00:00 triggers on the first and fifteenth day of every month at six, while *-01,04,07,10-01 describes the first day of every quarterly month. This combinability of lists and ranges makes OnCalendar expressions considerably more readable than cron lines once you have internalized the basic patterns, because you no longer have to count which column holds which value.
[Timer]
# Weekdays only, three times a day
OnCalendar=Mon..Fri 08,12,17:00:00
[Timer]
# First and fifteenth of every month at 06:00
OnCalendar=01,15 *-*-* 06:00:00
[Timer]
# Last day of every month — systemd resolves this automatically
OnCalendar=*-*~1 23:59:00
4. Repeats with the slash operator and shorthands
For regular intervals within a single field there is the slash operator, for example *-*-* 0/4:00:00 for every four hours starting at midnight. That is especially practical for monitoring or health check jobs that need to run at fixed intervals without spelling out every single hour as a list. Important detail: the slash defines the interval relative to the starting value in front of it, not relative to the current time when the timer is activated.
For the most common cases systemd additionally offers ready made shorthands like hourly, daily, weekly, monthly, yearly, and quarterly. These shorthands are pure aliases that internally resolve to a full OnCalendar expression, for example daily resolves to *-*-* 00:00:00. For simple daily or weekly jobs without a particular time, these shorthands are the most readable and least error prone choice.
[Timer]
# Every 4 hours, using the slash repeat operator
OnCalendar=*-*-* 0/4:00:00
[Timer]
# Equivalent using a built-in shorthand (less flexible, but readable)
OnCalendar=daily
[Timer]
# Every 15 minutes, via slash operator on minutes
OnCalendar=*-*-* *:0/15:00
5. Using Persistent and RandomizedDelaySec correctly
Two options in the [Timer] section are especially important for production use, but are frequently overlooked. Persistent=true ensures that a missed run, for example because the server was powered off at the scheduled time, is caught up immediately at the next boot. Without this option a missed timer run is simply lost, which can lead to unnoticed missing backups or maintenance jobs, especially on laptops and rarely running servers.
RandomizedDelaySec delays the actual start by a random value within the given window, which prevents many simultaneously scheduled timers on different servers from starting at exactly the same second and creating load spikes on shared resources such as databases or network storage. Both options together turn a simple OnCalendar expression into a robust, production ready scheduling solution.
[Timer]
OnCalendar=daily
# Catch up on missed runs after being offline (e.g. laptop suspended overnight)
Persistent=true
# Spread the actual start time within a 10 minute window to avoid load spikes
RandomizedDelaySec=600
6. Testing expressions with systemd-analyze calendar
Before a new timer goes live, every OnCalendar expression should be checked with systemd-analyze calendar. Besides the next execution time, the command also shows whether the expression is syntactically valid and normalizes it into its canonical form. This normalization is an underrated diagnostic tool, because it immediately reveals whether an expression actually means what you intended.
The --iterations option additionally prints several future execution times at once, which is especially helpful with complex expressions involving weekdays and ranges, to verify the actual pattern over several weeks before the timer goes into production. This upfront check saves considerable time compared to activating a timer and then watching for days whether it fires at the expected time.
# Validate a calendar expression and see the next execution time
systemd-analyze calendar "Mon..Fri 08,12,17:00:00"
# Show multiple future occurrences to verify the pattern over weeks
systemd-analyze calendar --iterations=5 "01,15 *-*-* 06:00:00"
# Confirm which timers are actually scheduled next on this system
systemctl list-timers --all
7. Understanding timezone behavior of timers
OnCalendar expressions are interpreted by default in the system's local timezone, not UTC. That is convenient as long as the server and use case stay in the same timezone, but it becomes a trap around daylight saving transitions: a daily timer at 02:30:00 can fail to fire on the day the clock is moved forward, because that time simply does not exist that day. Conversely it can fire twice when the clock is moved back.
For time critical server jobs, especially those tied to distributed systems or international deployments, it is therefore recommended to deliberately schedule the time outside the daylight saving transition window, for example 03:30 instead of 02:30, or to consistently run the system on UTC and avoid local interpretation entirely. Server operating systems almost always benefit from running on UTC as a matter of principle and leaving timezone conversion entirely to the application layer.
8. Practical examples for typical operational scenarios
A classic maintenance window for nightly backups can be mapped with OnCalendar=*-*-* 02:00:00 combined with Persistent=true, so a missed run gets caught up at the next boot. For monthly billing runs, OnCalendar=*-*~1 04:00:00 is a good fit, reliably hitting the last day of every month regardless of whether it has 28, 30, or 31 days, a detail that would require additional script logic with classic cron.
For log rotation at the end of every work week, OnCalendar=Fri 18:00:00 works well, while a high frequency health check job runs every five minutes via OnCalendar=*:0/5. These examples show how real operational requirements almost always translate directly into a single, compact OnCalendar expression, without needing additional wrapper scripts for the time logic.
9. OnCalendar shorthands compared to cron expressions
Anyone coming from cron benefits from a direct comparison to internalize the new syntax faster and avoid typical translation mistakes.
| Requirement | Cron expression | OnCalendar expression |
|---|---|---|
| Daily at 3 AM | 0 3 * * * |
*-*-* 03:00:00 |
| Weekdays at 8, 12, 17 | 0 8,12,17 * * 1-5 |
Mon..Fri 08,12,17:00:00 |
| Every 15 minutes | */15 * * * * |
*-*-* *:0/15:00 |
| Last day of the month | Not directly possible | *-*~1 23:59:00 |
| Catching up missed runs | Not without anacron | Persistent=true |
The main advantage of OnCalendar syntax lies exactly in the rows cron cannot represent, or only with extra tooling: end of month, catching up on missed runs, and jitter distribution are natively built into systemd, whereas plain cron environments require additional script logic or helper programs such as anacron.
Mironsoft
Linux server administration and scheduling infrastructure
Want to migrate cron jobs to systemd timers cleanly?
We analyze your existing cron jobs, translate them into robust OnCalendar expressions with Persistent and RandomizedDelaySec, and set up monitoring for your timer units.
Migration
Translate crontab lines into tested OnCalendar expressions
Hardening
Add Persistent, RandomizedDelaySec, and error handling
Monitoring
Reliably detect failed and missed timer runs
10. Summary
The OnCalendar syntax describes weekday, date, and time in a single, flexible expression, replacing the five rigid columns of a crontab line with a considerably more powerful notation. Comma separated lists, two dot ranges, and slash based repeats can be combined freely, while shorthands like daily or weekly abbreviate the most common cases. systemd-analyze calendar is the central tool for validating every expression before it goes into production.
Persistent=true catches up missed runs after the next boot, RandomizedDelaySec spreads simultaneously scheduled timers over a time window and prevents load spikes. Cases like the last day of a month, which require additional script logic in plain cron, are natively built into OnCalendar syntax. Once you have internalized the basic patterns, writing timer expressions becomes faster and less error prone than equivalent cron lines.
systemd OnCalendar syntax, the essentials at a glance
Basic structure
Weekday Year-Month-Day Hour:Minute:Second, each part optional or a * wildcard.
Lists and ranges
Commas for lists (Mon,Wed,Fri), two dots for ranges (Mon..Fri), slash for intervals.
Test before deploying
systemd-analyze calendar "expression" shows the next run and normalized form.
Persistent and jitter
Persistent=true catches up missed runs, RandomizedDelaySec prevents load spikes.