Skip to content
ansezz.

▸ Free tool

Cron Explainer.

Paste a 5-field cron expression — get a plain-English description of exactly when it fires. Built it for my own schedulers.

▸ Format

Standard 5 fields: minute hour day-of-month month day-of-week. Supports *, */n, ranges a-b, and lists a,b,c.

▸ In plain English

    The five fields, left to right

    Minute (0–59), hour (0–23), day-of-month (1–31), month (1–12), day-of-week (0–7). Sunday is both 0 and 7 — a Vixie-era compromise that still catches anyone assuming 7 means Saturday. Each field takes a star, a value, a range a-b, a list a,b,c, or a step */n. Steps are a Vixie extension, not POSIX.

    Three-letter names like JAN and MON work for month and day-of-week, but classic crontab(5) forbids ranges and lists of names — MON-FRI is a portability gamble. Use numbers. The readout above walks the expression left to right, token by token.

    Day-of-month and day-of-week are OR, not AND

    This is the bug that ships. Minute, hour and month are ANDed together the way you expect. The two day fields are not: when both are restricted, cron fires if either one matches.

    So 0 0 13 * 5 is not "midnight on Friday the 13th". It is midnight on the 13th of every month plus midnight every Friday — roughly sixty runs a year, not one or two. Leave one of the two day fields as * and normal AND behaviour returns. Vixie-derived crons test "restricted" by looking for a literal leading asterisk, so 0 0 */2 * 5 still gets AND semantics. Quartz sidesteps the argument by requiring ? in exactly one of the two fields.

    */7 is not "every 7 minutes"

    A step is a set, not an interval. */7 in the minute field expands to 0, 7, 14, 21, 28, 35, 42, 49, 56 — then the hour rolls over and the count restarts, so the gap between :56 and the next :00 is four minutes.

    Day-of-month is worse: the field length changes underneath you. */2 means 1, 3, 5 … 31 and restarts at 1 every month, so a 31-day month hands you runs on two consecutive days. Only steps that divide their field evenly give a true interval — for minutes, 2, 3, 4, 5, 6, 10, 12, 15, 20 and 30. If you need a real interval, use a scheduler that has one — systemd's OnUnitActiveSec=, or @every 1h30m in Go's robfig/cron.

    Timezones, and the two broken nights a year

    Cron schedules in the daemon's local timezone, not UTC. cronie and Debian's cron read a CRON_TZ= line at the top of a crontab, and Kubernetes CronJob takes spec.timeZone. Get it wrong and everything is an hour off for half the year, which reads as flakiness rather than as a bug.

    DST is the sharp edge: on spring-forward 02:30 local does not exist; on fall-back it happens twice. Vixie and cronie special-case shifts under three hours for fixed-time jobs: skipped runs fire once right after the jump, nothing repeats. Jobs running more often than hourly get no such help — */5 follows the wall clock, so you collect a bonus hour of runs every autumn. Run schedulers in UTC, convert at the edges with an epoch converter, and make jobs idempotent so a double fire is boring.

    Cron will happily stack overlapping runs

    Cron has no idea whether the previous invocation finished. Schedule something every five minutes, let it take seven under load, and by lunchtime you have concurrent processes fighting over the same rows. That is how a slow report job takes out a database.

    Cron gives you nothing here, so wrap it: flock -n /var/lock/job.lock ./job.sh from util-linux, withoutOverlapping() in Laravel's scheduler, concurrencyPolicy: Forbid on a Kubernetes CronJob, or a systemd service unit, which cannot run two instances at once. Once a job is routinely long enough to overlap, put the work behind a queue instead — the argument in message queues for document processing.

    @reboot, seconds, and the dialect problem

    Everything past the five fields is an extension, and the extensions disagree. @daily, @hourly, @weekly and @reboot are Vixie's. @reboot is the misleading one: it fires when the cron daemon starts, not when the machine boots and not when your dependencies are ready. Restart cron on a running box and every @reboot line runs again.

    Seconds are where copy-paste dies. Quartz, Spring's @Scheduled, node-cron and Go's robfig/cron with WithSeconds() put a seconds field first, so a six-field expression pasted into a Linux crontab shifts every field one place and gets rejected — and a five-field expression pasted into Quartz quietly becomes a seconds schedule. Kubernetes CronJob stays five-field; one minute is its floor.

      Vixie / cronie (Linux) Quartz (Java, Spring)
    Fields 5 — minute to day-of-week 6 or 7 — seconds first, optional year
    Seconds field
    Day-of-week numbers 0–7, both 0 and 7 are Sunday 1–7, 1 is Sunday
    ? (no specific value)
    L, W and # tokens
    @daily, @reboot shortcuts
    Parsed by this page

    Questions, answered.

    What does 0 2 * * * mean in cron?

    It runs once a day at 02:00 — minute 0, hour 2, every day-of-month, every month, every day-of-week. That is the expression loaded in the box above. It is also the worst hour to pick on a server that observes daylight saving, because 02:00 local is exactly the time that gets skipped or repeated twice a year.

    Why did my cron job run on the wrong day?

    Almost always the day-of-month versus day-of-week trap. When both of those fields are restricted, cron runs the job if either one matches, not both — so 0 0 13 * 5 fires on the 13th of every month and on every Friday, roughly sixty times a year, instead of on Friday the 13th. Leave one of the two fields as * and the usual AND behaviour comes back.

    Does */7 * * * * run every 7 minutes?

    No. A step expands to a set within the field, so */7 means minutes 0, 7, 14, 21, 28, 35, 42, 49 and 56, and then the hour rolls over and the count restarts at zero. The last gap is four minutes, not seven. Steps only give an even interval when they divide the field evenly — for minutes that means 2, 3, 4, 5, 6, 10, 12, 15, 20 or 30.

    What timezone do cron jobs run in?

    Whatever timezone the scheduler itself is in, which is usually the system timezone rather than UTC. cronie and Debian's cron let you put a CRON_TZ=Europe/Paris line at the top of a crontab, Kubernetes CronJob takes spec.timeZone, and most application schedulers simply inherit the process timezone. Running schedulers in UTC and converting only for display removes a whole class of daylight-saving bugs.

    Why does my cron job work in the terminal but not in cron?

    Environment. Cron hands a job a nearly empty environment with a minimal PATH — often just /usr/bin:/bin — no shell profile, and none of the variables your interactive login sets, so a command that resolves fine for you comes back 'not found'. Use absolute paths for every binary, and remember that % is special inside a crontab command: it is turned into a newline unless you escape it as \%, which is why date +%Y silently breaks.

    Does this cron explainer support @daily, seconds, or Quartz syntax?

    No — it parses the standard five-field crontab format only: *, single values, ranges, comma lists and steps. Shortcuts like @daily and @reboot, six-field seconds-first expressions, and Quartz tokens such as ? L W and # are rejected with an 'expected 5 fields' message rather than guessed at. Everything is parsed in your browser as you type; the expression is never sent anywhere.

    Keep going

    Keep reading