Five Cron Gotchas That Silently Break Your Schedule
The day-of-month/day-of-week OR-trap, timezone and DST surprises, overlapping runs and the ranges that don't mean what you think — the cron mistakes that fire jobs on the wrong days, and how to catch them before production does.
Cron looks like the simplest scheduling syntax in the world: five fields, a handful of stars, done. Then a backup that was “supposed to run on the 1st” fires every single day, a nightly report lands an hour late for six months, or a job you wrote for Monday-only quietly runs seven days a week. None of these throw an error. Cron does exactly what you told it — the problem is that what you told it isn’t what you meant. Here are the five traps that catch experienced engineers, and how to see them before they reach production.
1. The day-of-month / day-of-week OR-trap
This is the big one, and it surprises almost everyone the first time. A cron expression has two independent day fields: day-of-month (field 3) and day-of-week (field 5). Intuitively you’d expect them to combine with AND — “the 15th and a Monday.” They don’t. When both fields are restricted (neither is *), cron combines them with OR.
So 0 0 13 * 5 does not mean “midnight on Friday the 13th.” It means “midnight on the 13th of the month, or any Friday” — which fires far more often than you intended. The classic real-world version is 0 9 1 * 1, meant as “9am on the first Monday.” It actually runs at 9am on the 1st of every month and on every Monday.
The rule the standard defines, and the one implemented in the Cron Expression Builder, is precise:
- If only day-of-month is set → match that day-of-month.
- If only day-of-week is set → match that day-of-week.
- If both are set → match if either condition is true (OR).
- If neither is set (
* *) → every day.
The practical takeaway: never restrict both day fields at once unless you genuinely want the union. “First Monday of the month” cannot be expressed in plain POSIX cron at all — you either use a 1-7 day-of-month range plus a weekday test inside your script, or reach for an extension like Quartz’s # syntax. When you paste an expression into the builder and watch the “next fires” list, an OR-trap jumps out immediately: you’ll see the job scheduled on dates you never expected.
2. Cron runs in some timezone — but which one?
A bare cron expression carries no timezone. The daemon interprets it in whatever zone the system, container, or scheduler is configured for — and that is very often UTC, not your local time. 0 2 * * * on your laptop means 2am your time; the same line in a UTC container means 2am UTC, which could be the middle of your afternoon.
This bites hardest when a job’s timing actually matters: a “quiet hours” maintenance window, a report that must land before staff arrive, a rate-limited API call you wanted at off-peak. Always ask which clock the scheduler uses, and prefer to state it explicitly — CRON_TZ=Europe/London on the line, a TZ in the job’s environment, or your scheduler’s timezone field. The Cron Expression Builder lets you pick a timezone and shows the next fire times in that zone, so you can confirm “2am” is the 2am you actually meant before you ship it.
3. Daylight Saving Time eats and duplicates runs
Even with the right timezone, twice a year the clock jumps. On the spring-forward night, 2:30am simply doesn’t exist — so a 30 2 * * * job skips entirely that day. On the fall-back night, 1:30am happens twice, so a 30 1 * * * job can fire twice. Different cron implementations paper over this differently (Vixie cron has special catch-up rules for jobs in the skipped hour), which means behaviour isn’t even portable.
The robust fix is to schedule sensitive jobs outside the DST transition window — anything at, say, 4am or later on the changeover dates is safe — or to run in UTC, which has no DST and never jumps. Because the builder computes real calendar fire times in your chosen zone, you can scroll its upcoming-runs list across a DST boundary and literally watch a naive 2:30am job vanish or double.
4. Overlapping runs: cron doesn’t wait for the last one
Cron fires on the clock, not on completion. If you schedule */5 * * * * for a task that usually takes 90 seconds but occasionally takes eight minutes, cron will happily start a second copy while the first is still running. Two backups writing the same file, two importers double-inserting rows, load piling on load until the box falls over — a whole class of production incidents traces back to a job that quietly stopped finishing inside its own interval.
Cron gives you no built-in mutual exclusion. Guard it yourself: wrap the command in flock (flock -n /tmp/job.lock -c '…'), have the script take a lock or check a PID file, or use a scheduler that enforces “no concurrent runs.” The scheduling syntax is only half the job; idempotency and locking are the other half.
5. Ranges, field counts and dialects that don’t mean what you think
The field mini-language has its own sharp edges — and the sharpest ones are about which dialect you’re actually writing in:
- A wrong field count is a dialect mismatch, not a typo. Standard cron wants exactly five fields; the six-field “with seconds” variant used by some libraries is a different dialect that happens to look identical. Pasting a six-field expression into a five-field parser is a common copy-paste bug.
- Extensions don’t travel. Quartz’s
#syntax for “first Monday of the month” — the workaround from gotcha 1 — isn’t plain POSIX cron. An expression that works in one scheduler isn’t guaranteed to be understood by the crontab on the box. - Ranges are inclusive.
9-17in the hour field is 9am through 5pm inclusive — nine hours, not eight. Off-by-one here is quiet: the job runs, just for an hour longer than you budgeted.
If the basics of *, ,, - and / are what’s biting you — including why */40 doesn’t give you a clean 40-minute cycle, and whether Sunday is 0 or 7 — those are covered in cron expressions explained.
The safest habit is to never trust an expression you wrote from memory. Paste it into the Cron Expression Builder, read the plain-English translation back, and check the list of actual next fire times. If the English sentence doesn’t match your intent, or the dates look wrong, you’ve caught the bug at your desk instead of in an incident channel.
Verify, don’t assume
Cron’s terseness is exactly why it’s error-prone: five characters can encode a schedule that’s off by a day, an hour, or an entire OR-clause, with nothing to warn you. The defence is the same every time — make the schedule concrete before you deploy it. Translate the expression to English, project its next several fire times in the correct timezone, scroll across a DST boundary if timing is critical, and add a lock if a run could ever outlast its interval. The Cron Expression Builder does the first two for you; the Unix Timestamp Converter helps when you’re reconciling those fire times against log entries and epoch fields, and the Time Zone Overlap Calculator is handy when a scheduled job has to line up with people in other regions. Get the schedule right on paper, and cron will do exactly what you meant — which, after all, was the point.
Try the tools from this guide
-
Cron Expression Builder & Translator
Build cron expressions visually and translate any cron string to plain English with the next 5 fire times.
-
Unix Timestamp Converter (with Timezone)
Bidirectional Unix timestamp ↔ human date converter with timezones, milliseconds and ISO 8601.
-
Time Zone Overlap Calculator (Remote Teams)
Find overlapping productive hours across up to 8 time zones for distributed teams.