6 crontab pitfalls: weekday-OR-day-of-month, step ranges, timezones, DST, no seconds, and `@reboot`

4 min read

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 CronJob has spec.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 OnCalendar works.
  • 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:

  1. Are both day-of-month and day-of-week specified? (That’s OR, not AND.)
  2. Does any step expression have an implicit range that isn’t what you meant?
  3. Is the timezone correct (CRON_TZ, K8s timeZone, etc.)?
  4. Avoiding DST transition hours?
  5. Need sub-minute precision? Use systemd timer or Quartz, not POSIX cron.
  6. 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.