Understanding and writing cron expressions correctly

Cron expressions are a strange mix of brilliant and cryptic. Five fields with digits and special characters are enough to describe arbitrarily complex schedules — from a daily backup at 03:15 to "every other Tuesday of the quarter." Once you internalize the syntax, you never want to leave it. If you don't, it looks like magic. This article clears it up.

A short history

cron goes back to Unix Version 7 in 1979. Written by Brian Kernighan, it was a simple daemon that read a config file every minute and ran matching commands. In 1987, Paul Vixie wrote the variant in widespread use today (Vixie cron), adding user crontabs, environment variables, and the @reboot keyword.

Today there are many cron dialects: GNU/Linux distros usually ship Vixie cron or a derivative. macOS used cron for a long time but now uses launchd. Kubernetes has CronJobs with Unix syntax. Java frameworks like Quartz introduced an extended 6/7-field dialect. The core ideas are similar — details differ.

The 5-field syntax

A classic Unix cron expression consists of five whitespace-separated fields, followed by the command to run. The fields, in order:

  • Minute (0–59)
  • Hour (0–23)
  • Day of month (1–31)
  • Month (1–12 or JAN–DEC)
  • Day of week (0–6, 0 = Sunday, or SUN–SAT)

Special characters

Each field supports several special characters to describe value ranges:

  • * — any value. In the minute field, * means every minute.
  • , — list of values. Example: 0,30 in the minute field means minute 0 and minute 30.
  • - — range. Example: 9-17 in the hour field means 09:00, 10:00, ..., 17:00.
  • / — step. Example: */15 in the minute field means every 15 minutes (0, 15, 30, 45). Combinable with ranges: 0-30/5 means 0, 5, 10, ..., 30.
  • ? — "no specific value". Only supported by Quartz, used in either the day-of-month or day-of-week field when the other has been specified.

Aliases

Instead of the five fields, you can use aliases — shorter and more readable:

  • @yearly / @annually — equivalent to 0 0 1 1 * (once a year, at midnight on January 1)
  • @monthly — 0 0 1 * * (the 1st of every month at midnight)
  • @weekly — 0 0 * * 0 (every Sunday at midnight)
  • @daily / @midnight — 0 0 * * * (every day at midnight)
  • @hourly — 0 * * * * (every hour, on the hour)
  • @reboot — once at cron daemon start (typically at system boot)

Practical examples

  • */15 * * * * — every 15 minutes
  • 0 3 * * 0 — every Sunday at 03:00
  • 30 8 * * 1-5 — Monday to Friday at 08:30
  • 0 0 1 */3 * — on the 1st of every quarter at midnight
  • 15 9-17 * * 1-5 — Monday to Friday, at minute 15 of each hour between 9 and 17

Quartz and other extensions

Java schedulers like Quartz use an extended syntax with six or seven fields: an additional seconds field at the front, optionally a year field at the end. There are also special characters like L (last day of month), W (nearest weekday), and # (nth occurrence of weekday in month). Quartz also allows ?, which Unix cron doesn't know.

If you copy a cron expression from a Java/Spring tutorial into a Linux crontab, the extra seconds field can be parsed as a minute — and the job runs at completely different times. Before porting, always check the target platform's documentation.

Common pitfalls

  • Time zone: cron uses the system's local timezone by default. If you run servers worldwide, set a timezone explicitly (e.g. via CRON_TZ in Vixie cron) — otherwise the "nightly backup" suddenly runs in the afternoon of your region on Tokyo servers.
  • DST transitions: on days with clock changes, a job can be skipped entirely in a missing hour, or run twice in a doubled hour. If that's not acceptable, use UTC as the cron timezone.
  • Slow jobs: cron starts the next job at its scheduled time, regardless of whether the previous one is still running. For overlapping backups or syncs, use a lock file or a wrapper like flock.
  • DoM and DoW are combined with OR, not AND: 0 0 15 * 1 runs both on the 15th of any month and on every Monday. If you want "only Mondays that are the 15th," you need either an extra script check or a Quartz-like syntax.

My three most painful cron mistakes

Cron mistake #1 (2016): I had written a cron job that triggered a 200 GB database backup every morning at 4 am — and then at 4:05 a script that operated on the finished backup. Sounds logical, wasn't. The backup duration grew with the data, from 3 to eventually 12 minutes. For three weeks my follow-up script was processing pieces of the previous day's backup, because the current one wasn't done yet. The bug only surfaced when a customer reported that reports felt out of date.

Cron mistake #2 (2019): server migration to a new machine, crontab copied, everything worked — I thought. A week later the disk was full. A log-rotate script that ran hourly had never deleted old logs because the path on the new server was different. tar: cannot open errors were logged but nobody had ever read the log. Lesson: every cron job needs a visible failure notification, not just a log entry.

Cron mistake #3 (2022): DST changeover. My cron job ran every night at 02:30 — and on 30 March 2022 that time was skipped because the clock jumped from 02:00 → 03:00. An important reporting script simply didn't run that day. Nobody noticed, because the dashboard 'looked empty but somehow' worked the next day. Since then I schedule critical jobs at 04:30 or 05:00 — safely outside the DST risk window. Plus: use UTC wherever possible to dodge the problem entirely.

systemd timers: why they beat cron in most cases

