6 crontab pitfalls: weekday-OR-day-of-month, step ranges, timezones, DST, no seconds, and `@reboot`
cron expressions are deceptively simple — five numbers and you’re done. The pitfalls hide in the details: AND/OR semantics between fields, step-with-range interactions, timezone, DST. Almost all cron mistakes come from skipping the spec text. This article walks through six traps that actually break production schedules.
1. The day-of-month / day-of-week OR (the worst trap)
cron’s five fields:
minute hour day-of-month month day-of-week To run on the third Monday, the natural-feeling expression is:
0 5 15-21 * 1 # "15-21 of the month" AND "Monday (=1)" — intended But that’s evaluated as an OR, not AND. When both day-of-month and day-of-week are non-*, the job runs whenever either matches:
- Days 15-21 of any month (any weekday) → runs
- Every Monday (any date) → runs
You’d get 11-12 runs per month, not 1.
What the spec actually says
POSIX cron / Vixie cron:
If both fields are restricted (i.e., aren’t
*), the command will be run when either field matches the current time.
Fix
Put the date logic in a shell guard:
0 5 15-21 * * test $(date +%u) -eq 1 && /path/to/job (Note \% — % is special in crontab and must be escaped.) Don’t try to express it in cron syntax alone; use cron + shell test for “Nth weekday of month.”
2. Step expressions and range starting points
*/5 reads as “every 5 minutes.” But step is ”from the range minimum, in steps of N“:
*/5 * * * * # 0, 5, 10, ... 55 minutes (works) Add a range and the meaning changes:
5/15 * * * * # 5 then 15-step → 5, 20, 35, 50 (NOT 5, 20, 35, 50, 65 — caps at 59)
0-30/10 * * * * # 0, 10, 20, 30 (capped by the range, not by 59) If you intended “every 15 minutes starting at minute 5,” the result clusters near the end of each hour. Without an explicit range, * means 0-59.
Fix
- Keep step expressions range-less:
*/15 * * * *. - If you do use a range, dry-run the expression to verify the actual fire times.
3. Timezones
cron daemons run in server localtime by default. If the server is UTC and you want JST 9:00, writing 0 9 * * * fires at UTC 9:00 = JST 18:00.
Fix
- For UTC-server with JST 9:00 intent:
0 0 * * *(UTC 0 = JST 9). - Most modern cron implementations support per-entry timezone:
CRON_TZ=Asia/Tokyo
0 9 * * * /path/to/job - Kubernetes
CronJobhasspec.timeZone(GA since 1.27). - AWS EventBridge / Google Cloud Scheduler take an explicit timezone field.
Without timezone awareness, you get unpredictable shifts at DST boundaries.
4. DST boundary: double-runs and skipped runs
In timezones with DST (most US/EU, not Japan), a job at 02:30:
- Spring forward: 02:00 jumps to 03:00 — 02:30 does not run.
- Fall back: 02:30 occurs twice — the job runs twice.
For idempotency-sensitive work (subscription billing, end-of-day report), this breaks once or twice a year.
Fix
- Don’t schedule jobs in the DST transition window (typically 02:00-04:00). Use 06:00 or 12:00.
- If you must, make the job idempotent so a duplicate run is harmless.
5. There is no “seconds” field
POSIX cron’s smallest unit is the minute. Writing * * * * * * (six fields) is a syntax error in most cron implementations. Sub-minute alternatives:
- systemd timer: per-second
OnCalendarworks. - Quartz cron (Java / Spring): 6-7 fields including seconds and year.
- AWS EventBridge / Google Cloud Scheduler: 5 fields, no seconds.
Each “cron with seconds” implementation has its own dialect. Read your specific tool’s docs — Spring Boot’s @Scheduled accepts a 6-field Quartz-like format but isn’t strictly Quartz.
6. Special strings and their portability
cron has human-readable shorthand:
@yearly # 0 0 1 1 *
@monthly # 0 0 1 * *
@weekly # 0 0 * * 0
@daily # 0 0 * * *
@hourly # 0 * * * *
@reboot # once at boot @reboot semantics vary:
- Vixie cron / cronie: runs once at boot.
- systemd-cron: implementation-dependent.
- dcron: not supported.
A common surprise: “wrote a @reboot job, dockerized the app, and the container had no cron daemon — the job never runs.” Containers should use the init system’s restart: always or a systemd OneShot=true unit instead.
Checklist
When reviewing a cron expression:
- Are both day-of-month and day-of-week specified? (That’s OR, not AND.)
- Does any step expression have an implicit range that isn’t what you meant?
- Is the timezone correct (CRON_TZ, K8s timeZone, etc.)?
- Avoiding DST transition hours?
- Need sub-minute precision? Use systemd timer or Quartz, not POSIX cron.
- Special strings (
@reboot) supported in the target environment?
Summary
cron pitfalls come from misreading the spec’s edge cases. The day-of-month/day-of-week OR is the most universally surprising — it’s documented in man crontab(5) exactly as observed, but the natural-feeling reading is AND. Run the expression through a checklist before deploying, and dry-run the actual fire times before relying on a new schedule.
To verify expression parsing and the next fire time, use the cron expression tool.