Cron expressions: every edge case in one place
The day-of-month/day-of-week OR trap, time-zone behavior across Linux/AWS/GitHub Actions, what happens during DST, Quartz vs standard cron, and how to safely schedule jobs that run exactly when you mean them to.
Cron expressions look simple. Five fields, each a number or asterisk, schedules a job at a given time. The complete explanation should fit on a napkin. In practice, getting cron right in production is one of the more error-prone parts of running a system, and the failure modes — jobs running twice, jobs not running at all, jobs running at unexpected times — tend to manifest in production at 3 AM after weeks of working correctly.
This post is a tour of every edge case worth knowing: the day-of-week/day-of-month OR trap, time zones across different schedulers, what happens during daylight saving transitions, Quartz vs standard cron, and how to write expressions that do what you mean.
The five fields
Standard cron expressions have five fields, in this order:
┌───────────── minute (0-59) │ ┌───────────── hour (0-23) │ │ ┌───────────── day of month (1-31) │ │ │ ┌───────────── month (1-12 or JAN-DEC) │ │ │ │ ┌───────────── day of week (0-7 or SUN-SAT, where 0 and 7 both = Sunday) │ │ │ │ │ * * * * *
Each field can be: a single value (5), a range (1-5), a list (1,3,5), a step (*/15 = every 15 units), or a combination (0,15,30,45 = same as */15).
Examples that read naturally:
0 9 * * 1-5— 9 AM every weekday*/15 * * * *— every 15 minutes0 0 1 * *— midnight on the first of every month30 2 * * 0— 2:30 AM every Sunday
The day-of-month vs day-of-week OR trap
This is the single most common cron mistake. When BOTH day-of-month AND day-of-week are specified, most cron implementations OR them together, not AND. Read that again — it's the opposite of what people expect.
Consider 0 9 13 * 5. The naive reading is "9 AM, on the 13th, but only if it's a Friday" — i.e., Friday the 13th. What it actually means in standard cron is "9 AM on the 13th of every month, OR on every Friday". You get every Friday plus every 13th of the month, totaling roughly 60 runs a year instead of two.
This OR behavior is specified in POSIX. The reasoning was probably that the historical intent was "I want this job on the 1st of the month and also on Mondays" — so OR is more useful as a default. But it surprises everyone the first time they hit it.
The workaround in standard cron: leave one field as * and check the other condition in your script. To run on Friday the 13th specifically:
0 9 13 * * [ $(date +\%u) -eq 5 ] && /path/to/job.sh
Some cron variants (Quartz, used in Java schedulers; Vixie cron with the ? token) let you specify "this field is intentionally unspecified" rather than wildcard, sidestepping the issue. Standard system cron doesn't have this option.
Time zones: nearly always wrong by default
The cron expression itself doesn't include a time zone. Whether 0 9 * * * runs at 9 AM UTC, 9 AM in your server's local time, or 9 AM in some configured time zone depends entirely on the scheduler.
The defaults across common systems:
- System cron on Linux: server's local time zone, set by
/etc/localtimeorTZenvironment variable. If the server is in UTC (typical for cloud servers), schedules run in UTC. - systemd timers: configurable per timer via
OnCalendar=andPersistent=. Default is local time but you can specify UTC explicitly. - AWS EventBridge (formerly CloudWatch Events) cron: always UTC. There is no way to configure this. Many people have built recurring jobs assuming local time and been surprised.
- GitHub Actions cron: also UTC, always.
- Kubernetes CronJobs: cluster-wide time zone setting, defaults to UTC. As of Kubernetes 1.27+ you can override per-job with
spec.timeZone. - Heroku Scheduler: the dyno's TZ env var; defaults to UTC.
The lesson: always know what time zone your cron runs in, and prefer UTC for cloud environments. If a job needs to run at 9 AM in some user-facing time zone, store the time zone in the job's data and convert inside the job — don't rely on the cron expression to handle it.
Daylight saving transitions
DST is where everything time-related gets weird. In time zones that observe DST, there are two days per year where a particular wall-clock time either doesn't exist (spring forward, 2:30 AM jumps directly to 3:30 AM) or happens twice (fall back, 2:30 AM happens at standard time and again an hour later).
What happens to a cron job scheduled at 2:30 AM:
- Spring forward (the 2:30 doesn't exist day): most schedulers either skip the run entirely or fire it at 3:30 AM when the next valid minute occurs. Standard system cron (Vixie) catches up — fires once at 3:00 AM. Quartz misses entirely. AWS EventBridge runs in UTC so DST doesn't apply.
- Fall back (the 2:30 happens twice day): standard cron fires once. Quartz fires twice. Some schedulers fire neither because of the ambiguity.
If a job is critical and runs near the DST transition hours (~2-3 AM local time), schedule it in UTC to avoid the issue entirely. If you must use local time for some user-facing reason, schedule the job at a time well clear of the DST transition window — 1 AM and 4 AM are safe; 2:30 AM is dangerous.
Quartz extensions: seconds, year, and ?
Quartz is the Java scheduling library that ships with Spring, Hangfire, Hibernate, and many other Java-based systems. It uses a 6 or 7-field cron expression rather than 5:
second minute hour day-of-month month day-of-week [year] 0 0 9 * * ? *
The extra ? means "no specific value" — used to resolve the day-of-month/day-of-week ambiguity. You must use ? in exactly one of those two fields and a value or * in the other. A Quartz expression with * in both is rejected as invalid.
Quartz also supports:
Lin day-of-month means "last day of month".L-2means "two days before the end of the month".Lin day-of-week means Saturday (numerically the last day).5Lmeans "the last Friday of the month".Win day-of-month means "weekday nearest".15Wmeans "the weekday nearest the 15th". Useful for payroll: pay employees on the 15th, but on a weekday.#in day-of-week means "nth occurrence".1#3means "third Monday of the month".
These extensions are genuinely useful but only work with Quartz-compatible schedulers. Standard system cron has none of them. If you're writing a cron expression for use with Spring Schedule or Hangfire, you can use them; if you're writing for crontab or AWS EventBridge, you can't.
Special strings
Most cron implementations recognize a few human-readable shortcuts:
@yearlyor@annually=0 0 1 1 *(midnight on January 1)@monthly=0 0 1 * *(midnight on the 1st of each month)@weekly=0 0 * * 0(midnight on Sunday)@dailyor@midnight=0 0 * * *@hourly=0 * * * *@reboot= run once when cron starts (boot time on Linux)
Use them when they fit; they read more clearly than the equivalent five-field forms. Note that @reboot is unique to system cron — it doesn't translate to other schedulers.
Catch-up behavior after downtime
What happens if the cron server was down when a scheduled run should have fired? Behavior varies wildly:
- System cron: doesn't fire missed jobs. The schedule simply skipped.
- anacron (often installed alongside system cron on desktops/laptops): runs jobs that were skipped, on next boot. Designed for machines that aren't always on.
- systemd timers with
Persistent=true: runs missed jobs on next start. - Quartz with
MISFIRE_INSTRUCTION_FIRE_NOW: fires once for all missed runs. - Quartz with
MISFIRE_INSTRUCTION_IGNORE_MISFIRE_POLICY: fires for every missed run, in sequence. Watch out: if the system was down for a week, you'll get hundreds of fires when it comes back. - AWS EventBridge: doesn't fire missed runs; AWS service downtime is the only reason a run would be missed and AWS handles delivery retries internally.
The right behavior depends on what the job does. Idempotent jobs that compute "what should the world look like at this time" handle missed runs fine — when they next fire, they catch up. Jobs that send an email or process a transaction shouldn't double-fire. Be deliberate about which kind your jobs are.
Overlapping runs
A cron job scheduled every minute that takes 90 seconds to complete will be running two copies concurrently after the first execution. System cron does not prevent this — it spawns a new process at every scheduled time regardless of whether the previous run finished.
Most production systems use file-based locks or distributed locks to prevent overlapping runs:
# Use flock on Linux to prevent overlapping * * * * * /usr/bin/flock -n /tmp/myjob.lock /path/to/job.sh
The -n flag means "fail immediately if the lock is held", so overlapping invocations exit without running. Without -n, they queue.
Kubernetes CronJobs have a concurrencyPolicy setting: Allow (default, lets jobs overlap), Forbid (skip if previous still running), Replace (kill the previous run and start the new one). For production cron-driven Kubernetes jobs, Forbid is usually the right default.
Common patterns that work
Cron expressions that come up frequently and are worth memorizing:
0 0 * * *— midnight daily0 2 * * *— 2 AM daily (popular for backups and cleanup jobs)0 0 * * 0— midnight Sunday (weekly)0 0 1 * *— midnight on the first of each month (monthly)0 9 * * 1-5— 9 AM weekdays*/15 * * * *— every 15 minutes*/5 9-17 * * 1-5— every 5 minutes during business hours, weekdays only0 */6 * * *— every 6 hours (midnight, 6 AM, noon, 6 PM)30 1 * * *— 1:30 AM daily (just past the DST danger window)
Testing cron expressions before deploying
The best practice is: never deploy a cron expression you haven't tested. The schedule isn't intuitive enough to trust your reading of it. Two ways to test:
- Use our cron explainer to convert the expression to a natural-language description AND see the next ten run times. If those match what you intended, the expression is right.
- Run the expression in a test environment for a day or two before promoting to production. Logs will show actual fire times.
The cost of a wrong cron expression in production is usually paid in 3 AM pages. The cost of testing it first is five minutes. The math is one-sided.
When not to use cron
Cron is a reasonable choice for: periodic maintenance, scheduled reports, daily/weekly batch processing, anything that's tied to a wall-clock time. It's a bad choice for:
- Polling for work — a queue with a worker is simpler, faster, and doesn't fire when there's nothing to do.
- Delayed execution after a user action — a job queue with delay support (Sidekiq, BullMQ, AWS SQS with delay) is the right primitive.
- Workflows with dependencies — Airflow, Dagster, Temporal handle "run B after A succeeds" cleanly. Cron doesn't.
- High-frequency tasks (sub-minute) — system cron's minimum resolution is one minute. Sub-minute scheduling needs an in-process scheduler.
Cron is older than most of the systems built on top of it, simpler than its modern replacements, and stubbornly useful for the cases it was designed for. Knowing its edges is the price of admission.