Every backend eventually grows a cron job. Backups at 2 AM, digests at 8 AM, health checks every five minutes. And every team eventually hits the same wall: the schedule that fires at a time nobody asked for, in a timezone nobody configured, on a day-of-week nobody intended.
Cron syntax is five fields. It fits on a sticky note. The bugs do not come from the syntax — they come from the three places where different implementations disagree with each other. This is a field guide to all of it.
The five fields, in one breath
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12 or JAN-DEC)
│ │ │ │ ┌───────────── day of week (0-6 or SUN-SAT; 7 = Sunday too)
* * * * * command
Four operators cover 99% of real expressions:
-
*— every value in the field -
,— list:6,18in hours = 6 AM and 6 PM -
-— range:MON-FRI -
/— step:*/15in minutes = every 15 minutes
One subtlety worth internalizing: */15 starts at the field's zero (0, 15, 30, 45), but 5/15 starts at 5 (5, 20, 35, 50). The start value shifts the whole sequence — this is the difference between "aligned" and "offset" intervals, and it matters when you are spreading load across workers.
The trap: day-of-month OR day-of-week
Here is the bug I have now seen three times in production, always from a competent engineer, always written down as "cron is weird."
The expression 0 0 1 * 1 looks like it means "midnight on the first of the month, if it's a Monday."
In standard Unix cron, it means "midnight on the first of the month AND midnight on every Monday." The relationship is OR, not AND. That job fires ~48 times a year instead of ~1.
The rule, straight from the Vixie cron man page: when both the day-of-month and day-of-week fields are restricted (neither is *), the job runs when either matches. To get true AND logic you need Quartz-style syntax (0 0 1 ? * 1, where ? means "no specific value") or a guard in the script itself.
This single rule explains most "my cron job runs at the wrong time" mysteries. If your target platform is Quartz, Spring, or Jenkins, check their docs — most treat the two day fields differently from Unix cron, which is exactly why the bug survives code review: it worked on the engineer's machine, and their machine was running a different scheduler.
The L, W, and # extensions (and where they get you fired)
Modern schedulers — Quartz, Spring, AWS EventBridge, Kubernetes — support three extensions beyond classic cron:
-
L— last:0 0 L * *= last day of the month;0 0 * * 5L= last Friday -
W— nearest weekday:0 0 15W * *= weekday closest to the 15th -
#— nth weekday:0 0 * * 1#3= third Monday
These are genuinely useful for monthly report jobs. They are also silently invalid in classic Vixie cron — the daemon treats them as syntax errors. Copy an L expression from a Quartz tutorial into /etc/cron.d/ and your job simply never runs. No warning, no email, just silence.
The portability rule: know your target before you copy the expression. The quick reference:
| Platform | Fields | L/W/# | Timezone |
|---|---|---|---|
| Unix crontab | 5 | no | system |
| Quartz / Spring | 6-7 | yes | JVM |
| Kubernetes CronJob | 5 | yes | node (or timeZone) |
| AWS EventBridge | 6 | yes | UTC |
| GitHub Actions | 5 | no | UTC |
Timezone and DST: the silent schedule-shifters
Two production incidents waiting to happen:
1. UTC defaults in the cloud. "0 9 * * " means 9 AM in *the scheduler's timezone. On GitHub Actions and AWS EventBridge, that is UTC. Your 9 AM daily report for the New York office goes out at 5 AM local. Nobody notices for a week because 5 AM email is just "early," until someone checks timestamps during a debugging session.
2. Daylight saving transitions. A job scheduled inside the spring-forward hour (e.g., 2:30 AM in a US timezone) does not run at all that day — the wall-clock time never happens. During fall-back, a job in the repeated hour runs twice. If "runs exactly once per day" is a billing or audit requirement, that's a defect. The standard fix: schedule critical jobs in UTC, or outside DST transition windows.
The general principle: for anything with an external SLA (backups, billing, cert renewal), write the cron expression in UTC and document that it is UTC. For anything user-facing, use a scheduler that supports explicit timezones and set it deliberately.
Six expressions worth memorizing
You will type these for the rest of your career:
-
0 2 * * *— nightly at 2 AM (backups, the classic low-traffic window) -
*/5 * * * *— every 5 minutes (health checks, watchdogs) -
0 9 * * MON-FRI— weekday mornings (reports, digests) -
15 4 * * 0— Sunday 4:15 AM (log rotation, cleanup) -
0 */2 * * *— every 2 hours (syncs, cache refresh) -
0 0 1 * *— first of the month (invoices, archives)
And the debug checklist when a job "didn't run": (1) is the daemon alive? (2) is the expression valid for this scheduler? (3) is the timezone what you think it is? (4) did a DST boundary swallow it? (5) check the scheduler's own log, not just your application log. In my experience the answer is (3) about half the time.
When I'm porting a schedule between platforms I run it through a free Cron Expression Generator first — it parses the expression, describes it in plain English, and shows the next 10 run times, so a UTC bug or an OR-logic trap is visible before it ships. It runs entirely in the browser. There's a longer reference writeup of everything above, including the full platform table, in the Cron Expressions Guide.
What's the worst scheduling bug you've shipped? Bonus points if the root cause was a timezone.
Top comments (0)