The Complete Guide to Cron Expressions: Five Fields, Classic Pitfalls, and Debugging Tips
2026-08-06
Almost every backend engineer has lived this moment: staring at a line of five asterisks in a crontab, silently wondering “is that every minute or every hour?” Cron’s syntax is only a handful of symbols, yet it packs four decades of historical baggage and genuinely counterintuitive design: Sunday has two legal values, day-of-month and day-of-week combine with OR instead of AND, and jobs scheduled for 2:30 AM vanish into thin air one day a year. This guide walks through the rules and the scenarios most likely to break in production.
The Five Fields: Anatomy of a Cron Expression
A standard cron expression has five fields, separated by spaces:
┌───────────── minute (0-59)
│ ┌─────────── hour (0-23)
│ │ ┌───────── day of month (1-31)
│ │ │ ┌─────── month (1-12)
│ │ │ │ ┌───── day of week (0-7, both 0 and 7 are Sunday)
│ │ │ │ │
* * * * *
Each field can hold a specific value, a range, a list, or * for “any value.” The job fires only when the current time matches all fields simultaneously (with one famous exception covered below). Note that cron’s finest granularity is the minute — there’s no seconds field, so “every 30 seconds” is impossible; you’d need a loop inside your script or a different scheduler entirely.
The day-of-week field deserves emphasis: both 0 and 7 mean Sunday, with 1 through 6 for Monday to Saturday. This is historical residue — different Unix flavors picked different conventions, and eventually both were accepted. Stick to 0 in new expressions to avoid ambiguity. Many implementations also accept three-letter names like SUN and MON, which read better, but verify support before relying on them in a portable script.
The Four Operators: *, /, ,, -
All variation inside a field comes from four symbols:
*— wildcard, matches every legal value.*in the minute field means every minute.*/n— step, “every n”.*/5in the minute field means minutes 0, 5, 10, 15, and so on.,— list, enumerates values.1,15in the day-of-month field means the 1st and 15th.-— range.1-5in the day-of-week field means Monday through Friday.
Combined, they produce the everyday patterns:
*/5 * * * * every 5 minutes
0 */2 * * * at minute 0 of every 2nd hour
0 9-18 * * 1-5 on the hour, 9 AM to 6 PM, weekdays
0 0 1,15 * * at midnight on the 1st and 15th
Hiding here is a classic misreading: */5 and 5 are completely different. */5 is “every 5 minutes” — twelve runs per hour — while 5 * * * * is “at minute 5 of every hour” — twenty-four runs per day. The incident report template “I wanted every 5 minutes but wrote 5 * * * *” has been filled out by generations of engineers. Steps compose with ranges too: 10-30/10 means minutes 10, 20, and 30.
When you’re unsure of an expression, don’t simulate it in your head — build it visually in the Cron Generator, which explains each field and lists the upcoming fire times. It’s faster than counting on your fingers.
Common Schedules, Field by Field
Here are the schedules you’ll see most in production, broken down:
0 3 * * *
│ │ │ │ └── day of week: any
│ │ │ └──── month: any
│ │ └────── day of month: any
│ └──────── hour: 3
└────────── minute: 0
→ 3:00 AM daily — prime time for log rotation and backups
30 2 * * 0
→ 2:30 AM every Sunday — a good slot for full data rebuilds
*/10 8-19 * * 1-5
→ every 10 minutes from 8:00 to 19:59 on weekdays — business-hours health checks
0 0 1 * *
→ midnight on the 1st of every month — monthly report generation
Beyond the five-field syntax, many cron implementations accept @ shortcuts: @daily equals 0 0 * * *, @hourly equals 0 * * * *, @weekly is 0 0 * * 0, @monthly is 0 0 1 * *, and @yearly (or @annually) is 0 0 1 1 *. There’s also the special @reboot, which runs once at system startup. These are maximally readable — use them when they fit.
The Biggest Trap: Day-of-Month OR Day-of-Week
Now the exception. Normally the five fields combine with AND logic, but when both day-of-month and day-of-week are restricted (neither is *), cron fires when either one matches — OR, not AND.
This rule will bite you eventually, usually at a bad hour. Say you want “run on Friday the 13th” (the classic superstition gag) and write:
0 0 13 * 5
You expect it to fire only on a Friday the 13th. In reality it fires on the 13th of every month, and again on every Friday. The cron man page states this plainly, but most people first learn it in a postmortem. The correct approach: let cron handle one condition and your script handle the other. Write 0 0 13 * *, then check at the top of the script whether today is Friday and exit early if not.
Timezones and DST: Jobs That Vanish and Jobs That Double
Cron uses the system’s local timezone by default. That sounds harmless until your server migrates to a new datacenter, your container image defaults to UTC, or daylight saving time arrives.
DST transition days are where the magic happens. When clocks spring forward, the local times from 2:00 to 2:59 AM don’t exist — a job scheduled at 30 2 * * * simply skips that day. When clocks fall back, the hour from 1:00 to 1:59 AM happens twice, and jobs in that window run twice. If your job sends invoices or push notifications, double execution means real money and angry users. Three defenses: move critical jobs to a safe window (after 3:30 AM avoids DST transitions in essentially every timezone); run the server in UTC and write expressions in UTC, using the Timestamp Converter when you need to map back to local time; and make the job idempotent so a duplicate run is harmless.
One more note for distributed teams: always state the timezone when discussing a schedule. “9 AM” means nothing across continents. To check what a given UTC schedule means locally, the Timestamp Converter shows conversions side by side, and the Date Calculator works out the concrete calendar date of a schedule N days from now.
Crontab vs. systemd Timers vs. CI Schedulers
The five-field syntax is a lingua franca, but every scheduler speaks its own dialect, and migrations are where things go wrong.
Traditional crontab: edited with crontab -e, minute-level granularity, field order exactly as above. Vixie cron and its descendants (the default on most Linux distributions) follow every rule in this article, including the OR semantics.
systemd timers: the recommended approach on modern distributions. They don’t use five-field syntax at all — instead they have their own OnCalendar format, like OnCalendar=Mon..Fri 09:00. The advantages are real: full logging via journald, second-level precision, Persistent=true to catch up on runs missed while the machine was off, and explicit dependency ordering. The cost is another syntax to learn and subtly different field semantics from classic cron.
GitHub Actions: the schedule trigger in a workflow uses five-field cron that looks identical to crontab, with one lethal difference — it is always interpreted in UTC. Write 0 9 * * * expecting a 9 AM run and you’ll get 5 PM if your team is in Beijing (UTC+8). GitHub’s own documentation also warns that scheduled workflows can be delayed significantly under load — never treat a CI schedule as a precise alarm clock. GitLab CI pipeline schedules, by contrast, let you pick a timezone per schedule, a third semantic. Before migrating a crontab into a CI scheduler, re-validate the expression’s fire times in the Cron Generator with UTC in mind.
Debugging: The Job Didn’t Run — Now What?
Silent cron failures are a rite of passage. Work through this checklist:
- Check the logs first:
grep CRON /var/log/syslogon Debian/Ubuntu or/var/log/cronon RHEL/CentOS. Confirm the daemon actually triggered the job. - Run the script manually: cron’s environment is minimal —
PATHis typically just/usr/bin:/bin, so commands available in your interactive shell may not exist for cron. Use absolute paths in scripts, or setPATHexplicitly at the top of the crontab. - The percent trap:
%is a special character in crontab entries (it means newline), sodate +\%Y-\%m-\%dneeds escaping. Inside a script file it doesn’t. - Validate the expression itself: paste it into the Cron Generator and check whether the listed future fire times are what you intended — watch especially for
*/5written as5and wrong day-of-week numbers. - Check the timezone: is the server on UTC or local time?
timedatectlanswers in one command; don’t guess. - Capture the output: cron mails stdout/stderr to the local user, which nobody reads. Redirect explicitly:
*/5 * * * * /opt/job.sh >> /var/log/job.log 2>&1.
Cron is a pure expression of the Unix philosophy: a simple tool that does one thing and never goes out of style. The price of that longevity is a handful of counterintuitive designs — field semantics, OR logic, timezone behavior — lying in wait for the uninitiated. Internalize the rules in this guide and your scheduled jobs will get a lot quieter, and your postmortems a lot rarer.