<?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>Forem: AI Dev Hub</title>
    <description>The latest articles on Forem by AI Dev Hub (@aidevhub).</description>
    <link>https://forem.com/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>Forem: AI Dev Hub</title>
      <link>https://forem.com/aidevhub</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/aidevhub"/>
    <language>en</language>
    <item>
      <title>How token counters actually work in 2026, and when to trust them</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Wed, 29 Apr 2026 17:58:09 +0000</pubDate>
      <link>https://forem.com/aidevhub/how-token-counters-actually-work-in-2026-and-when-to-trust-them-20jj</link>
      <guid>https://forem.com/aidevhub/how-token-counters-actually-work-in-2026-and-when-to-trust-them-20jj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Most "free token counter" tools in your bookmarks are not running the model's tokenizer. They're running a character-ratio estimate and labeling the output "tokens". For OpenAI's GPT family the official tokenizer is open and easy to ship in a browser. For Claude, Gemini, and most others it isn't. Here's what that means for your context-window math.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Up-front disclosure on this one: the tool I link to below is one I built. I got tired of paste-counter-paste-counter loops where the same input produced different numbers, and tired of tools that claim to support every model but quietly use one tokenizer for all of them. Free, client-side, no signup. I'm linking to it because it's what I use, and because I'd rather show you how it works than pitch it.&lt;/p&gt;

&lt;p&gt;If you've ever opened three "GPT token counter" tabs and gotten three different numbers, you're not crazy and the tools aren't all wrong. They're doing different things and labeling them the same way. Knowing which is which makes the difference between "this prompt fits" and "the API will reject it at the boundary".&lt;/p&gt;

&lt;h2&gt;
  
  
  What "tokenization" actually does
&lt;/h2&gt;

&lt;p&gt;A tokenizer takes raw text and splits it into the integer IDs the model actually consumes. Every model family ships its own vocabulary, trained on its own corpus. Same input string yields different token counts because the vocabularies differ.&lt;/p&gt;

&lt;p&gt;OpenAI's GPT-4 family uses an encoding called &lt;code&gt;cl100k_base&lt;/code&gt;. The newer GPT-4o, GPT-5, o3 and o4 models use &lt;code&gt;o200k_base&lt;/code&gt;, a larger vocabulary tuned for multilingual and code-heavy input. Anthropic's Claude family uses its own vocabulary that's published only as a server-side counting endpoint. Google's Gemini family is similar: server-side counting, no public local tokenizer at the time of writing (April 2026).&lt;/p&gt;

&lt;p&gt;The rule of thumb people quote, "1 token is about 4 characters of English", is fine for napkin math and wrong by 10 to 20 percent on real input. German tokenizes worse than English because compound words don't fit the English-trained vocabulary. Code with many short identifiers tokenizes better than prose. Emoji are usually 2 to 4 tokens each. JSON with verbose keys tokenizes much worse than minified JSON. If you're sitting near the context window, the rule of thumb will lie to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exact vs estimated, the real divide
&lt;/h2&gt;

&lt;p&gt;Free token counters fall into two camps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exact counters&lt;/strong&gt; ship the model's actual tokenizer in the browser and run it on your input. The numbers match what the API will charge, give or take a token or two. This is feasible only when the tokenizer is published as a runnable library. For OpenAI's GPT and o-series, that library is &lt;code&gt;tiktoken&lt;/code&gt; (Python) and &lt;code&gt;gpt-tokenizer&lt;/code&gt; (JavaScript). Both are MIT-licensed and small enough to ship client-side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Estimating counters&lt;/strong&gt; apply a character-ratio heuristic. They divide the character count by some constant (3.5 to 4.0 depending on the model family) and round up. The number is roughly right on plain English. It can be 10 to 20 percent off on code, JSON, German, mixed scripts, or anything with unusual whitespace. If a counter is fast on a 100,000-character paste regardless of which model you pick, it's almost certainly estimating.&lt;/p&gt;

&lt;p&gt;The honest move is to label which is which. Most counters don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tool I built actually does
&lt;/h2&gt;

