Five fields, each one individually simple, and still the single most common cause of a cron job running on the wrong day is a rule almost nobody reads until it bites them: when both the day-of-month and day-of-week fields are restricted at the same time, standard cron doesn't AND them together โ it ORs them. 0 9 15 * 1 doesn't mean "9am on the 15th, if it's a Monday." It means 9am on the 15th of every month, and also 9am every Monday, which is almost never what anyone intended when they wrote it.
The five fields, and the OR trap specifically
A standard cron expression is minute hour day-of-month month day-of-week, each field accepting a number, a range (1-5), a list (1,15), a step (*/15), or * for "any value." On their own, each field is exactly as straightforward as it looks. The surprising behavior only shows up when day-of-month and day-of-week are both restricted (neither one left as *) in the same expression โ POSIX cron's rule is that the job fires if either field matches, not only when both match. The reliable way to avoid it entirely: never restrict both fields in the same expression. If you need "the 15th, but only if it's a weekday," that's not expressible in a single standard cron line at all โ it needs either a wrapper script that checks the day and exits early, or two separate jobs with application-level logic deciding which one should actually act.
The fields that quietly use different numbering than you'd guess
Day-of-week in standard cron runs 0-7, where both 0 and 7 mean Sunday โ an intentional overlap for compatibility, not a typo, but one that trips up anyone assuming a clean 0-6 range. Months can be written as 1-12 or as three-letter names (JAN-DEC) depending on the implementation, and mixing conventions across a team's crontab is a quiet source of "wait, which Tuesday did you mean" confusion during review. The field order itself is also not universal: standard Unix cron is minute-first, but some schedulers (Quartz, used heavily in Java ecosystems, and some cloud schedulers) use a six- or seven-field format that adds seconds at the front and sometimes year at the end โ copying an expression from a different system's documentation into a standard crontab without checking the field count is a reliable way to schedule a job on completely the wrong minute.
Timezone is implicit, and implicit is where the 3am bugs live
A cron expression carries no timezone information at all โ it runs in whatever timezone the executing system (or scheduler service) is configured for, which is UTC by default on most cloud infrastructure and managed schedulers, but the local system timezone for a traditional Linux crontab unless explicitly overridden. A job scheduled for 0 2 * * * meant to mean "2am local time for the ops team" silently becomes 2am UTC the moment it's deployed to infrastructure running in a different timezone โ correct, predictable, and wrong for six months of the year if daylight saving adds a second layer of drift on top. The fix isn't memorizing an offset; it's either setting the scheduler's timezone explicitly where the platform supports it, or converting to UTC deliberately at write time and documenting that the expression is already in UTC, so nobody "fixes" it later based on a wrong assumption about what timezone it's in.
Daylight saving makes "every day at 2am" ambiguous twice a year
On a system using local time with daylight saving transitions, a job scheduled for a time inside the skipped hour (in regions that spring forward) simply doesn't run that day โ the clock jumps from 1:59am straight to 3:00am, and 2:00am never occurs. On the day clocks fall back, a naive scheduler can run the job twice, once during each occurrence of the repeated hour, depending on how the underlying scheduler handles the ambiguity. Neither failure mode is a bug in cron itself; it's an inherent consequence of scheduling in local civil time, which is exactly why critical infrastructure jobs are conventionally scheduled in UTC โ UTC has no daylight saving transitions, so a UTC-scheduled job runs exactly once, every single day, with no twice-a-year exception to remember.
Step values don't start where you'd assume
*/15 in the minute field means every 15 minutes starting from minute 0 of the hour โ :00, :15, :30, :45 โ not "every 15 minutes from whenever this was deployed." This matters when two jobs need to run staggered relative to each other: */15 and 5-59/15 don't offset by 5 minutes the way the second expression's start value might suggest, because a step value's range always starts counting from the field's own minimum (0 for minutes), not from the first number written. Testing an expression's actual next several fire times before deploying it โ rather than reading it and trusting your own mental parse โ catches this class of mistake in seconds instead of after the job has already fired at the wrong time in production.
Cron doesn't know or care if the last run is still going
Cron fires a new instance of a job at the scheduled time regardless of whether the previous instance has finished โ there's no built-in concept of "skip this run if one's already in progress." A job that normally takes 3 minutes but occasionally takes 20 (a slow database query, a network hiccup, an unusually large batch) can end up with two, three, or more overlapping instances all running simultaneously against the same resources, which is a frequent, genuinely hard-to-diagnose cause of a job that "sometimes" produces duplicate records, corrupted output, or lock contention that makes every subsequent run slower still, compounding the problem. The standard fix is wrapping the job command with a locking mechanism โ flock on Linux is the common choice โ so a new invocation exits immediately if an existing one already holds the lock, rather than piling on top of it. This is worth adding by default to any cron job whose runtime isn't both short and extremely predictable, not just the ones that have already caused a visible incident.
"It works when I run it manually" is the classic cron symptom
Cron executes jobs with a minimal environment โ typically just SHELL, PATH (often reduced to something like /usr/bin:/bin), and HOME โ none of the exports, aliases, or PATH additions sitting in your interactive shell's .bashrc or .zshrc, because cron doesn't load them; it's not an interactive login shell. A script that calls a tool installed via a version manager (nvm, rbenv, a user-local pip install), or that relies on an environment variable your shell profile sets, runs perfectly when you execute it by hand and fails silently or with a "command not found" buried in a log nobody checks when cron runs the identical command. The fix is making the job self-contained rather than environment-dependent: use absolute paths for every binary and file the script touches, set every environment variable the script needs explicitly inside the script itself (or in the crontab via a VAR=value line above the schedule), and redirect both stdout and stderr to a log file โ cron's own default behavior of emailing output to a local mail spool that nobody reads is how failures stay invisible for months.