<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: AI Dev Hub</title>
    <description>The latest articles on DEV Community by AI Dev Hub (@aidevhub).</description>
    <link>https://dev.to/aidevhub</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3769170%2F51b2c1be-6090-4a70-b86f-000759e46929.png</url>
      <title>DEV Community: AI Dev Hub</title>
      <link>https://dev.to/aidevhub</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aidevhub"/>
    <language>en</language>
    <item>
      <title>5 cron expression mistakes I made before reaching for a builder</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Sun, 19 Apr 2026 14:11:59 +0000</pubDate>
      <link>https://dev.to/aidevhub/5-cron-expression-mistakes-i-made-before-reaching-for-a-builder-4aa7</link>
      <guid>https://dev.to/aidevhub/5-cron-expression-mistakes-i-made-before-reaching-for-a-builder-4aa7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The 5 mistakes below are the ones I keep making. None of them are exotic. All of them passed code review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 1: assuming &lt;code&gt;*/5&lt;/code&gt; means "exactly 5 minutes after the last fire"
&lt;/h2&gt;

&lt;p&gt;Spoiler: it doesn't. &lt;code&gt;*/5 * * * *&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;This bit me on a sync job last September. I was testing it manually with &lt;code&gt;launchctl start&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;The lesson: &lt;code&gt;*/N&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 2: day-of-week and day-of-month are OR, not AND
&lt;/h2&gt;

&lt;p&gt;This one is actually documented but I never read the docs. The expression &lt;code&gt;0 9 1 * 1&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;
&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;# fire only if it's the first Monday of the month
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;run_billing_job&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skipping; not first Monday of the month&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Mistake 3: forgetting that launchd is local time, not UTC
&lt;/h2&gt;

&lt;p&gt;This is a Mac-specific gotcha but it caught me last month. macOS &lt;code&gt;launchd&lt;/code&gt; uses local time for &lt;code&gt;StartCalendarInterval&lt;/code&gt;. If your plist says &lt;code&gt;Hour=14&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 4: cron does not catch up
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;StartInterval&lt;/code&gt; instead of &lt;code&gt;StartCalendarInterval&lt;/code&gt; (interval-based, fires on wake), or use a tool with persistent scheduling. I picked the second option for anything I actually care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 5: the build-it-by-hand fallacy
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I built the free tool myself at &lt;a href="https://aidevhub.io/cron-builder/" rel="noopener noreferrer"&gt;aidevhub.io/cron-builder&lt;/a&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the 5 expressions translated
&lt;/h2&gt;

&lt;p&gt;For reference, here's what each of my 5 mistakes should have been written as.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  When raw cron is still fine
&lt;/h2&gt;

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

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

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Why is the day-of-week / day-of-month thing an OR?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; It's a POSIX thing. The original spec says if either field is restricted (not &lt;code&gt;*&lt;/code&gt;), 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does this work for AWS EventBridge cron expressions?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; 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.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q: How do I test a cron expression without waiting?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; 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 &lt;code&gt;croniter&lt;/code&gt; for Python and &lt;code&gt;cron-parser&lt;/code&gt; for Node, both of which let you iterate the next N firings programmatically. I write a one-line script when I'm not sure: &lt;code&gt;python3 -c "from croniter import croniter; from datetime import datetime; c=croniter('0 9 * * 1'); [print(c.get_next(datetime)) for _ in range(5)]"&lt;/code&gt;. If the printed times look right, the expression is right.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing about timezone math
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;0 9 * * *&lt;/code&gt; 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.&lt;/p&gt;

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

&lt;p&gt;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".&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance and human review.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devtools</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
