A scheduled job that quietly skips runs is one of the worst categories of bug to debug. There is no exception, no failed deploy, no red status page. The job is "running fine," it just is not running when you thought it would. By the time anyone notices, you have a weekend of missing reports, a stale cache, or a backup that never happened.
Most of the time the schedule expression is the problem, and the expression is not as obvious as it looks. Five small fields can encode a surprisingly wide range of mistakes, and the same expression behaves differently across distributions, container images, and managed schedulers. This guide walks through the failure modes that come up over and over, how to actually read a cron line, and how to confirm the next ten firing times before you ship the change.
Photo by Felix Mittermeier on Pexels
The five fields, in the order you actually need to remember them
The classic cron line has five space-separated fields followed by the command:
minute hour day-of-month month day-of-week command
That order is easy to recite and easy to get wrong under stress. The two fields that cause the most outages are day-of-month and day-of-week, partly because both of them can be a wildcard and partly because the rules for combining them are unintuitive.
The traditional behavior, codified in the Wikipedia overview of cron, is that when day-of-month and day-of-week are both restricted, the job fires when either matches. That is an OR, not an AND. So 0 9 1 * 1 does not mean "9 AM on the first Monday of the month." It means "9 AM on the 1st of every month, and also 9 AM every Monday." A scheduler that fires too often is usually safe to spot in logs. A scheduler that fires too rarely is the one that bites you.
The values you can put in each field are wider than most people use day to day:
- A specific number, like
15 - A range, like
9-17 - A list, like
1,15,30 - A step value, like
*/5(every five units of that field) - A combination, like
0,30 9-17 * * 1-5
Steps are where small typos turn into big problems. */7 in the minutes field does not mean "every seven minutes" in the sense of seven minutes apart all day. It means "every minute whose value is divisible by seven," so the schedule fires at minute 0, 7, 14, 21, 28, 35, 42, 49, 56, then jumps back to 0 of the next hour. There is a six-minute gap between minute 56 and the next minute 0. If you treated this as "every seven minutes," you would be wrong eight times a day.
Why your job did not fire: the short list
Almost every "cron didn't run" incident I have looked at boils down to one of these:
- Wrong timezone. The host runs in UTC. You wrote the expression in local time. The job fires four to eight hours away from where you expected, and you only notice when a downstream consumer complains.
- Daylight saving time skip or repeat. The job is scheduled at
0 2 * * *. On the spring-forward day, 2:00 AM local time does not exist. On the fall-back day, 2:00 AM happens twice. Both are surprising and both are documented in the Wikipedia article on daylight saving time. - The day-of-month / day-of-week OR rule (covered above).
- PATH or environment differences. The command runs by hand from your shell. It fails silently under cron because cron's environment does not include your PATH, your virtualenv, or your shell aliases. The job is "firing" but the command is exiting nonzero before it does any real work.
- The crontab was never installed. You edited a file in the repo but never ran
crontabon the box, or the deploy pipeline only installs crontabs on a subset of hosts. The schedule looks correct in source control. The host does not have it. - The container exited. In a container-based deployment, the process holding the cron daemon was OOM-killed or replaced by a new image. Cron is not running, so nothing is firing.
Each of these has a different fix. Lumping them together as "cron is broken" is what keeps the bug alive. Reading the schedule carefully, in writing, before you change anything, is the discipline that catches them.
Photo by Annas Zakaria on Pexels
Reading an expression you did not write
Inheriting someone else's crontab is the most common way to encounter an unfamiliar expression. The trick is to translate it one field at a time, out loud or on paper, before you do anything else.
Take */15 9-17 * * 1-5. Field by field:
- Minutes:
*/15is every minute divisible by 15, so :00, :15, :30, :45. - Hours:
9-17is 9 AM through 5 PM inclusive, so nine slots. - Day of month:
*is every day. - Month:
*is every month. - Day of week:
1-5is Monday through Friday.
That schedule fires four times an hour, nine hours a day, five days a week, all year. Thirty-six firings per business day, 180 per business week, roughly 9,400 per year. If the job is doing something expensive, the cost of running it is hiding in plain sight inside that one line.
Now take a worse example: 0 2,14 * * 0,6. The temptation is to read it left to right and say "2 AM and 2 PM on Sunday and Saturday." That is correct. But the same person who wrote that schedule could have meant "2 AM Saturday and 2 PM Sunday" and gotten a job that fires twice on each weekend day instead of once. The expression cannot distinguish those two intents. If the second one is what you wanted, you need two crontab lines, one for each day.
The reading-it-out-loud discipline does not catch everything, but it catches the bugs you would not notice by squinting. Pair it with a tool that translates expressions to plain English and lists the next several firings. The free Cron Expression Builder from EvvyTools does both, and it shows the upcoming run times so you can verify the schedule matches the intent before you commit.
The timezone problem deserves its own paragraph
The single most common production incident with cron is the schedule firing in a timezone the author did not expect. Standard Unix cron uses the system's local timezone, which on a cloud host is almost always UTC. Some schedulers, like systemd timers and most cloud-managed cron services, let you specify the timezone explicitly. Others, like classic Vixie cron, require you to set CRON_TZ at the top of the crontab or to do the math yourself.
Daylight saving makes this worse. A job scheduled at 0 2 * * * on a host that observes US Eastern time will fire zero times on the spring-forward day (the 2 AM hour is skipped entirely) and twice on the fall-back day (the 2 AM hour repeats). On a host that runs in UTC, neither problem exists, but every local-time consumer of the job's output has to adjust by hand. The fix is to pick one of two clear positions: either run everything in UTC and translate when you display, or pin every job to an explicit timezone using a scheduler that supports it. Mixing the two is where the bugs come from.
"We standardized every scheduled job in our deploy to use a per-job timezone field instead of relying on the host clock. The number of 'job didn't fire' tickets dropped almost immediately, and the ones that remained were actual code bugs instead of clock-math bugs." - Dennis Traina, founder of 137Foundry
If you maintain your own timezone data, the IANA time zone database is the source of truth. Most distributions ship it, but if your container image is minimal you may need to install tzdata explicitly.
Photo by Walls.io on Pexels
Confirming next runs before you deploy
The cheapest insurance against a silent cron bug is to compute the next several firings and read them. Not "the next one." The next ten. A schedule that looks right for the next firing can still be wrong on the third one, especially if you are using step values or list syntax.
A pragmatic workflow:
- Write the expression. Translate it field by field on paper.
- Paste it into a builder that produces both English and the next ten firing times. The EvvyTools cron builder does this; so does the well-known crontab.guru reference. The point is to see the runs as dates, not as fields.
- Check that the spacing between consecutive firings matches your intent. A schedule that should fire every fifteen minutes will show gaps of fifteen minutes, fifteen minutes, fifteen minutes, never a hidden thirty-minute gap.
- Cover the edge cases: a DST boundary in the next week if you live somewhere observing it, the end of the month, February 29 if you are anywhere near it, and the transition at midnight UTC if your job uses date-bounded queries.
- Only after the table of next runs looks right, install the crontab.
This sounds like overhead. It takes thirty seconds. The alternative is the weekend of debugging a job that "should be running."
Systemd timers, container schedulers, and the future of cron
Classic cron is still everywhere, but a lot of modern scheduling has moved to systemd timers, Kubernetes CronJobs, and managed cloud schedulers like AWS EventBridge or Google Cloud Scheduler. Each one has its own quirks, but they share a few advantages worth knowing about.
Systemd timers, documented across the systemd project, let you express schedules in a richer calendar format (OnCalendar=Mon..Fri 09:00 America/New_York), include explicit timezones in the unit file, run missed jobs after a reboot, and surface their next firing time with systemctl list-timers. The same is true of most managed schedulers: they let you specify the timezone explicitly and they show you the upcoming runs in the console.
If you are starting a new scheduled workload today and you have the option, prefer a scheduler that supports explicit timezones and "next runs" visibility out of the box. Keep classic cron for legacy jobs and short shell-script tasks where adding a unit file would be more ceremony than the job is worth. Either way, the discipline of reading the expression and verifying the next firings is what keeps the schedule honest.
Cron is small, but the surface area is real
Five fields, one command, decades of folklore. Cron expressions look trivial, and that is exactly why they bite. Most of the bugs are not in cron itself. They are in the gap between what the author meant and what the expression actually says, plus the gap between the author's wall clock and the host's UTC clock.
The fix is not to memorize more cron syntax. It is to build a small habit: write the expression, read it out loud, run it through a translator that shows the next ten firings, check the timezone, then deploy. Two minutes of verification beats two hours of "why didn't the report send."
For the next one you write, paste it into the EvvyTools cron builder and look at the upcoming firings before you commit. If the times surprise you, the expression is wrong, and you would rather find out now than at 3 AM on a Sunday. Browse the rest of the EvvyTools directory for the other small utilities that solve the same shape of problem, and the EvvyTools blog for more deep dives on tools you might already use without quite understanding.
Photo by jack hadley on Pexels