DEV Community

Cover image for 5 cron expression gotchas that catch experienced devs in 2026
AI Dev Hub
AI Dev Hub

Posted on

5 cron expression gotchas that catch experienced devs in 2026

Cron is one of those tools where the syntax looks obvious until a job fires at the wrong time and you start digging. Five behaviors below are documented in the man page and still catch people who've been writing cron for years. Each one is in a footnote most tutorials skip.

Quick disclosure on this one: the cron builder I link to below is something I built. After enough years of writing 5-field expressions by hand, I wanted a tool that showed me the next 5 fire times in my actual local timezone before I committed. Free, client-side, no signup. Linking to it because it's the workflow I use now.

I think most devs learn 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 a few days later something fires at the wrong time and you start reading the man page properly. The 5 behaviors below are the ones I see trip people up over and over. None are exotic. All are documented. All pass code review.

Gotcha 1: */5 is anchored to the field origin, not to "now"

*/5 * * * * does not mean "every 5 minutes from whenever the job loaded". It means "every minute whose value is divisible by 5". So it fires at :00, :05, :10, :15, etc. If you load the job at :07 and expect the next fire 5 minutes later, you'll see the next fire at :10, not :12.

The same rule applies to every field. 0 */6 * * * fires at 00:00, 06:00, 12:00, 18:00, anchored to midnight. Not to whenever you started the scheduler.

This is the right behavior for most use cases (predictable, aligned across machines) but it's not what people often expect on the first read. The lesson: */N is anchored to the field's natural origin, never to the load time.

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

This one is in the POSIX spec and almost nobody reads it. The expression 0 9 1 * 1 does NOT mean "the 1st of the month, but only if it's a Monday". It means "at 9am on the 1st of every month, OR on every Monday". So it fires roughly 5 times more often than the AND interpretation would suggest.

There's no way to express AND between those two fields in standard POSIX cron. Two common workarounds:

import datetime as dt

# Cron fires every Monday. Script filters down to "first Monday of the month".
now = dt.datetime.now()
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

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

The other workaround is to switch to a scheduler that supports AND between those fields. Quartz syntax (used by AWS EventBridge and many JVM schedulers) treats them as AND when both are non-*. Different platform, different rule. Worth knowing which one you're on.

Gotcha 3: launchd reads local time, not UTC

This is a Mac-specific gotcha and it's caused enough confusion that I now put a comment at the top of every plist. macOS launchd interprets StartCalendarInterval in the system's local timezone. If your plist has Hour=14, the job fires at 14:00 wherever the Mac thinks it is. There is no built-in "interpret as UTC" flag.

If you're migrating a cron job from a Linux server (where cron typically runs in UTC unless configured otherwise) to launchd on a Mac in another timezone, the job will fire at a different absolute time. The expression looks identical. The behavior isn't.

Two ways to fix it on launchd:

  1. Set the system clock to UTC. Works if you control the machine and don't mind the rest of the OS displaying UTC times.
  2. Compute the UTC-equivalent local hour and update it twice a year for daylight saving. Less elegant but doesn't change anything else on the system.

I pick option 2 with a comment in the plist that says "fires at 13:00 UTC; adjust for DST in March and October". Ugly, but explicit, which is what you want when you read the file 6 months later.

Gotcha 4: cron does not catch up missed firings

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

This is a portable laptop problem more than a server problem. A server that's always on rarely misses. A Mac that sleeps overnight can easily miss its 3am job most nights and never log an error, because there's no error to log. The job didn't fail. It just wasn't fired.

The fix on launchd is StartInterval (interval-based, fires on wake) instead of StartCalendarInterval (clock-time, no catch-up). Or you use a tool with persistent scheduling that does catch up: anacron is the classic Linux answer, cronie with crond -P works similarly, and various job runners (systemd timers with Persistent=true, etc.) handle this natively.

I default to interval-based scheduling for anything maintenance-shaped (backups, cleanup, log rotation) where the exact time matters less than "did it run today". Calendar-based scheduling for anything time-sensitive (a daily 9am email) where running at 11am after the laptop wakes would be wrong.

Gotcha 5: a cron expression has no timezone embedded in it

This is the one that bites distributed teams. The expression 0 9 * * * says "at 9:00 in whatever timezone the scheduler runs in". It doesn't say UTC. It doesn't say Berlin. It says "whatever the scheduler thinks 9:00 is".

If you write the expression in Berlin, deploy the code to a server in US-East, and that server's cron runs in UTC, your job fires at 9:00 UTC, which is 10:00 or 11:00 Berlin time depending on the season. The expression looks fine in code review. The behavior is wrong.

A few things help:

  • For Linux cron, CRON_TZ=Europe/Berlin at the top of the crontab file pins all subsequent entries to that zone. Documented in man 5 crontab. Easy to miss.
  • For Quartz-based schedulers, the timezone is usually a separate config field (timeZone in Spring's @Scheduled, for example).
  • For launchd, you compute it yourself or set the system clock.

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 the timezone-archaeology session that always comes a month later.

How I'd write each of these now

For reference, here's how each gotcha translates to a defensible expression.

Goal Naive attempt What it actually does Defensible version
Every 5 minutes from now */5 * * * * Fires at :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 on launchd Hour=14 in plist 14:00 in local timezone, not UTC Compute local hour, comment with intended zone
Daily backup at 3am 0 3 * * * cron OR Hour=3 plist Skips firings when machine is asleep StartInterval=86400 or use a catch-up scheduler
Anything moderately complex Hand-typed Often wrong on the first try Build visually, paste, comment what it fires on

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 with more than one non-* field. Two fields with values is where my error rate spikes and the cost of building visually is zero.

Worth knowing: most cron implementations support extensions that aren't in POSIX. @daily, @weekly, @reboot, @hourly all exist in Vixie cron and read better than the equivalent expressions. If your environment supports them, prefer them. They're more readable to whoever opens the file in 2027.

The free cron builder I made and use regularly now is at aidevhub.io/cron-builder. Pick days, hours, minutes from dropdowns, get the expression, see the next 5 fire times in your local timezone. The next-fire preview is the part I find most useful, because it catches the "this expression doesn't actually fire when I think it does" cases before they ship.

FAQ

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

Q: Does this work for AWS EventBridge cron expressions?
A: EventBridge 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, that specific gotcha goes away. The other 4 still apply. EventBridge also requires you to use ? in one of the two day fields, which is its own kind of footgun.

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). Most Linux distros ship systemd.timer which is way more readable but is its own thing. Pick whatever your platform supports best. I find systemd timers the cleanest for new Linux work and stick with launchd for Mac because the alternatives aren't worth the friction.

Q: How do I test a cron expression without waiting?
A: Easiest path is a builder that shows the next 5 fire times so you can eyeball whether the schedule matches your intent. Beyond that, croniter for Python and cron-parser for Node both 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.

Q: What about Quartz cron expressions?
A: Different beast. 6 or 7 fields (seconds optional, year optional), ? placeholder for day fields, L for last, # for nth-day-of-month. More expressive, less portable. If you're on a Quartz-based stack you're already in a different syntax and most of the POSIX gotchas above don't apply.


Written with AI assistance and human review.

Top comments (0)