Skip to main content

OnCalendar Syntax

Learning Focus

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:

FieldDefaultExample
DayOfWeekAny day (*)Mon, Mon..Fri, Sat,Sun
YearAny year (*)2026
MonthAny month (*)01..06, 1,4,7,10
DayAny day (*)01, 1..7, ~1 (last)
Hour0002, 08..20, 0/6
Minute0015, 0/5, 0/15
Second0000, 30
TimezoneSystem defaultUTC, Asia/Jakarta, US/Eastern

Validation — Your Best Friend

Always validate before deploying. This is the single most important habit:

validate-expression.sh
systemd-analyze calendar --iterations=5 'Mon..Fri 06:00'
example-output.txt
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.
No Shortcut

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:

KeywordNormalized FormFires
minutely*-*-* *:*:00Every minute
hourly*-*-* *:00:00Top of every hour
daily*-*-* 00:00:00Midnight every day
weeklyMon *-*-* 00:00:00Monday midnight
monthly*-*-01 00:00:001st of every month at midnight
quarterly*-01,04,07,10-01 00:00:00Jan 1, Apr 1, Jul 1, Oct 1
semiannually*-01,07-01 00:00:00Jan 1 and Jul 1
yearly / annually*-01-01 00:00:00January 1st
validate-keywords.sh
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
validate-list.sh
systemd-analyze calendar --iterations=4 '09,21:00'
expected-output.txt
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
validate-range.sh
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)
validate-step.sh
systemd-analyze calendar --iterations=5 '*:0/5'
expected-output.txt
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
validate-tilde.sh
systemd-analyze calendar --iterations=3 '*-*~1 00:00:00'
expected-output.txt
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

PatternOnCalendarValidate
Every minuteminutelysystemd-analyze calendar minutely
Every 5 minutes*:0/5systemd-analyze calendar '*:0/5'
Every 10 minutes*:0/10systemd-analyze calendar '*:0/10'
Every 15 minutes*:0/15systemd-analyze calendar '*:0/15'
Every 30 minutes*:0/30systemd-analyze calendar '*:0/30'

Hours

PatternOnCalendarValidate
Every hourhourlysystemd-analyze calendar hourly
Every 2 hours*-*-* 0/2:00:00systemd-analyze calendar '*-*-* 0/2:00:00'
Every 4 hours*-*-* 0/4:00:00systemd-analyze calendar '*-*-* 0/4:00:00'
Every 6 hours*-*-* 00/6:00:00systemd-analyze calendar '*-*-* 00/6:00:00'
Every 8 hours*-*-* 0/8:00:00systemd-analyze calendar '*-*-* 0/8:00:00'
Every 12 hours*-*-* 0/12:00:00systemd-analyze calendar '*-*-* 0/12:00:00'

Daily

PatternOnCalendarValidate
Midnightdailysystemd-analyze calendar daily
2:15 AM02:15systemd-analyze calendar '02:15'
3 AM03:00systemd-analyze calendar '03:00'
9 AM and 9 PM09,21:00systemd-analyze calendar '09,21:00'
8 AM–8 PM every 10 min*-*-* 08..20:0/10systemd-analyze calendar '*-*-* 08..20:0/10'

Weekly

PatternOnCalendarValidate
Monday midnightweeklysystemd-analyze calendar weekly
Weekdays at 6 AMMon..Fri 06:00systemd-analyze calendar 'Mon..Fri 06:00'
Saturday at 2 AMSat 02:00systemd-analyze calendar 'Sat 02:00'
Mon/Wed/Fri at 9 AMMon,Wed,Fri 09:00systemd-analyze calendar 'Mon,Wed,Fri 09:00'

Monthly and Beyond

PatternOnCalendarValidate
1st of monthmonthlysystemd-analyze calendar monthly
1st and 15th at 4 AM*-*-01,15 04:00:00systemd-analyze calendar '*-*-01,15 04:00:00'
Last day of month*-*~1 00:00:00systemd-analyze calendar '*-*~1 00:00:00'
First Monday of monthMon *-*-1..7 03:00:00systemd-analyze calendar 'Mon *-*-1..7 03:00:00'
Quarterlyquarterlysystemd-analyze calendar quarterly
Yearlyyearlysystemd-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
validate-timezone.sh
systemd-analyze calendar --iterations=3 '02:15 Asia/Jakarta'
Timezone and DST

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:

multi-schedule.timer
[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-multi.sh
# 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

MistakeWhat HappensFix
OnCalendar=2:15Fires at 02:15 (OK, but ambiguous)Use 02:15 for clarity
OnCalendar=*/5 * * * *Invalid — this is cron syntax, not systemdUse *:0/5
OnCalendar=0/5Fires every 5 hours at minute 0Use *:0/5 for every 5 minutes
Missing validationTimer fires at unexpected timesAlways run systemd-analyze calendar
Omitting secondsTime normalizes to :00 secondsUsually fine; add :30 if needed
Wrong timezone spellingFalls back to UTC silentlyCheck /usr/share/zoneinfo/ for valid names

Hands-On: Validate These Expressions

Practice validating these expressions. Predict the output before running:

practice.sh
# 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 a DayOfWeek Year-Month-Day Hour:Minute:Second Timezone format.
  • 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.