Five fields, twenty ways to misread them. Here's what the spec actually says, what Vixie cron assumes, and which combinations will silently fire at the wrong time — or not at all.

You spend twenty minutes on a cron expression, validate it in a simulator, deploy, and then — at 3am — it fires at the wrong time. Or doesn't fire. Or fires every minute because a wildcard ended up in the wrong column. The syntax is five fields and a command. How hard can it be? Apparently hard enough that it has been causing on-call incidents since 1975, when Vixie cron first shipped on V7 Unix. Every item below has paged someone in the past year. Check your crontabs against this before you go to sleep.

1. Field order — and what "1" means in each position

The fields are: minute, hour, day-of-month, month, day-of-week. The mnemonic "M-H-D-M-W" works, or "Man Hour Drank More Whiskey" if you need something stickier. Most people know the order. They still get it wrong at 4am when they're writing a report schedule from memory.

0 9 * * 1   # 09:00am every Monday                ← what you wanted
9 0 * * 1   # 00:09am every Monday                ← what you wrote
0 9 * 1 *   # 09:00am every day in January only   # also not what you wanted

The month field is 1–12 (January = 1), not 0–11 like the JavaScript Date API that trained your fingers. Day-of-week is 0–7, where both 0 and 7 map to Sunday on Vixie cron, cronie, and the BSDs — but some implementations reject 7, so use 0 to be safe. Writing 5 in the day-of-week column means Friday. Writing 5 in the month column means May. The columns don't announce which one they are.

Before committing, paste any non-trivial expression into a tool that can explain it back to you. The cron parser here does exactly that — it shows you the next five fire times in plain English so you can see whether "9am Monday" is actually what you wrote.

2. The day-of-month + day-of-week OR trap

This one is in the POSIX spec. Almost nobody knows it until it burns them.

"If both fields are not *, a match occurs when both the current day of the month matches the day-of-month field and the current day of the week matches the day-of-week field" — except that is the non-POSIX reading. The actual POSIX behavior: when neither field is *, the command runs when either matches.

Wikipedia, "Cron — POSIX syntax" (CC BY-SA 4.0)

Concretely: 0 0 1 * 1 fires at midnight on the 1st of every month — and at midnight every Monday. Not "on the first Monday of the month." OR, not AND. If you want the first Monday of the month, cron can't express it directly. The standard workaround:

0 0 * * 1 [ $(date +\%d) -le 7 ] && your-command

It's ugly. Cron was designed in 1975. Some of the decisions reflect that vintage.

3. Step values — what ÷n actually divides

*/5 in the minutes field means "at minutes 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55." That's every 5 minutes, starting from zero. It's not "5 minutes after the previous run ended" — if your job takes 3 minutes to execute and runs at :00, the next trigger is :05 regardless of when it finished.

The step divides the entire valid range, beginning at the range's minimum. */7 in minutes gives you 0, 7, 14, 21, 28, 35, 42, 49, 56 — nine fires per hour, with an irregular gap at :56→:00. It's not "every 7 minutes"; it's "at each multiple of 7 within 0–59." You can constrain the range explicitly: 0-30/5 fires every 5 minutes from :00 through :30 only.

The most common accident: */60 in the minutes field. It maps only to minute 0, so the job fires once per hour — which may be exactly what you wanted, but if you thought you were expressing "every 60 seconds," cron has no seconds field at all. The minimum scheduling resolution is one minute.

4. Timezone blindspot — cron has no idea you're in Berlin

A cron daemon runs in the system timezone, which on any properly-configured server is UTC. 0 9 * * * fires at 09:00 UTC — which is 10:00 CET in winter and 11:00 CEST in summer. Whether it runs at the time a stakeholder in Munich expects depends entirely on which half of the year you're in.

DST transitions create two failure modes. When clocks spring forward, the skipped hour simply doesn't exist — any job scheduled in that window won't run. When clocks fall back, the repeated hour can fire twice. Most cron implementations don't handle either case gracefully; they just follow the system clock.

The clean fix: run servers in UTC, schedule everything in UTC, and annotate every human-facing time with its UTC offset in a comment.

0 8 * * *  report.sh   # 08:00 UTC = 09:00 CET / 10:00 CEST

The timestamp converter makes UTC↔local conversion quick when you're writing schedules for distributed teams — paste in a local time and it hands you the UTC equivalent without mental arithmetic. Some modern cron implementations (Vixie cron 3.0+, fcron) support a per-crontab TZ=Europe/Berlin line; if yours does, use it — it's far cleaner than computing offsets in your head.

5. The PATH problem — why jobs fail silently in production

Cron runs with a minimal environment. PATH is typically /usr/bin:/bin. Your shell profile's additions — /usr/local/bin, ~/.local/bin, Homebrew's prefix, any virtualenv activation — are invisible. A command that works perfectly when you run it interactively will produce "command not found" in cron, logged nowhere visible.

That last part is the real problem. By default, cron mails output to the local mail spool. If you're not running a mail daemon — and most dev servers aren't — the output goes nowhere. Jobs fail silently. You find out because the report that was supposed to run every morning hasn't arrived in six weeks.

Three fixes, applied together:

If your job depends on a pattern match or path manipulation in the shell, test the regex in the regex tester before encoding it in a crontab line where debugging feedback is minimal.

6. The 1-minute floor — and when to graduate

Standard cron can't schedule anything shorter than one minute. There's no seconds field. If you need "run every 30 seconds," you're outside cron's scope.

Two common but wrong workarounds people try: * * * * * sleep 30 && command — this delays one execution by 30 seconds, not schedules a second one. Two crontab lines 30 seconds apart isn't expressible at all in standard syntax.

Real alternatives: systemd timers support OnCalendar with second precision and give you dependency ordering, failure tracking, and journalctl integration. Python's APScheduler handles sub-minute intervals inside long-running processes. For orchestrated pipelines, Airflow and Prefect give you proper retry logic, monitoring, and audit history instead of a flat file nobody re-reads after it's written.

If you're fighting cron to schedule something every few seconds, the right move is to pick a real scheduler — not to contort the syntax further. The cron builder covers everything within standard cron's range; for anything outside it, the builder won't let you express it, which is a feature.

← All articles