&lt;p&gt;Since I'm linking to one of these, I owe you the spec.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aidevhub.io/token-counter/" rel="noopener noreferrer"&gt;aidevhub.io/token-counter&lt;/a&gt; uses &lt;code&gt;gpt-tokenizer&lt;/code&gt; to compute exact counts for OpenAI's GPT-4, GPT-5, o3, and o4 model names. For every other family (Claude 3.x, Claude 4.x, Gemini, Llama, DeepSeek, Mistral, Grok) it uses a character-ratio estimate calibrated per family. Claude is &lt;code&gt;chars / 3.5&lt;/code&gt;. The others are &lt;code&gt;chars / 4.0&lt;/code&gt;. The output labels each row as either &lt;code&gt;exact&lt;/code&gt; or &lt;code&gt;estimate&lt;/code&gt; so you can tell which you're looking at.&lt;/p&gt;

&lt;p&gt;This is honest about what's possible. I can't ship Anthropic's tokenizer client-side because it isn't published as a local library. I can't ship Google's either. The choice was either to fake-claim "supports every tokenizer" (the easy lie) or to label estimates as estimates (the harder honesty). Picked the second.&lt;/p&gt;

&lt;p&gt;For most context-budget math at 30 to 70 percent of the window, the estimate is close enough. For boundary cases at 95+ percent of the window, you want the actual tokenizer. The next section is how to get certainty when you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to get certainty when the number matters
&lt;/h2&gt;

&lt;p&gt;If the count matters (you're at the boundary, or you're billing customers per-token), don't trust any browser tool, including mine. Use the model's own counting endpoint or library.&lt;/p&gt;

&lt;p&gt;For OpenAI:&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;tiktoken&lt;/span&gt;
&lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encoding_for_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the source of truth. &lt;code&gt;gpt-tokenizer&lt;/code&gt; in the browser uses the same encodings (&lt;code&gt;cl100k_base&lt;/code&gt; for GPT-4 era, &lt;code&gt;o200k_base&lt;/code&gt; for GPT-4o and newer), so a browser-based exact counter and &lt;code&gt;tiktoken&lt;/code&gt; should match within a token or two. If they don't, your &lt;code&gt;tiktoken&lt;/code&gt; version is probably stale and the model has updated its vocabulary since you last upgraded.&lt;/p&gt;

&lt;p&gt;For Claude, Anthropic publishes a server-side counting endpoint accessible via the SDK as &lt;code&gt;client.messages.count_tokens()&lt;/code&gt; (or &lt;code&gt;client.beta.messages.count_tokens()&lt;/code&gt; depending on SDK version). It costs nothing to call but it does need network and an API key. Returns the exact count the API will charge for that exact &lt;code&gt;messages&lt;/code&gt; array including system prompt and tool definitions.&lt;/p&gt;

&lt;p&gt;For Gemini, the SDK exposes &lt;code&gt;model.count_tokens()&lt;/code&gt; which similarly calls Google's server.&lt;/p&gt;

&lt;p&gt;The post-call &lt;code&gt;usage&lt;/code&gt; field on every modern API is also authoritative. After your call, the response includes &lt;code&gt;input_tokens&lt;/code&gt; and &lt;code&gt;output_tokens&lt;/code&gt; as the actual billed counts. If your local count and the API's &lt;code&gt;usage&lt;/code&gt; consistently disagree, your local tokenizer is the one that's wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where token counts and API math diverge
&lt;/h2&gt;

&lt;p&gt;A counter on raw text isn't the full picture for an API call. Three things eat budget that a naive counter doesn't see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;System prompt and tool definitions count.&lt;/strong&gt; Every modern API includes them in the input total. If you're counting only the user message, you're under-counting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Message structure adds overhead.&lt;/strong&gt; Each message in a chat-format request costs a few tokens for the role markers and separators, on top of the content. OpenAI documents this; Anthropic does too. It's small (3 to 6 tokens per message) but at scale it matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output tokens are a separate budget.&lt;/strong&gt; The 200,000 number you see in Claude's docs is the input window. Output is configured separately. Claude 4 family has a third configurable budget for thinking tokens. Always check the model's docs for the specific split.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A browser counter that gives you a single number against a single model is a useful sanity check, not a complete budget calculation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compact summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Counter type&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;th&gt;When to use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tiktoken&lt;/code&gt; (Python)&lt;/td&gt;
&lt;td&gt;Runs OpenAI's official tokenizer locally&lt;/td&gt;
&lt;td&gt;Exact for GPT and o-series&lt;/td&gt;
&lt;td&gt;Boundary cases, prod budget math&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gpt-tokenizer&lt;/code&gt; (JS)&lt;/td&gt;
&lt;td&gt;Same vocabularies, browser-shippable&lt;/td&gt;
&lt;td&gt;Exact for GPT and o-series&lt;/td&gt;
&lt;td&gt;Browser tools, paste-and-count UIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic &lt;code&gt;count_tokens&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Server-side API call&lt;/td&gt;
&lt;td&gt;Exact for Claude, includes message overhead&lt;/td&gt;
&lt;td&gt;When the count matters and you have a key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini &lt;code&gt;count_tokens&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Server-side API call&lt;/td&gt;
&lt;td&gt;Exact for Gemini, includes message overhead&lt;/td&gt;
&lt;td&gt;When the count matters and you have a key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Character-ratio estimate&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;chars / 3.5&lt;/code&gt; or &lt;code&gt;chars / 4.0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Within 10 to 20 percent on most input&lt;/td&gt;
&lt;td&gt;Quick sanity check, no key needed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A few small habits that pay off
&lt;/h2&gt;

