ToyTools Guide

Converting crontab Lines to systemd OnCalendar

How the OnCalendar grammar maps onto cron fields, and the one day rule that changes meaning in translation.

6 min read Updated Aug 2026

Quick Answer

A crontab line maps onto OnCalendar= field by field: minute hour day-of-month month day-of-week becomes DayOfWeek Year-Month-Day Hour:Minute:Second. For example, 0 9 * * 1-5 becomes OnCalendar=Mon..Fri *-*-* 09:00:00. The mapping is mechanical except for one rule, covered below, which changes what the schedule means rather than how it is written.

Try the Cron to systemd Timer Converter →

How The Fields Line Up

cron reads five fields in a fixed order. systemd reads a calendar expression with an optional weekday prefix, a date part, and a time part. Both accept * for "any", comma lists, and ranges, so most of a translation is rearrangement.

Going from crontab to OnCalendar is mostly a matter of learning where each field lands. The OnCalendar syntax is stricter than cron in one helpful way and looser in another: systemd timer units separate the schedule from the job, so the same systemd oncalendar syntax can drive several services, while the day-of-week and day-of-month rules differ in a way covered below. Everything else about crontab to systemd translation is mechanical.

  • Minute and hour move into the time part, as hour:minute:00.
  • Day of month and month move into the date part, as *-month-day.
  • Day of week becomes the prefix, written Mon, Mon..Fri, or a comma list.
  • Seconds have no cron field, so a translation always writes :00.

To write an OnCalendar expression for every 15 minutes, keep the hour a wildcard and step the minute field: OnCalendar=*-*-* *:00/15:00. Steps translate too, though they are written differently. Input: 0 star-slash-4 star star star in cron shorthand. Output: OnCalendar=*-*-* 00/4:00:00, where 00/4 reads as "starting at 0, every 4". systemd also accepts a plain comma list, so a schedule that is not a clean arithmetic run simply becomes *-*-* *:05,20,50:00.

The One Rule That Changes Meaning

This is the whole reason a converter is worth using rather than rearranging the fields by hand.

When a crontab line restricts both the day of the month and the day of the week, cron fires on a day where either matches. It is a documented quirk and it surprises people who have never needed it. systemd has no such rule: the date specification and the weekday prefix are independent constraints, so the timer fires only where both match.

For example, 0 0 13 * 5 under cron means midnight on the 13th of any month or any Friday, which is roughly 60 runs a year. Translated literally to Fri *-*-13 00:00:00, the timer fires only on a Friday that is also the 13th, which is one or two runs a year. Both are valid. Both load without complaint. Nothing reports the difference, and the job simply stops happening on most of the days it used to.

Therefore the tool evaluates both rules over the next year of dates and names the first day they disagree, or states plainly that they agree. When only one day field is restricted the rules coincide, so the check stays silent rather than warning about a schedule that translated exactly.

cron vs systemd timers

Beyond the schedule grammar the two systems differ in what they give you. A cron line is one row of text that couples the schedule to the command. A systemd timer unit splits them: the schedule lives in a .timer and the work in a matching .service, which means the job gets logging through the journal, resource limits, dependency ordering, and a systemctl status that says whether the last run failed.

The trade is verbosity. Two files replace one line, and the cron vs systemd timer decision usually comes down to whether the job is worth that: a one-line cleanup probably is not, a nightly backup whose failures should be visible probably is.

Timezones And Missed Runs

A crontab can set TZ at the top of the file, and that setting does not survive the move. OnCalendar is evaluated in the system timezone of the host, so a job written to run at 02:00 in one zone can land hours away once it becomes a timer. Set Timezone= in the [Timer] section when the schedule genuinely depends on a zone rather than on the host.

The other behavioural difference is what happens when the machine was off. cron skips the run entirely. A timer with Persistent=true runs as soon as it can afterwards, which is usually what people expect from a nightly job on a laptop. The generated unit sets it; remove the line to get cron's skipping behaviour back.

Shorthands And @reboot

@daily, @hourly, @weekly, @monthly and @yearly expand to their five-field forms and then translate normally. Be careful with @weekly: in cron it means Sunday at midnight, while systemd's own weekly shorthand means Monday. That is why an explicit expression is emitted rather than the shorthand passed through.

@reboot is different in kind. It is not a calendar event, so it has no OnCalendar form at all. The replacement is OnBootSec=, which fires a fixed interval after boot, or OnStartupSec= relative to systemd starting. The tool says this instead of reporting a parse error, because a syntax error would send you looking for a typo that does not exist.

Verifying On The Real Machine

Once the unit is in place, systemd-analyze calendar '<expression>' prints the next elapse and confirms systemd parses the expression the way you meant. However, it is worth being precise about what that proves: it validates the expression in isolation and never saw the crontab line, so it cannot tell you the schedule still matches what you replaced. That comparison is the one this page makes before you copy anything.

systemctl list-timers then shows the timer alongside its next and last run once it is enabled.

Common Mistakes

  • Treating a clean load as a correct schedule. Both grammars accept the translation; only the calendar tells you whether it matches.
  • Carrying over TZ. The timer uses the host timezone unless told otherwise.
  • Assuming weekly means the same day. cron day 0 is Sunday; systemd's week starts Monday.
  • Looking for a syntax fix for @reboot. It needs a different directive, not a different expression.

You May Also Need

You may also need

Next steps

Alternatives

Continue Learning