A cron expression packs an entire recurring schedule into five terse fields — which is exactly why it's so easy to misread one at a glance. 0 9 * * 1-5 and 9 0 * * 1-5 look almost identical and mean completely different things. The syntax itself is small, though: once a handful of rules click, you can read (and write) almost any schedule you'll actually encounter.
The five fields
A standard cron expression is five space-separated fields, always in this order:
minute hour day-of-month month day-of-week
0-59 0-23 1-31 1-12 0-6 (0 = Sunday)There's no field for seconds and no field for year in standard cron — five fields, every time. (Some schedulers, like Quartz, add a seconds field or a year field; that's a different format, not a variant of the same one.)
The four symbols that do all the work
*— "any value."* * * * *means every minute of every hour of every day.- a number — an exact value.
0 9 * * *means minute 0, hour 9 — i.e. 9:00am, every day. - a range (
-) — inclusive on both ends.1-5in the day-of-week field means Monday through Friday. - a list (
,) —1,15in the day-of-month field means the 1st and the 15th, and nothing in between. - a step (
/) —*/15in the minute field means every 15 minutes, starting from the field's minimum (so :00, :15, :30, :45). Steps combine with ranges too:9-17/2means every 2 hours between 9am and 5pm.
Building up from simple to specific
*/15 * * * * Every 15 minutes
0 * * * * Every hour, on the hour
0 9 * * * Every day at 9:00am
0 9 * * 1-5 Every weekday at 9:00am
0 9 1 * * The 1st of every month at 9:00am
*/15 9-17 * * 1-5 Every 15 minutes, 9am-5pm, on weekdaysReading left to right — minute, then hour, then day-of-month, then month, then day-of-week — each field just narrows down which minutes qualify. An empty-feeling field (a bare *) contributes no restriction at all.
The trap: day-of-month and day-of-week together mean OR, not AND
This is the single most common source of cron confusion. Everywhere else, adding a restriction narrows the schedule down. But if both day-of-month and day-of-week are restricted (neither is a bare *), standard cron matches a date that satisfies either one — not both:
0 0 1 * 1 Runs at midnight on the 1st of the month
AND ALSO at midnight every Monday
— not "the 1st, only if it's a Monday"If you actually want "only if both conditions hold" (e.g. "the 1st, but only when it falls on a Monday"), plain cron syntax can't express that — you need the job itself to check the date and exit early, or a scheduler with richer expressions. The fix for the OR trap is usually simpler: leave one of the two fields as *. If you only meant to restrict by day-of-week, don't also restrict day-of-month (and vice versa) — the moment both are restricted, you've opted into OR semantics whether you meant to or not.
Nicknames: @daily, @hourly, and friends
Many cron implementations (including Vixie cron and most Linux distributions) accept a handful of shorthand strings in place of the five fields:
@yearly (or @annually) 0 0 1 1 *
@monthly 0 0 1 * *
@weekly 0 0 * * 0
@daily (or @midnight) 0 0 * * *
@hourly 0 0 * * * (fires at minute 0 of every hour)
@reboot runs once, at startup — no fixed-field equivalentThey're convenient, but not universal — not every scheduler (or every cron-parsing library, including some used to build tooling like this) supports them. If you're not sure your target environment does, the numeric five-field form on the left is the portable choice.
Mistakes worth double-checking
- 24-hour clock.
0 9 * * *is 9am;0 21 * * *is 9pm. There's no am/pm field, and a typo here is silent — the job just runs 12 hours off from what you intended. - Sunday can be
0or7. Most implementations accept both as Sunday for compatibility, but don't rely on7if you need to double-check against a stricter parser —0is the safer, more universally understood choice. - It runs in the system's timezone, not necessarily yours. A cron job on a server is evaluated against that server's configured timezone (often UTC), which may not match your local time. This is the single most common reason a schedule seems to fire "a few hours off" from what was intended.
- Step values don't always divide evenly.
*/7in the hour field doesn't mean "every 7 hours forever" — it means every hour that's a multiple of 7 within the field's range (0, 7, 14, 21), so the gap between the last match and midnight is shorter than 7 hours.
Try it yourself
Don't hand-write an expression from scratch if you don't have to — Cron Builder generates one from a plain schedule (interval, daily, weekly, monthly, or custom fields) with a live preview of the next run times. To go the other way — turn an expression you found into plain English — use Cron Parser. And before you commit a crontab file to a server, run it through Cron Validator to catch malformed fields, reversed ranges, and missing commands before they cause a job to silently never run. All three run entirely in your browser.