DEV Community

Cover image for 5 cron expression mistakes I made before reaching for a builder
AI Dev Hub
AI Dev Hub

Posted on

5 cron expression mistakes I made before reaching for a builder

Cron is one of those tools where the syntax looks obvious until you read your own expression a week later and have no idea what it does. Here are 5 mistakes I made in production code over the last year. Each one fired at a time I did not expect, and the fix was always the same: write it visually first, then translate to the 5-field expression.

Quick note up front: the cron builder I link to below is something I built. After years of writing 5-field expressions by hand and hitting most of the mistakes in this post, I wanted a tool that showed me the next 5 fire times in my actual local timezone before I committed. I wrote it for me as part of the AI Dev Hub toolbox. It's free, client-side, no signup. Linking to it because it's what I actually use and I hope it's valuable for you too.

I think most of us initially learned cron the same way. You copy something off Stack Overflow that looks close to what you want, you tweak a number, you commit it, and then 3 days later something happens at the wrong time and you start digging. I've done this enough times that I now keep a builder open in a tab whenever I touch a cron job, even for one I think I know cold.

The 5 mistakes below are the ones I keep making. None of them are exotic. All of them passed code review.

Mistake 1: assuming */5 means "exactly 5 minutes after the last fire"

Spoiler: it doesn't. */5 * * * * means "every minute whose value is divisible by 5", which fires at :00, :05, :10, etc. If you load the job at :07 and expect the next fire at :12, you're going to be wrong by 2 minutes. The next fire is at :10.

This bit me on a sync job last September. I was testing it manually with launchctl start, then waiting for the "next fire" to confirm the schedule was working, and the timing felt random. Turns out the timing was exactly right. My mental model was wrong.

The lesson: */N is anchored to the field's natural origin, not to whenever you loaded the job. For minutes that's :00. For hours it's midnight.

Mistake 2: day-of-week and day-of-month are OR, not AND

This one is actually documented but I never read the docs. The expression 0 9 1 * 1 does NOT mean "the 1st of the month, but only if it's a Monday". It means "the 1st of the month OR every Monday". So it'll fire on the 1st of every month and also on every Monday.

I wanted "first Monday of the month" for a billing job and what I shipped fired about 5 times more often than I expected. Caught it on the first weekly invoice that went out 4 days early. The customer was nice about it.

There's no way to express "AND" between those two fields in standard cron. You either pick one and filter inside the script, or you use a more expressive scheduler. I pick the script-side filter every time now:

import datetime as dt
now = dt.datetime.now()
# fire only if it's the first Monday of the month
if now.weekday() == 0 and now.day <= 7:
    run_billing_job()
else:
    log.info("skipping; not first Monday of the month")
Enter fullscreen mode Exit fullscreen mode

The cron expression becomes 0 9 * * 1 (every Monday at 9am) and the script handles the "first" part. Two pieces of logic, each obvious on its own.

Mistake 3: forgetting that launchd is local time, not UTC

This is a Mac-specific gotcha but it caught me last month. macOS launchd uses local time for StartCalendarInterval. If your plist says Hour=14, it'll fire at 14:00 wherever the Mac is. I had a job I expected to fire at 14:00 UTC because that's what the cron equivalent meant on the Linux server it was migrated from. It was firing at 14:00 Berlin time, which is 12:00 or 13:00 UTC depending on daylight saving.

If you want UTC behavior on launchd, you have to either set the system to UTC, or compute the UTC-equivalent local hour and update it twice a year for DST. There is no built-in "interpret as UTC" flag. I put a comment at the top of every plist now reminding me.

Mistake 4: cron does not catch up

If your laptop is asleep at the scheduled time, the job does NOT fire when the laptop wakes. Cron has no built-in catch-up. If your job is "delete files older than 30 days" and you sleep through 3 firings, it just runs once when the next scheduled time arrives. The "missed" firings are gone.

I had a backup job that I assumed was running daily because I never saw any errors. Turns out my Mac was asleep most nights at the firing time and the job had run maybe 8 times in 30 days. The fix on launchd is StartInterval instead of StartCalendarInterval (interval-based, fires on wake), or use a tool with persistent scheduling. I picked the second option for anything I actually care about.

