How to read a cron expression without dying
Standard format has five space-separated fields: minute hour day-of-month month day-of-week. For example, 0 9 * * 1-5 reads left to right: minute 0, hour 9, any day of month, any month, days 1 to 5 of the week (Monday to Friday). Result: 9 AM on weekdays.
Most-used special characters are asterisk (*) meaning "any value", slash (/) for intervals (*/15 = every 15), comma (,) for lists (1,3,5 = Mon, Wed, Fri), and hyphen (-) for ranges (9-17 = from 9 to 17). In extended cron (Quartz) there's a sixth seconds field at the start and extra characters like L (last) and # (nth).
Days of week range 0 to 6, where 0 is Sunday in most implementations (including Linux cron and crontab). Some systems like Quartz count 1-7 with 1=Sunday. That causes the classic bug: you schedule "every Monday" with * * * * 1 and it ends up running Sundays. Verify your system's docs. cron-utils in Java has flags to switch dialects.
Cron in different systems: most common traps
In Linux crontab, jobs run with user shell and inherit limited PATH. If your script uses node and crontab can't find node in its PATH, it fails silently. Solution: use absolute paths (/usr/local/bin/node /home/user/script.js) or set PATH=... at the top of crontab.
In GitHub Actions, cron accepts standard syntax but only runs on default branch. schedule: - cron: '0 9 * * 1-5' triggers workflow at 9 UTC, not your timezone. GitHub runs everything in UTC with no option to change. For 9 AM ET (UTC-5), schedule 0 14 * * 1-5. Another trap: GitHub may delay executions under load, so critical cron isn't ideal there.
In Kubernetes CronJob, schedule field uses standard cron but also respects timeZone (since k8s 1.27). Before that, all UTC. Another quirk: concurrencyPolicy: Forbid prevents a job from overlapping with previous instance if it runs long. For tasks that may run more than a minute on per-minute cron, this is critical to avoid runaway jobs.
Typical mistakes and how to avoid them
Mistake #1: confusing day-of-month with day-of-week. 0 9 1 * 1 is NOT "Mondays the 1st of month". It's "day 1 of month OR any Monday" (implicit OR operator). If you want first Monday specifically, use 0 9 * * 1#1 in extended cron or compute in code.
Mistake #2: assuming */N divides exactly. */7 * * * * isn't "every 7 minutes" strictly: it runs at 0, 7, 14, 21, 28, 35, 42, 49, 56 and back to 0 at hour change, leaving 4-minute gap between 56 and next 0. If you need exact intervals, use a scheduler with configurable duration, not cron.
Mistake #3: scheduled tasks depending on DST timezones. A task scheduled at 2:30 AM may run twice or none on time changes. Linux cron with local TZ has this bug. Solution: use TZ=UTC in crontab for critical tasks, or systemd timers that handle DST correctly. Atlassian, Stripe and GitHub had public incidents from this.
Cron shortcuts and modern alternatives
Cron has predefined shortcuts many don't know: @yearly (= 0 0 1 1 *), @monthly, @weekly, @daily, @hourly and @reboot. Readable but less flexible. @reboot runs once on system startup, useful for bootstrap scripts but dangerous if your system restarts frequently.
For cases where cron falls short, there are alternatives. systemd timers on modern Linux allow persistence (re-execute if system was off), randomization (RandomizedDelaySec avoids thundering herd) and task dependencies. Apache Airflow and Prefect handle DAGs with dependencies, retries and observability cron never had.
For modern apps, frameworks like BullMQ (Node), Celery (Python) and Sidekiq (Ruby) offer scheduling with built-in monitoring. If your cron starts having 30+ tasks, conditional logic or needs observability, move to one of those. Cron is meant for simple isolated tasks. Slack, GitHub and Stripe use combinations of cron + queue systems so cron only triggers workers, no business logic.