Cron Expression Parser

Parse and explain cron schedule expressions

minute (0-59) | hour (0-23) | day (1-31) | month (1-12) | weekday (0-6)

Common Examples

The DOM/DOW OR Trap: Why '0 0 13 * 5' Is Not Friday the 13th

The single most misunderstood rule in cron is written plainly in POSIX crontab(5): if both the day-of-month field and the day-of-week field are restricted — that is, neither is * — the job runs when either one matches. The expression 0 0 13 * 5 therefore fires at midnight on every 13th of the month and on every Friday. Instead of the one-to-three Friday-the-13ths a year you expected, you get roughly 60 runs annually. Because both trigger conditions look individually plausible, this bug routinely survives code review and only surfaces when someone asks why a "monthly" report arrived four times last week. The reliable way to get AND semantics is to restrict only one of the two fields and verify the other inside the command. Pin the day-of-month to 13 and let the shell check the weekday: date +%u prints the ISO weekday (1 = Monday, 7 = Sunday), so testing it against 5 selects Fridays. The job now runs at most twelve times a year, and the guard exits quietly on the non-Friday 13ths. There is a second trap hiding in that command line: in a crontab entry, an unescaped % is special. The first % terminates the command — everything after it is fed to the command as standard input — and any further % characters become newlines. date +%Y-%m-%d works perfectly in an interactive shell and silently truncates your job in cron. Escape each one as \%, or move the date formatting into a script that cron merely invokes. A surprising share of "cron ran but did nothing" tickets trace back to this one character.
# OR semantics: fires every Friday AND every 13th (~60x/year)
0 0 13 * 5 /opt/report.sh

# AND semantics: pin DOM, test DOW in the command (max 12x/year)
0 0 13 * * [ "$(date +\%u)" = 5 ] && /opt/report.sh

# Unescaped % silently truncates the command line
0 1 * * * echo "backup-$(date +\%Y\%m\%d)" >> /var/log/runs.log

Daylight Saving Time: Where Cron Jobs Go to Die

Schedule a job at 02:30 in a timezone that springs forward and the wall clock jumps from 02:00 straight to 03:00 — the moment your job was due simply never occurs that night. Implementations disagree about what happens next: vixie-cron and its descendant cronie detect the jump and run the skipped jobs shortly after the transition, while many minimal cron clones in containers skip them entirely. Fall-back is worse: the 01:00-02:00 hour happens twice, and a naive scheduler fires the job in both passes. If that job issues invoices or charges cards, you have just double-billed customers once a year. Two defenses work. Keep local-time schedules inside the safe window — at or after 03:00 and before 02:00 — so no transition can touch them, or run the scheduler in UTC, which has no transitions at all. Hosted schedulers add their own semantics. GitHub Actions interprets schedule: cron in UTC only, and its documentation is explicit that runs can be delayed by minutes or dropped entirely under high load — popular minutes like the top of the hour suffer most, so pick an odd minute like 17 or 43. Kubernetes CronJob gained a stable spec.timeZone field in 1.27. Pair it with startingDeadlineSeconds (how late a start is still acceptable — past the deadline the run counts as missed) and concurrencyPolicy, which decides whether a still-running previous job coexists with the next one (Allow, the default), blocks it (Forbid), or is killed by it (Replace).
# Kubernetes CronJob with explicit time zone (stable in 1.27)
apiVersion: batch/v1
kind: CronJob
spec:
  schedule: "30 4 * * *"
  timeZone: "Asia/Seoul"
  startingDeadlineSeconds: 300
  concurrencyPolicy: Forbid

*/N Does Not Mean 'Every N': How Step Values Really Work

A step value */N expands to "the values in this field's range, starting from the range start, in increments of N" — not "every N units of elapsed time". For minutes the two readings coincide: */15 gives 0,15,30,45, and since an hour is exactly 60 minutes the gaps are uniform. Day-of-month breaks the illusion. 1-31/10 expands to days 1, 11, 21, 31 — and the next month starts over at 1, so a "31st then 1st" pair runs on consecutive days. This means a true "every 10 days" is unrepresentable in cron: the day counter resets at every month boundary, and cron keeps no memory of when the last run happened. The same quirk makes 0 0 */2 * * ("every other day") fire on days 1,3,...,29,31 and then day 1 of the next month — back-to-back runs whenever a month has 31 days, which happens seven times a year. When you genuinely need a fixed interval across month boundaries, run the job daily and gate it inside the script: compare the epoch-day number modulo N, or store the last-run timestamp in a file and exit early. Alternatively, systemd timers offer monotonic scheduling (OnUnitActiveSec=10d) that counts from the previous activation and ignores the calendar entirely.
*/15 * * * *      # minutes 0,15,30,45 — uniform, as expected
0 0 1-31/10 * *   # days 1,11,21,31 — NOT "every 10 days"
0 0 */2 * *       # 1,3,...,29,31 then the 1st: twice in a row

# real "every 10 days": run daily, gate on the epoch day
0 3 * * * [ $(( $(date +\%s) / 86400 \% 10 )) -eq 0 ] && /opt/task.sh

One Syntax, Many Dialects: Quartz, EventBridge, and systemd Timers