Mistake 5: the build-it-by-hand fallacy

The single biggest mistake is thinking I'll get the expression right by hand on the first try. After all 4 of the above, I now build cron expressions visually in a tool first, paste the result, and add a one-line comment explaining what it does. Takes 30 seconds and saves the "what does this fire on?" archaeology session every time.

I built the free tool myself at aidevhub.io/cron-builder. Pick days, hours, minutes from dropdowns, get the expression. It also shows the next 5 fire times in your local timezone, which is the part I find most useful because it catches the "this won't actually fire when you think" cases before they ship.

How the 5 expressions translated

For reference, here's what each of my 5 mistakes should have been written as.

What I wanted What I wrote What it actually does What I should have written
Every 5 minutes from now */5 * * * * Every :00, :05, :10... Same expression, accept the alignment
First Monday of month at 9am 0 9 1 * 1 1st of month OR every Monday at 9am 0 9 * * 1 plus script-side date check
14:00 UTC daily launchd Hour=14 14:00 Berlin local Hour=15 in winter, Hour=14 in summer (or ditch launchd)
Daily backup at 3am 0 3 * * * cron OR Hour=3 plist Skips firings when machine is asleep StartInterval=86400 or use a scheduler with catch-up
Anything moderately complex Hand-typed Usually wrong on the first try Paste from a builder

When raw cron is still fine

I'm not saying never write cron by hand. For "every minute" (* * * * *) or "every hour at the top" (0 * * * *) it's faster to just type it. The break point for me is anything involving more than one non-* field. Two fields with values is where my error rate spikes.

Also worth knowing: most cron implementations support extensions that aren't in POSIX. @daily, @weekly, @reboot all exist in Vixie cron and are honestly easier to read than the equivalent expressions. If your environment supports them, use them.

FAQ

Q: Why is the day-of-week / day-of-month thing an OR?
A: It's a POSIX thing. The original spec says if either field is restricted (not *), they're OR-ed. There's a footnote in the man page if you want to read it. Most cron tutorials skip this because it's a footgun.

Q: Does this work for AWS EventBridge cron expressions?
A: AWS uses a 6-field cron syntax with year, and the day-of-week / day-of-month rule is AND there, not OR. So if you're going EventBridge, this specific gotcha goes away. The other 4 mistakes still apply.

Q: Is there a cron syntax that's better than the 5-field one?
A: Quartz scheduler's syntax is more expressive (seconds, year, AND between day fields). Some Linux distros ship systemd.timer which is way more readable but is its own thing. Pick whatever your platform supports best.

Q: How do I test a cron expression without waiting?
A: A few options. The fastest is a builder that shows you the next 5 fire times so you can eyeball whether the schedule matches your intent. Beyond that, there's croniter for Python and cron-parser for Node, both of which let you iterate the next N firings programmatically. I write a one-line script when I'm not sure: python3 -c "from croniter import croniter; from datetime import datetime; c=croniter('0 9 * * 1'); [print(c.get_next(datetime)) for _ in range(5)]". If the printed times look right, the expression is right.

One more thing about timezone math

A cron expression has no timezone embedded in it. The interpretation is whatever the scheduler runs in. If you set up a job in Berlin, deploy the code to a server in US-East, and the server runs cron in UTC, your 0 9 * * * will fire at 9am UTC, which is 10am or 11am Berlin time depending on DST. This is extremely easy to miss in code review because the expression itself looks fine.

The fix I use: store the intended timezone alongside the expression, and have the scheduler convert. Most modern schedulers (including launchd via StartCalendarInterval plus a wrapper) can do this. For raw cron on Linux, you can often set CRON_TZ=Europe/Berlin at the top of the crontab file and the entries below it will be interpreted in that timezone. Documented but obscure.

I add a comment to every cron entry now that says what timezone I expect it to fire in. Adds 3 seconds to writing the entry and saves me the timezone-archaeology session that always comes a month later when something fires "wrong".


Written with AI assistance and human review.

Top comments (0)