&lt;p&gt;After watching too many "but my count said it'd fit" boundary failures, three habits I've stuck with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Count against the actual target model&lt;/strong&gt;, not "GPT-4 close enough". Different vocabularies give different numbers on identical input. If you're sending to Claude 4.6, count with Anthropic's tokenizer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minify JSON before sending.&lt;/strong&gt; Pretty-printed JSON spends tokens on whitespace. The model doesn't care. Editor reads the indented version, model reads the minified one. Easy to script in your client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log token counts on every prod call&lt;/strong&gt; and graph the average weekly. If your average prompt size starts creeping up because someone added a new few-shot example, you'll see it before it tips over the budget. Costs about 10 lines of code per service.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;&lt;strong&gt;Q: Are there official tokenizers I can run locally for every model?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Only OpenAI publishes one as a runnable library (&lt;code&gt;tiktoken&lt;/code&gt; in Python, &lt;code&gt;gpt-tokenizer&lt;/code&gt; in JS). Anthropic and Google publish counting as server APIs only. If a third-party tool claims to do exact tokenization for Claude or Gemini in your browser, it's almost certainly estimating, no matter what the marketing says.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why does the count change when I add a system prompt?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Because system prompt is part of the input. Same for tool definitions if you're using tool-use APIs. The input window includes the entire request payload, not just the user turn. This trips people who count only their user message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How accurate is the post-call &lt;code&gt;usage&lt;/code&gt; field?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; It's the source of truth. That's what was billed. Counters before the call are estimates of what &lt;code&gt;usage&lt;/code&gt; will say. They should match within 1 to 2 tokens if your local tokenizer matches the model's current version. Consistent drift means your local library is stale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does whitespace really matter that much?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes, on text-heavy input. Repeated newlines and indentation are often single tokens each, but they add up. A pretty-printed 5,000-line JSON file can use noticeably more tokens than the same JSON minified, with no information loss. If you're trimming for budget, that's the first place to look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What about thinking tokens on Claude 4 and reasoning tokens on o-series?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Separate budget on both. Claude 4 family has a configurable &lt;code&gt;thinking&lt;/code&gt; token budget independent of input and output. OpenAI's o-series has reasoning tokens that count against output. Check the specific model's docs because the rules vary by version.&lt;/p&gt;




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

</description>
      <category>ai</category>
      <category>devtools</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>5 cron expression gotchas that catch experienced devs in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Sat, 25 Apr 2026 13:34:21 +0000</pubDate>
      <link>https://forem.com/aidevhub/5-cron-expression-gotchas-that-catch-experienced-devs-in-2026-21l1</link>
      <guid>https://forem.com/aidevhub/5-cron-expression-gotchas-that-catch-experienced-devs-in-2026-21l1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

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

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

&lt;h2&gt;
  
  
  Gotcha 1: &lt;code&gt;*/5&lt;/code&gt; is anchored to the field origin, not to "now"
&lt;/h2&gt;

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

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

&lt;p&gt;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: &lt;code&gt;*/N&lt;/code&gt; is anchored to the field's natural origin, never to the load time.&lt;/p&gt;

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

&lt;p&gt;This one is in the POSIX spec and almost nobody reads it. 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 "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.&lt;/p&gt;