The 5-field POSIX form (minute, hour, day-of-month, month, day-of-week) is only the baseline. Quartz — the scheduler embedded in countless Java services, Spring's @Scheduled among them — uses 6 or 7 fields with seconds first and an optional year last, and it refuses expressions where both DOM and DOW are concrete: one of them must be ?, meaning "no specific value". In exchange you get genuinely useful operators: L for the last day of the month (or 6L, the last Friday), W for the nearest weekday to a date (15W), and # for the nth weekday, so MON#2 is the second Monday — things plain cron simply cannot express. AWS EventBridge speaks its own 6-field dialect ending in a year field (1970-2199) and, like Quartz, rejects a * in both DOM and DOW at once. Copying a crontab line into an EventBridge rule therefore often fails validation until you swap one field to ?. Classic cron also accepts the @reboot, @daily, and @hourly nicknames — convenient, but nonstandard and unevenly supported, so avoid them in anything portable. If you need second-level resolution or catch-up semantics, systemd timers are the modern answer: OnCalendar accepts seconds (Mon..Fri 09:00:30), and Persistent=true records the last trigger time and fires missed runs at the next boot — the catch-up behavior cron famously lacks, and the reason anacron exists for laptops that sleep through their schedules.
0 9 * * 1-5            # POSIX: weekdays at 09:00
0 0 9 ? * MON-FRI      # Quartz: seconds first, ? required in DOM
0 0 9 ? * MON#2        # Quartz: second Monday of the month
0 9 ? * MON-FRI *      # EventBridge: year field appended

# systemd timer unit equivalent
OnCalendar=Mon..Fri 09:00:00
Persistent=true        # run missed jobs after downtime

Operational Hygiene: Overlaps, Lost Output, and Jobs That Silently Stop

Cron has no catch-up: if the host is down or the daemon stopped when a job was due, that run is gone — no queue, no retry. For jobs where a missed run matters (log rotation, certificate renewal, billing), move to systemd timers with Persistent=true or make the job's own logic tolerate gaps. Output is the next surprise. Cron mails anything a job writes to stdout or stderr to the crontab owner via the local MTA; on a modern box with no MTA configured, that output simply vanishes. Set MAILTO for real alerting, or redirect explicitly with >> /var/log/job.log 2>&1 — and rotate that file, because cron will happily append to it for years. A slow job overlapping its next invocation is the classic corruption source — two rsyncs into one directory, two migrations on one database. Wrap the command in flock -n /path/lock so a second instance exits immediately instead of piling up. And beware the thundering herd: a huge fraction of the world's crontabs say 0 0 * * *, so shared databases and APIs get hammered at midnight sharp. cronie honors RANDOM_DELAY, systemd has RandomizedDelaySec, or derive the minute from a hash of the hostname so a fleet spreads itself out. Finally, the most dangerous failure mode is the job that silently stops running — a bad edit, a disabled account, a full disk. Cron will not tell you. Invert the monitoring with a dead-man's-switch: the job pings a URL on success (the healthchecks.io pattern), and the alert fires when pings stop arriving, not when something reports an error.
[email protected]
RANDOM_DELAY=15    # cronie: spread the start by 0-15 minutes

# no overlap, logged output, dead-man's-switch ping
17 3 * * * flock -n /run/backup.lock /opt/backup.sh \
  >> /var/log/backup.log 2>&1 \
  && curl -fsS https://hc-ping.com/UUID > /dev/null
Last updated:

About this tool

A cron expression parser turns the cryptic five-field syntax used by Unix cron, GitHub Actions, Kubernetes CronJobs, and most schedulers into plain English and shows the next several execution times. The fields are minute, hour, day-of-month, month, and day-of-week — each accepting wildcards (*), ranges (1-5), lists (1,3,5), and steps (*/15).

How to use

  1. Type a cron expression like 0 9 * * 1-5 into the input.
  2. Read the human-readable description that appears below — it confirms what your expression actually means.
  3. Inspect the next execution times to verify edge cases (month boundaries, leap years, etc.).
  4. Click any example expression to load it as a starting point.
  5. Copy the validated expression into your crontab, GitHub Actions workflow, or Kubernetes manifest.

Common use cases

  • Scheduling a daily database backup at 3 AM (0 3 * * *).
  • Running a CI workflow every Monday morning (0 9 * * 1).
  • Triggering a cleanup job every 15 minutes (*/15 * * * *).
  • Sending a weekly digest email Friday afternoon (0 17 * * 5).
  • Defining a Kubernetes CronJob to rotate logs at midnight on the first of each month.
  • Validating a tricky expression that uses ranges and steps before deploying it.

Frequently asked questions

Q. Why does my expression run at unexpected times?

A. Cron interprets day-of-month and day-of-week as OR (not AND) when both are restricted. * * 15 * 1 fires on the 15th OR every Monday — not just Mondays falling on the 15th.

Q. What time zone does cron use?

A. Whatever the host or container is configured for. GitHub Actions cron runs in UTC. Kubernetes CronJobs default to the cluster controller manager time zone unless you set spec.timeZone.

Q. Are 6-field cron expressions supported?

A. Some schedulers (Quartz, AWS EventBridge) add a seconds or year field. This parser uses the standard 5-field POSIX format.

Q. How do I run a job once at a specific time?

A. Cron itself does not have a one-shot mode. Use systemd timers, an at job, or an event-based trigger instead.