Since around 2018, systemd timers have become a serious alternative to cron on modern Linux distros (Ubuntu 20.04+, Debian 11+, RHEL 8+). Instead of two pieces (cron + script) you have two unit files: a .service unit defining what runs, and a .timer unit defining when. Sounds like more work, but it's markedly more robust.

Key advantages over cron: journalctl integration (all logs unified and searchable), persistence (missed runs are caught up after reboot with Persistent=true), randomization (RandomizedDelaySec= spreads cluster jobs so servers don't all start at once), calendar specs are more expressive than cron ('the first weekday of each month at 9 am' works directly in the timer with no script logic), and dependency management (a timer can wait for another service).

When cron still wins: shared hosting (no root for systemctl), very simple one-liners ('curl this endpoint hourly'), Docker containers without systemd. In every other case on modern Linux, systemd timers are the better choice. Personally I've been migrating new jobs to systemd since 2021 but leave existing crons alone — 'works' is a good state.

Cron in Kubernetes: the CronJob resource

If you work in Kubernetes clusters, you know the CronJob resource. It uses the same cron syntax but runs as a container pod and is highly available — if a node fails, another takes over. The most important pitfall: concurrencyPolicy. Default is Allow, meaning if a job runs longer than the interval, a second one starts in parallel. For DB migrations or backup scripts that's fatal. Consistent recommendation: set Forbid or Replace for all non-idempotent jobs.

Second K8s pitfall: startingDeadlineSeconds. If the cluster is overloaded or restarting at the scheduled time, a job can be deferred. Without startingDeadlineSeconds K8s tries to catch up indefinitely — with it you can say 'if not started within 10 minutes, skip'. For daily backups: startingDeadlineSeconds: 3600 (1 hour) is a reasonable default. For hourly cleanups: startingDeadlineSeconds: 300.

When a cron job doesn't run: 5-point debug plan

Cron bugs are especially frustrating because they happen invisibly — nothing fails, the script simply never ran. My workflow, ordered by probability:

  • Check the log. grep CRON /var/log/syslog or journalctl -u cron. If no entries appear at the scheduled time: crontab isn't active or the job runs under another user account. crontab -l (current user), sudo crontab -u www-data -l (other users).
  • Environment variables. Cron runs with a minimal environment — no PATH like in your shell, no LANG, often no user-specific ENVs. If your script calls php artisan ... and you get 'command not found', PATH is the culprit. Solution: use full paths (/usr/bin/php /var/www/artisan ...) or set PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin at the top of the crontab.
  • Working directory. Cron starts in the user's home directory, not the script's directory. Relative paths break. Solution: cd /path/to/project && ./script.sh, or inside the script as the first line cd $(dirname "$0").
  • Lock files. If a previous run is still going and your script checks a lock file, the new run gets skipped. ls -la /var/run/myscript.lock shows whether a lock exists. If the file is old and the process long dead, delete it manually.
  • MAILTO notifications. Cron can send output and errors by email if you set [email protected] at the top of the crontab. Prerequisite: a local MTA (postfix/sendmail) is configured. With this, every silent error reaches you proactively — my default for every production system.

In 90 % of cases it's one of the first three points. Anyone who has set up email or Slack notifications for cron failures has banished job-status anxiety from their life. Plus: a weekly 'all cron jobs ran as scheduled' health check as an extra job is little extra effort for a lot of peace of mind.

Frequently asked questions

How do I test a cron expression without waiting?

Cron generators and parsers can predict the next few execution times of an expression. On servers, you can test the command with a short cron entry (e.g. * * * * *) and remove it after a successful run.

Why isn't my cron job running?

Classics: PATH and env vars under cron are usually minimal. Use absolute paths (/usr/bin/python3, not python3), check the cron user's mailbox for errors, or redirect stdout/stderr to a log file. Permissions and the correct user in the crontab header are often overlooked, too.

Do I still need cron today?

For many scenarios there are alternatives: systemd timers on modern Linux, Kubernetes CronJobs in containers, Lambda schedules in the cloud. But cron is everywhere, well understood, and works without extra infrastructure — for a simple, regular task on a server, it's often the most pragmatic choice.

What's the difference between cron and at?

cron is for recurring tasks ('every day at 4 am'), at is for one-off future tasks ('start a script on 24 June at 2 pm'). at is less commonly installed and less known, but useful for maintenance windows or planned migrations. Syntax: echo "/path/to/script.sh" | at 14:00 2026-06-24.

How do you test a cron expression without running it?

Our cron expression generator shows the next execution times for any expression. In the terminal you can use croniter (Python) or node-cron to verify what an expression actually does. Especially for complex expressions like 0 9 1-7 * 1 a visual preview is gold — that expression means 'every Monday in the first week of the month at 9 am', which is far from intuitive.

What about cloud schedulers like AWS EventBridge or Google Cloud Scheduler?

Both use cron syntax (with small variations — AWS has a 6-field format with year) but offer extras: high availability, automatic retries, integration with Lambda/Functions/topics, audit logs. For cloud-native workloads they're the clean choice. Cost: typically EUR 1-5/month per job — cheaper than a server running just for cron. But if you already have workloads on a server, local cron is still simpler.

Disclaimer: Cron dialects differ in subtle ways. Before deploying to production, consult the documentation of your specific cron implementation (or scheduling framework) and verify critical jobs in a test environment.

Comments