&lt;p&gt;There's no way to express AND between those two fields in standard POSIX cron. Two common workarounds:&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="c1"&gt;# Cron fires every Monday. Script filters down to "first Monday of the month".
&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="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;Cron expression becomes &lt;code&gt;0 9 * * 1&lt;/code&gt; (every Monday at 9am) and the script handles the "first" qualifier. Two pieces of logic, each obvious on its own.&lt;/p&gt;

&lt;p&gt;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-&lt;code&gt;*&lt;/code&gt;. Different platform, different rule. Worth knowing which one you're on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 3: launchd reads local time, not UTC
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;launchd&lt;/code&gt; interprets &lt;code&gt;StartCalendarInterval&lt;/code&gt; in the system's local timezone. If your plist has &lt;code&gt;Hour=14&lt;/code&gt;, the job fires at 14:00 wherever the Mac thinks it is. There is no built-in "interpret as UTC" flag.&lt;/p&gt;

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

&lt;p&gt;Two ways to fix it on launchd:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set the system clock to UTC. Works if you control the machine and don't mind the rest of the OS displaying UTC times.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;h2&gt;
  
  
  Gotcha 4: cron does not catch up missed firings
&lt;/h2&gt;

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

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

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

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

&lt;h2&gt;
  
  
  Gotcha 5: a cron expression has no timezone embedded in it
&lt;/h2&gt;

&lt;p&gt;This is the one that bites distributed teams. The expression &lt;code&gt;0 9 * * *&lt;/code&gt; 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".&lt;/p&gt;

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

&lt;p&gt;A few things help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For Linux cron, &lt;code&gt;CRON_TZ=Europe/Berlin&lt;/code&gt; at the top of the crontab file pins all subsequent entries to that zone. Documented in &lt;code&gt;man 5 crontab&lt;/code&gt;. Easy to miss.&lt;/li&gt;
&lt;li&gt;For Quartz-based schedulers, the timezone is usually a separate config field (&lt;code&gt;timeZone&lt;/code&gt; in Spring's &lt;code&gt;@Scheduled&lt;/code&gt;, for example).&lt;/li&gt;
&lt;li&gt;For launchd, you compute it yourself or set the system clock.&lt;/li&gt;
&lt;/ul&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 the timezone-archaeology session that always comes a month later.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd write each of these now
&lt;/h2&gt;

&lt;p&gt;For reference, here's how each gotcha translates to a defensible expression.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Naive attempt&lt;/th&gt;
&lt;th&gt;What it actually does&lt;/th&gt;
&lt;th&gt;Defensible version&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;Fires at :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 on launchd&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Hour=14&lt;/code&gt; in plist&lt;/td&gt;
&lt;td&gt;14:00 in local timezone, not UTC&lt;/td&gt;
&lt;td&gt;Compute local hour, comment with intended zone&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 catch-up scheduler&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;Often wrong on the first try&lt;/td&gt;
&lt;td&gt;Build visually, paste, comment what it fires on&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 with more than one non-&lt;code&gt;*&lt;/code&gt; field. Two fields with values is where my error rate spikes and the cost of building visually is zero.&lt;/p&gt;

&lt;p&gt;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;, &lt;code&gt;@hourly&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The free cron builder I made and use regularly now is 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, 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.&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, dating back to the original Unix cron. The spec says if either field is restricted (not &lt;code&gt;*&lt;/code&gt;), they're OR-ed together. There's a footnote in &lt;code&gt;man 5 crontab&lt;/code&gt; if you want to read it. Most cron tutorials skip this part 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; 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 &lt;code&gt;?&lt;/code&gt; in one of the two day fields, which is its own kind of footgun.&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). Most 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. I find systemd timers the cleanest for new Linux work and stick with launchd for Mac because the alternatives aren't worth the friction.&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; Easiest path is a builder that shows the next 5 fire times so you can eyeball whether the schedule matches your intent. Beyond that, &lt;code&gt;croniter&lt;/code&gt; for Python and &lt;code&gt;cron-parser&lt;/code&gt; for Node both 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;p&gt;&lt;strong&gt;Q: What about Quartz cron expressions?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Different beast. 6 or 7 fields (seconds optional, year optional), &lt;code&gt;?&lt;/code&gt; placeholder for day fields, &lt;code&gt;L&lt;/code&gt; for last, &lt;code&gt;#&lt;/code&gt; 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.&lt;/p&gt;




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

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