Skip to content
ansezz.

▸ Free tool

Cron Explainer.

Paste a 5-field cron expression and get a plain-English description, the next five run times and the Laravel scheduler line. I built it for my own schedulers.

▸ Format

Standard 5 fields: minute hour day-of-month month day-of-week. Supports *, */n, ranges a-b, lists a,b,c, names like MON and shortcuts like @daily.

▸ In plain English

—

    ▸ Next 5 runs

      ▸ Laravel scheduler

      -

      Goes in routes/console.php. Laravel runs it from one system cron entry: * * * * * php artisan schedule:run.

      The five fields, left to right

      Minute (0 to 59), hour (0 to 23), day-of-month (1 to 31), month (1 to 12), day-of-week (0 to 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 in GitHub Actions

      The on: schedule trigger takes the same five fields, so anything this page explains works there. A few rules are different. Schedules run in UTC unless you add a timezone key next to the cron line, for example timezone: "America/New_York". The shortest interval is once every 5 minutes. Scheduled runs only use the latest commit on the default branch, and GitHub says they can be delayed when Actions is under heavy load, so do not use them for anything that must fire on the exact minute.

      Source, checked October 10, 2026: GitHub Docs, events that trigger workflows: schedule.

      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 to 7, both 0 and 7 are Sunday 1 to 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?

      It reads the standard five-field crontab format: *, single values, ranges, comma lists, steps, and month and weekday names like JAN and MON. The Vixie shortcuts @yearly, @annually, @monthly, @weekly, @daily, @midnight and @hourly are expanded to their five-field form, and @reboot is explained but has no next run time. Six-field seconds-first expressions and Quartz tokens such as ? L W and # are rejected rather than guessed at. Everything is parsed in your browser as you type, and the expression is never sent anywhere.

      How do I write this cron schedule in the Laravel scheduler?

      The Laravel box under the next run times turns the expression into the closest fluent method, such as ->dailyAt('2:00'), ->everyFifteenMinutes() or ->weeklyOn(Schedule::MONDAY, '9:00'), and falls back to ->cron('...') when no helper fits. Put the line in routes/console.php, swap in your own command name, and keep a single system cron entry that runs php artisan schedule:run every minute. Laravel uses the app timezone unless you add ->timezone().

      How are the next run times worked out?

      The page walks forward minute by minute from now and lists the first five matches, in your browser's time zone or in UTC. It follows Vixie rules, so when both day fields are restricted a day matches if either one does. Times that do not exist because of a daylight saving jump are skipped, the same way most cron daemons skip them.

      Keep going

      ▸ Last verified:

      Need this in production?

      Building a Laravel SaaS? I design and ship multi-tenant Laravel apps, from first commit to production.

      Laravel SaaS MVP

      Also related: Architecture Audit .

      Keep reading