OnCalendar Syntax
By the end of this lesson you will be able to write any OnCalendar= expression from memory, validate it with systemd-analyze calendar, and handle edge cases like last-day-of-month, first-Monday, timezone scheduling, and multiple-schedule OR logic.
The Full Format
An OnCalendar= expression follows this structure:
DayOfWeek Year-Month-Day Hour:Minute:Second Timezone
Each field is optional. systemd fills in defaults:
| Field | Default | Example |
|---|---|---|
| DayOfWeek | Any day (*) | Mon, Mon..Fri, Sat,Sun |
| Year | Any year (*) | 2026 |
| Month | Any month (*) | 01..06, 1,4,7,10 |
| Day | Any day (*) | 01, 1..7, ~1 (last) |
| Hour | 00 | 02, 08..20, 0/6 |
| Minute | 00 | 15, 0/5, 0/15 |
| Second | 00 | 00, 30 |
| Timezone | System default | UTC, Asia/Jakarta, US/Eastern |
Validation — Your Best Friend
Always validate before deploying. This is the single most important habit:
systemd-analyze calendar --iterations=5 'Mon..Fri 06:00'
Original form: Mon..Fri 06:00
Normalized form: Mon..Fri *-*-* 06:00:00
Next elapse: Mon 2026-03-02 06:00:00 UTC
(in UTC): Mon 2026-03-02 06:00:00 UTC
From now: 5h 30min left
Iter. #2: Tue 2026-03-03 06:00:00 UTC
Iter. #3: Wed 2026-03-04 06:00:00 UTC
Iter. #4: Thu 2026-03-05 06:00:00 UTC
Iter. #5: Fri 2026-03-06 06:00:00 UTC
The output shows:
- Normalized form — how systemd internally interprets your expression.
- Next elapse — the next exact time the timer will fire.
- Iterations — the next N fire times, so you can verify the pattern.
If your expression is wrong, systemd will either never fire or fire at unexpected times. There is no error message — the timer just silently does the wrong thing. Always validate.
Shortcut Keywords
systemd provides convenient shortcut keywords for common schedules:
| Keyword | Normalized Form | Fires |
|---|---|---|
minutely | *-*-* *:*:00 | Every minute |
hourly | *-*-* *:00:00 | Top of every hour |
daily | *-*-* 00:00:00 | Midnight every day |
weekly | Mon *-*-* 00:00:00 | Monday midnight |
monthly | *-*-01 00:00:00 | 1st of every month at midnight |
quarterly | *-01,04,07,10-01 00:00:00 | Jan 1, Apr 1, Jul 1, Oct 1 |
semiannually | *-01,07-01 00:00:00 | Jan 1 and Jul 1 |
yearly / annually | *-01-01 00:00:00 | January 1st |
systemd-analyze calendar --iterations=3 'daily'
systemd-analyze calendar --iterations=3 'monthly'
systemd-analyze calendar --iterations=3 'quarterly'
Operators
Wildcard (*)
Matches any value in the field:
OnCalendar=*-*-* 02:00:00 # Every day at 2 AM
OnCalendar=*-*-* *:00:00 # Every hour
List (,)
Match multiple specific values:
OnCalendar=09,21:00 # At 9 AM and 9 PM
OnCalendar=Mon,Wed,Fri 06:00 # Monday, Wednesday, Friday at 6 AM
OnCalendar=*-01,04,07,10-01 # Jan, Apr, Jul, Oct on the 1st
systemd-analyze calendar --iterations=4 '09,21:00'
Next elapse: Mon 2026-03-02 09:00:00 UTC
Iter. #2: Mon 2026-03-02 21:00:00 UTC
Iter. #3: Tue 2026-03-03 09:00:00 UTC
Iter. #4: Tue 2026-03-03 21:00:00 UTC
Range (..)
Match a continuous range of values:
OnCalendar=Mon..Fri 06:00 # Monday through Friday at 6 AM
OnCalendar=*-*-* 08..20:00 # Every hour from 8 AM to 8 PM
OnCalendar=*-01..06-01 # 1st of month, January through June
systemd-analyze calendar --iterations=5 'Mon..Fri 06:00'
Step (/n)
Match every Nth value:
OnCalendar=*:0/5 # Every 5 minutes (at :00, :05, :10, ...)
OnCalendar=*:0/15 # Every 15 minutes
OnCalendar=*-*-* 0/2:00:00 # Every 2 hours (at 00:00, 02:00, 04:00, ...)
OnCalendar=*-*-* 00/6:00:00 # Every 6 hours (at 00:00, 06:00, 12:00, 18:00)
systemd-analyze calendar --iterations=5 '*:0/5'
Next elapse: Mon 2026-03-02 00:05:00 UTC
Iter. #2: Mon 2026-03-02 00:10:00 UTC
Iter. #3: Mon 2026-03-02 00:15:00 UTC
Iter. #4: Mon 2026-03-02 00:20:00 UTC
Iter. #5: Mon 2026-03-02 00:25:00 UTC
Last Day of Month (~)
The ~ operator counts from the end of the month:
OnCalendar=*-*~1 00:00:00 # Last day of every month
OnCalendar=*-*~3 02:00:00 # Third-to-last day at 2 AM
systemd-analyze calendar --iterations=3 '*-*~1 00:00:00'
Next elapse: Tue 2026-03-31 00:00:00 UTC
Iter. #2: Thu 2026-04-30 00:00:00 UTC
Iter. #3: Sun 2026-05-31 00:00:00 UTC
This is not possible in cron without shell scripting tricks. systemd handles it natively.
Common Patterns Cookbook
Minutes
| Pattern | OnCalendar | Validate |
|---|---|---|
| Every minute | minutely | systemd-analyze calendar minutely |
| Every 5 minutes | *:0/5 | systemd-analyze calendar '*:0/5' |
| Every 10 minutes | *:0/10 | systemd-analyze calendar '*:0/10' |
| Every 15 minutes | *:0/15 | systemd-analyze calendar '*:0/15' |
| Every 30 minutes | *:0/30 | systemd-analyze calendar '*:0/30' |
Hours
| Pattern | OnCalendar | Validate |
|---|---|---|
| Every hour | hourly | systemd-analyze calendar hourly |
| Every 2 hours | *-*-* 0/2:00:00 | systemd-analyze calendar '*-*-* 0/2:00:00' |
| Every 4 hours | *-*-* 0/4:00:00 | systemd-analyze calendar '*-*-* 0/4:00:00' |
| Every 6 hours | *-*-* 00/6:00:00 | systemd-analyze calendar '*-*-* 00/6:00:00' |
| Every 8 hours | *-*-* 0/8:00:00 | systemd-analyze calendar '*-*-* 0/8:00:00' |
| Every 12 hours | *-*-* 0/12:00:00 | systemd-analyze calendar '*-*-* 0/12:00:00' |
Daily
| Pattern | OnCalendar | Validate |
|---|---|---|
| Midnight | daily | systemd-analyze calendar daily |
| 2:15 AM | 02:15 | systemd-analyze calendar '02:15' |
| 3 AM | 03:00 | systemd-analyze calendar '03:00' |
| 9 AM and 9 PM | 09,21:00 | systemd-analyze calendar '09,21:00' |
| 8 AM–8 PM every 10 min | *-*-* 08..20:0/10 | systemd-analyze calendar '*-*-* 08..20:0/10' |
Weekly
| Pattern | OnCalendar | Validate |
|---|---|---|
| Monday midnight | weekly | systemd-analyze calendar weekly |
| Weekdays at 6 AM | Mon..Fri 06:00 | systemd-analyze calendar 'Mon..Fri 06:00' |
| Saturday at 2 AM | Sat 02:00 | systemd-analyze calendar 'Sat 02:00' |
| Mon/Wed/Fri at 9 AM | Mon,Wed,Fri 09:00 | systemd-analyze calendar 'Mon,Wed,Fri 09:00' |
Monthly and Beyond
| Pattern | OnCalendar | Validate |
|---|---|---|
| 1st of month | monthly | systemd-analyze calendar monthly |
| 1st and 15th at 4 AM | *-*-01,15 04:00:00 | systemd-analyze calendar '*-*-01,15 04:00:00' |
| Last day of month | *-*~1 00:00:00 | systemd-analyze calendar '*-*~1 00:00:00' |
| First Monday of month | Mon *-*-1..7 03:00:00 | systemd-analyze calendar 'Mon *-*-1..7 03:00:00' |
| Quarterly | quarterly | systemd-analyze calendar quarterly |
| Yearly | yearly | systemd-analyze calendar yearly |
Timezone Support
By default, OnCalendar= uses the system's timezone. You can override this per-timer:
[Timer]
OnCalendar=02:15 Asia/Jakarta # 2:15 AM Jakarta time
OnCalendar=09:00 US/Eastern # 9 AM Eastern time
OnCalendar=Mon..Fri 06:00 Europe/London # Weekdays 6 AM London
systemd-analyze calendar --iterations=3 '02:15 Asia/Jakarta'
systemd handles daylight saving time (DST) transitions correctly. When clocks spring forward, if the scheduled time falls in the skipped hour, the timer fires at the next valid time. When clocks fall back, duplicated times are handled by running once at the first occurrence.
Multiple OnCalendar Lines (OR Logic)
You can specify multiple OnCalendar= lines. The timer fires when any of them match:
[Timer]
OnCalendar=Mon..Fri 06:00 # Weekdays at 6 AM
OnCalendar=Sat 09:00 # Saturday at 9 AM
OnCalendar=Sun 10:00 # Sunday at 10 AM
Persistent=true
This is equivalent to: "Run at 6 AM on weekdays, 9 AM on Saturday, and 10 AM on Sunday."
# Validate each schedule independently
systemd-analyze calendar --iterations=3 'Mon..Fri 06:00'
systemd-analyze calendar --iterations=3 'Sat 09:00'
systemd-analyze calendar --iterations=3 'Sun 10:00'
Common Mistakes
| Mistake | What Happens | Fix |
|---|---|---|
OnCalendar=2:15 | Fires at 02:15 (OK, but ambiguous) | Use 02:15 for clarity |
OnCalendar=*/5 * * * * | Invalid — this is cron syntax, not systemd | Use *:0/5 |
OnCalendar=0/5 | Fires every 5 hours at minute 0 | Use *:0/5 for every 5 minutes |
| Missing validation | Timer fires at unexpected times | Always run systemd-analyze calendar |
| Omitting seconds | Time normalizes to :00 seconds | Usually fine; add :30 if needed |
| Wrong timezone spelling | Falls back to UTC silently | Check /usr/share/zoneinfo/ for valid names |
Hands-On: Validate These Expressions
Practice validating these expressions. Predict the output before running:
# 1. Every 5 minutes
systemd-analyze calendar --iterations=5 '*:0/5'
# 2. Weekdays at 6 AM
systemd-analyze calendar --iterations=5 'Mon..Fri 06:00'
# 3. Last day of every month
systemd-analyze calendar --iterations=3 '*-*~1 00:00:00'
# 4. First Monday of each month at 3 AM
systemd-analyze calendar --iterations=4 'Mon *-*-1..7 03:00:00'
# 5. Twice daily (9 AM and 9 PM)
systemd-analyze calendar --iterations=4 '09,21:00'
# 6. Peak hours only (8 AM to 8 PM, every 10 minutes)
systemd-analyze calendar --iterations=5 '*-*-* 08..20:0/10'
Key Takeaways
OnCalendar=uses aDayOfWeek Year-Month-Day Hour:Minute:Second Timezoneformat.- Always validate with
systemd-analyze calendar --iterations=5 'EXPRESSION'before deploying. - Use keywords (
daily,weekly,monthly) for simple schedules. - Use operators (
*,,,..,/,~) for complex patterns. - Multiple
OnCalendar=lines are OR-ed — any match triggers the timer. - Timezone is specified inline —
OnCalendar=02:15 Asia/Jakarta. - The
~operator for last-day-of-month is a unique systemd feature not available in cron.
What's Next
- Monotonic Timers — interval and boot-based scheduling for heartbeats and retries.