Standards explained · · 11 min read

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.

By The Toolsy team

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:

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:

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:

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:

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:

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:

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:

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:

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:

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.

Found this useful? Share it with a developer who'd want to read it. Have a topic to suggest? Email hello@toolsy.website.

← More posts