DevOps

How to Read a Cron Expression: The Complete Cron Syntax Guide

A complete guide to reading cron expressions the five fields, every special character, real examples decoded, a cron cheat sheet, and the timezone gotcha that breaks scheduled jobs.

Nikhil Sai··23 min read
All posts

Introduction

A cron expression is a five-field string that tells a scheduler when to run a job: 0 9 * * 1-5 means "9:00 AM, Monday through Friday." That's the whole idea. What makes cron confusing isn't the concept it's that five terse fields are doing the work of a full sentence, and the syntax gives you almost no visual cues about what each character means until you've memorized the layout.

Developers reach for cron constantly: cache invalidation, nightly backups, report generation, CI pipelines, Kubernetes CronJobs. And almost everyone who uses it has, at some point, stared at an expression like 0 0 15 * 5 and guessed wrong about what it actually does. The syntax is old it predates most of the tools that now rely on it and it was designed for compactness, not readability.

This guide teaches you to read any cron expression on sight, field by field, including the parts that trip people up: the day-of-month/day-of-week interaction, timezone handling, and the extended syntax (L, W, #) that some schedulers support and others don't. If you'd rather not do the field-by-field parsing by hand, you can paste any expression into the Cron Expression Parser at any point in this guide and see it translated into plain English along with its next run times.

What is a Cron Expression?

A cron expression is a compact, positional syntax for describing a recurring schedule. It comes from cron, the time-based job scheduler built into Unix and Unix-like operating systems since the 1970s. The name comes from the Greek chronos (time), and the tool exists because "run this script every day at 2 AM" is such a common requirement that operating systems bake a scheduler directly into the system rather than making every application build its own.

The expression itself is what you hand to that scheduler. Instead of writing "every day at 2 AM," you write 0 2 * * * five fields, space-separated, each one constraining a different unit of time. The scheduler evaluates all five fields once a minute; when the current time matches every field, it fires the job.

This matters because it's the same format used by dozens of unrelated tools, not just Linux's crontab. Once you can read a cron expression, you can read a schedule in a Kubernetes manifest, a GitHub Actions workflow, or an AWS EventBridge rule without switching mental models.

Common real-world uses:

  • Database backups 0 3 * * * (nightly, low-traffic hours)
  • Scheduled emails digest emails sent every Monday morning
  • Cache cleanup clearing expired cache entries every few hours
  • Report generation compiling weekly or monthly usage reports
  • CI/CD nightly builds, dependency-update checks, scheduled deployments
  • Kubernetes jobs CronJobs that rotate logs, reconcile state, or run maintenance scripts

Where Cron Expressions Are Used

Cron syntax shows up far outside the traditional Linux crontab file, and this is where a lot of confusion starts because not every platform implements the same version of it.

  • Linux crontab the original. Five fields, no seconds, runs in the system's local timezone unless configured otherwise.
  • Kubernetes CronJobs use standard five-field cron syntax in the schedule field, evaluated in UTC by default (configurable via spec.timeZone in recent Kubernetes versions).
  • GitHub Actions schedule.cron in a workflow file uses five-field syntax and always runs in UTC, with no timezone override.
  • AWS EventBridge (Scheduler / CloudWatch Events) supports both cron and rate expressions, but its cron format has six fields, not five: minute, hour, day-of-month, month, day-of-week, and year. Like Quartz, it requires a ? in either day-of-month or day-of-week (whichever isn't constrained) instead of using * in both.
  • Jenkins uses a five-field cron-like syntax for build triggers, with its own minor extensions (H for a hash-based "spread the load" value).
  • Vercel Cron, Railway, Render, DigitalOcean App Platform all use standard five-field cron syntax for scheduled tasks, typically evaluated in UTC.

The practical takeaway: the five core fields (minute, hour, day-of-month, month, day-of-week) are close to universal, but seconds fields, year fields, timezone defaults, and extended characters like L, W, and # vary by platform. Always check the specific scheduler's docs before assuming a feature you've used elsewhere is supported this is covered in detail in the Linux Cron vs Quartz Cron section below.

Cron Expression Structure

Every standard cron expression has five fields, always in this order:

Cron Expression Format

┌───────────── Minute (0–59)
│ ┌─────────── Hour (0–23)
│ │ ┌───────── Day of Month (1–31)
│ │ │ ┌─────── Month (1–12 or JAN–DEC)
│ │ │ │ ┌───── Day of Week (0–7 or SUN–SAT) (0 and 7 = Sunday)
│ │ │ │ │       
│ │ │ │ │
* * * * *

Read left to right, the fields go from the smallest unit of time (minute) to the largest recurring unit (day of week). Each field independently constrains when the job can run the scheduler checks the current minute against field 1, the current hour against field 2, and so on, and only fires the job when every field matches (with one important exception for day-of-month/day-of-week, covered below).

Quick summary of each field before the deep dive:

  • Minute which minute(s) of the hour, 0 to 59
  • Hour which hour(s) of the day, 0 to 23 (24-hour format, no AM/PM)
  • Day of month which day(s) of the month, 1 to 31
  • Month which month(s), 1 to 12
  • Day of week which day(s) of the week, 0 to 6, where Sunday is 0

Understanding Every Field

Minute

Valid values: 0-59

The minute field is the finest-grained unit cron supports you can't schedule something to the second in standard cron. A bare number means "at exactly this minute of every matching hour." A * means "every minute."

ExpressionMeaning
0At the top of the hour (:00)
30At the half-hour mark (:30)
*/15Every 15 minutes: :00, :15, :30, :45
*Every minute

Common mistake: assuming */15 starts counting from whenever the job was deployed. It doesn't step values always start from the field's minimum (0), so */15 always lands on :00, :15, :30, :45, never on :07, :22, :37, :52, regardless of when you created the schedule.

Hour

Valid values: 0-23

Cron uses 24-hour time with no AM/PM notation. Midnight is 0, not 12 or 24. Noon is 12.

ExpressionMeaning
0Midnight (12:00 AM)
12Noon (12:00 PM)
9-179 AM through 5 PM (business hours)
*/6Every 6 hours: 00:00, 06:00, 12:00, 18:00

Common mistake: writing 24 for midnight, or 12 thinking it means midnight. 24 is invalid hours only go up to 23 and 12 is noon, not midnight.

Day of Month

Valid values: 1-31

This field picks specific calendar dates. There's no 0, since months don't have a "day zero."

ExpressionMeaning
1The 1st of the month
15The 15th of the month
1,15The 1st and the 15th
*Every day of the month

Common mistake: scheduling 31 and assuming it silently adjusts for shorter months. It doesn't a job set to run on day 31 simply won't fire in February, April, June, September, or November, because that day never occurs. Standard cron also has no built-in way to say "the last day of the month" that requires the L character covered in Cron Special Characters below, which most Linux cron implementations don't support.

Month

Valid values: 1-12 (many implementations also accept JAN-DEC)

1January
6,12June and December
3-5March through May
*Every month

Common mistake: treating month as zero-indexed (thinking 0 = January) out of habit from programming languages like JavaScript, where Date.getMonth() returns 0 for January. Cron's month field is 1-indexed: 1 is January, 12 is December.

Day of Week

Valid values: 0-6, where Sunday is 0 (many implementations also accept SUN-SAT, and 7 as an alternate value for Sunday)

ExpressionMeaning
1-5Monday through Friday
0,6Sunday and Saturday
5Friday only
*Every day of the week

Common mistake: assuming every scheduler numbers the week the same way. Standard cron (and systemd timers) use 0 for Sunday, but this is genuinely easy to misremember since ISO 8601 (used in many other contexts) treats Monday as day 1 and doesn't assign Sunday to 0. When in doubt, use the three-letter names (MON, FRI) instead of numbers if your scheduler supports them it removes the ambiguity entirely.

How to Read a Cron Expression

Here's a repeatable process for decoding any cron expression, in order:

  1. Split it into five fields by whitespace.
  2. Read left to right minute, hour, day-of-month, month, day-of-week.
  3. For each field, ask: is this a wildcard (*, matches everything), a specific value, a range, a list, or a step?
  4. Check whether both day-of-month and day-of-week are restricted (not *). If so, remember they combine as OR, not AND this is the single most common misreading.
  5. Assemble the plain-English sentence, working from frequency → time → date restriction.

Walking through ten real expressions with this process:

*/5 * * * * Minute: every 5th minute. Everything else: wildcard. → Every five minutes, all day, every day.

0 * * * * Minute: exactly :00. Hour: every hour. → Once every hour, on the hour.

30 14 * * * Minute: :30. Hour: 14 (2 PM). → Every day at 2:30 PM.

0 9 * * 1-5 Minute: :00. Hour: 9 AM. Day of week: Monday–Friday. → 9:00 AM, Monday through Friday.

0 0 1 * * Minute: :00. Hour: midnight. Day of month: the 1st. → Midnight on the first day of every month.

15 10 * * 6,0 Minute: :15. Hour: 10 AM. Day of week: Saturday and Sunday. → 10:15 AM every Saturday and Sunday.

0 22 * * 1-5 Minute: :00. Hour: 10 PM. Day of week: weekdays. → 10:00 PM, Monday through Friday.

*/30 9-17 * * 1-5 Minute: every 30 minutes. Hour: 9 AM–5 PM. Day of week: weekdays. → Every 30 minutes during business hours (9 AM–5 PM), Monday through Friday.

0 0 1 1 * Minute: :00. Hour: midnight. Day of month: the 1st. Month: January. → Midnight on January 1st once a year, on New Year's Day.

0 0 15 * 5 Minute: :00. Hour: midnight. Day of month: the 15th (restricted). Day of week: Friday (restricted). → Both day-of-month and day-of-week are restricted, so they OR together: midnight on the 15th of every month, or midnight every Friday not "the 15th, if it falls on a Friday." This is the gotcha covered in depth in Common Mistakes.

If you'd rather skip the manual walkthrough, pasting the expression into the Cron Expression Parser does exactly this decoding for you and shows the next several run times so you can sanity-check the result.

Common Cron Expression Examples

A reference table for the schedules developers write most often:

ScheduleExpression
Every minute* * * * *
Every 5 minutes*/5 * * * *
Every 15 minutes*/15 * * * *
Every 30 minutes*/30 * * * *
Every hour, on the hour0 * * * *
Every 6 hours0 */6 * * *
Every 12 hours0 */12 * * *
Every day at midnight0 0 * * *
Every day at noon0 12 * * *
Every weekday (Mon–Fri)0 9 * * 1-5
Every weekend (Sat–Sun)0 9 * * 0,6
Every Monday0 9 * * 1
Every Friday0 17 * * 5
First day of every month0 0 1 * *
Last day of every month (Quartz-style schedulers only)0 0 L * ?
Every business hour, weekdays0 9-17 * * 1-5
Every 10 minutes*/10 * * * *
Once a year (Jan 1st, midnight)0 0 1 1 *
Every quarter (1st of Jan/Apr/Jul/Oct)0 0 1 1,4,7,10 *

Cron Special Characters

* Asterisk (wildcard)

Matches every possible value for that field. * * * * * means every minute, of every hour, of every day. Supported everywhere this is part of the original POSIX cron spec. Example: 0 * * * * → hour field is *, meaning every hour. Common mistake: using * in every field when you meant a step value * * * * * (every minute) is very different from */5 * * * * (every 5 minutes), and it's an easy character to drop while editing.

, Comma (list)

Specifies multiple discrete values in one field. Supported everywhere. Example: 0 9,17 * * * → runs at 9 AM and 5 PM. Common mistake: adding spaces after the comma (9, 17). Most parsers expect no whitespace inside a field put a space between fields, never inside one.

- Hyphen (range)

Specifies an inclusive range of values. Supported everywhere. Example: 0 9 * * 1-5 → day-of-week 1 through 5, Monday through Friday, inclusive on both ends. Common mistake: assuming a range wraps around, e.g. expecting 5-1 to mean "Friday through Monday." It doesn't most cron parsers require the start value to be numerically smaller than the end value, and 5-1 is either rejected or treated as invalid.

/ Slash (step)

Specifies a step within a range or wildcard "every Nth value." Supported everywhere, though some very old cron implementations lack it. Example: */15 in the minute field → every 15 minutes, starting from 0. 10-40/10 → 10, 20, 30, 40. Common mistake: thinking */5 means "every 5 minutes from now." It means every 5 minutes aligned to 0 the step always starts counting from the field's minimum value.

? Question mark (no specific value)

Used only in the day-of-month or day-of-week field, exclusively in Quartz-based schedulers (not standard Linux cron). It means "I don't care about this field," and exists specifically to resolve the ambiguity of the day-of-month/day-of-week OR behavior you use ? in whichever of the two fields you're not constraining. Example (Quartz): 0 0 15 * ? → the 15th of every month, with day-of-week explicitly ignored. Common mistake: using ? in standard Linux crontab, where it's not valid syntax Linux cron uses * for "don't care" in every field, including day-of-month and day-of-week.

L Last

Means "last" last day of the month, or last occurrence of a weekday. Quartz and some modern schedulers only, not standard Linux cron. Example (Quartz): L in day-of-month → the last day of the month, whether that's the 28th, 30th, or 31st. 5L in day-of-week → the last Friday of the month. Common mistake: trying to use L in a standard Linux crontab and expecting it to work it will either error out or be silently ignored depending on the cron daemon.

W Weekday

Means "the nearest weekday" to a given day-of-month, skipping weekends. Quartz only. Example (Quartz): 15W → the nearest weekday to the 15th. If the 15th is a Saturday, it runs on the 14th (Friday); if it's a Sunday, it runs on the 16th (Monday). Common mistake: confusing W (nearest weekday to a date) with the day-of-week field's weekday range (1-5) they solve different problems. W anchors to a specific calendar date; 1-5 anchors to specific weekdays regardless of date.

# Hash (nth weekday)

Specifies the nth occurrence of a weekday in the month. Quartz only. Example (Quartz): 6#3 → the third Friday of the month (Quartz numbers Sunday as 1 through Saturday as 7, so 6 = Friday note this differs from standard cron's 0-indexed-from-Sunday scheme). Common mistake: applying standard cron's day-of-week numbering (0 = Sunday) to a Quartz # expression. Quartz uses 1-7 for Sunday through Saturday in this context, not 0-6 mixing the two schemes silently picks the wrong day.

Linux Cron vs Quartz Cron

The five-field format covered so far is standard POSIX/Vixie cron what runs in Linux crontab, systemd timers, and most simple schedulers. Quartz is a Java scheduling library, widely used inside enterprise Java applications and some CI/CD and workflow tools, and it extends cron syntax significantly. Confusing the two is one of the most common sources of "valid-looking" cron expressions that fail outright.

FeatureLinux / POSIX cronQuartz cron
Number of fields5 (minute, hour, day-of-month, month, day-of-week)6 or 7 (seconds, minute, hour, day-of-month, month, day-of-week, optional year)
Seconds fieldNot supportedSupported (first field)
L (last)Not supportedSupported in day-of-month and day-of-week
W (nearest weekday)Not supportedSupported in day-of-month
# (nth weekday)Not supportedSupported in day-of-week
? (no specific value)Not used use * insteadRequired in day-of-month or day-of-week (whichever is unused)
Day-of-month / day-of-week when both specifiedOR logic (either condition triggers the run)Must use ? in one of the two fields both being concrete values is invalid

The practical rule: if you're writing a Linux crontab entry, a Kubernetes CronJob, or a GitHub Actions schedule, stick to the plain five-field syntax none of them understand L, W, #, or ?. If you're configuring a Quartz-based scheduler (common in Java frameworks like Spring), you get the extended syntax, but you also take on the six/seven-field format and the requirement to use ? correctly.

Common Mistakes

Day-of-month and day-of-week combine as OR, not AND. This is the single most common source of "why did my job run on the wrong day" bugs. If you specify both fields as something other than *, standard cron treats them as either/or: 0 0 15 * 5 doesn't mean "the 15th, only if it's a Friday" it means "midnight on the 15th, or midnight every Friday," whichever comes first. To express "the 15th, but only if it's a Friday," you need application-level logic, since five-field cron syntax genuinely can't represent that AND condition.

Timezone confusion. Cron carries no timezone information of its own it runs in whatever timezone the executing system or platform defaults to. A schedule of 0 9 * * * fires at 9 AM in that system's configured timezone, which is UTC on most managed platforms and often not the timezone the person who wrote the schedule was thinking in.

Daylight Saving Time (DST) edge cases. On systems that run cron in local time with DST enabled, a job scheduled for 0 2 * * * can run twice on the "fall back" day (2 AM occurs twice) or not at all on the "spring forward" day (2 AM never occurs). This is one of the strongest arguments for running scheduled jobs in UTC rather than a DST-observing timezone.

Using Quartz syntax in standard Linux cron. Writing 0 0 L * ? into a Linux crontab file won't do what you expect L and ? aren't part of POSIX cron, and the entry will either be rejected or silently misinterpreted depending on the cron daemon's error handling.

Wrong weekday numbering. Assuming every platform numbers Sunday as 0 (standard cron) or Sunday as 1 (Quartz's # day-of-week numbering) interchangeably. Always confirm which convention the specific scheduler uses, especially when porting a schedule between platforms.

Misusing *. Writing * in a field you actually meant to constrain most commonly leaving day-of-week as * (every day) when the intent was "weekdays only," which silently makes a job run on weekends too.

Confusing step values with specific values. Writing 5 * * * * (runs once, at minute 5 of every hour) when the intent was */5 * * * * (runs every 5 minutes). The two look similar at a glance but produce very different schedules one run per hour versus twelve.

Timezones and Daylight Saving

Because a cron expression is just five numbers, it has no inherent concept of timezone that context comes entirely from whatever's executing the schedule. This is worth being deliberate about, because it's the single biggest cause of "the job ran three hours late" reports.

On a self-managed Linux server, cron runs in the system's configured local timezone (check with timedatectl or by inspecting /etc/timezone). If that server is set to America/New_York and you write 0 9 * * *, the job runs at 9 AM Eastern but if a teammate assumes the server runs in UTC and copies that schedule to a UTC-based system, the job now fires at 9 AM UTC, which is 4 or 5 AM Eastern depending on the time of year.

On managed platforms Vercel Cron, GitHub Actions, AWS EventBridge, most CI providers the default is almost always UTC, explicitly and without exception, regardless of your account's configured timezone or your local system clock. If you want "9 AM New York time" on one of these platforms, you either calculate the UTC offset yourself and hardcode it, or use a timezone-aware configuration option if the platform provides one (Kubernetes CronJobs added a timeZone field in newer versions; most others still require manual offset math).

Daylight Saving Time compounds this. A schedule expressed in a DST-observing local timezone shifts relative to UTC twice a year. 0 9 * * * running in America/New_York is 13:00 UTC during EST (winter) and 12:00 UTC during EDT (summer) same cron expression, different absolute time, because the system's UTC offset changed underneath it. If your job's timing genuinely doesn't matter to the minute, this is harmless. If it needs to run at a precise wall-clock time relative to a business event (market open, a daily cutoff), running the scheduler in UTC and doing the offset math explicitly avoids the twice-yearly drift entirely and sidesteps the "job runs twice" or "job doesn't run at all" edge case on the actual transition days.

How to Debug a Cron Expression

When you're staring at a cron expression you didn't write inherited from a teammate, buried in a Kubernetes manifest, or found in a CI config debug it in this order:

  1. Read the fields left to right, using the process from How to Read a Cron Expression above, rather than trying to absorb the whole string at once.
  2. Check whether day-of-month and day-of-week are both restricted. If so, remember the OR behavior before you conclude what the schedule does.
  3. Verify the timezone the scheduler actually runs in don't assume it matches your local machine or the timezone implied by the surrounding code comments.
  4. Preview the next several run times rather than trusting your mental math, especially for expressions involving steps or lists.
  5. Test before deploying a change if the platform has a dry-run or preview feature, use it; if not, decode the expression with a tool first.

The Cron Expression Parser handles steps 1, 2, and 4 in one pass it converts the expression into a plain-English description and lists upcoming run times, which makes the day-of-month/day-of-week OR trap obvious before it causes a production surprise. If a run time comes back as a raw Unix timestamp from a platform's API or logs, the Unix Timestamp Converter turns it into a readable calendar date without manual math.

Cron Cheat Sheet

Fields (left to right):

FieldRangeNotes
Minute0–59
Hour0–2324-hour format; 0 = midnight
Day of month1–31No day 0; OR's with day-of-week if both set
Month1–12JAN–DEC also accepted in many implementations
Day of week0–60 = Sunday; SUN–SAT also accepted; 7 = Sunday in some implementations

Symbols:

SymbolMeaningStandard cron?
*Every valueYes
,List of valuesYes
-Inclusive rangeYes
/Step valueYes
?No specific valueQuartz only
LLast day/weekdayQuartz only
WNearest weekdayQuartz only
#Nth weekday of monthQuartz only

Quick examples:

  • * * * * * every minute
  • */5 * * * * every 5 minutes
  • 0 * * * * every hour
  • 0 9 * * 1-5 9 AM, weekdays
  • 0 0 1 * * midnight, 1st of the month
  • 0 0 1 1 * midnight, January 1st

Bookmark this section, or keep the Cron Expression Parser open for anything more complex than these six.

FAQ

What is a cron expression? A cron expression is a five-field string (minute, hour, day-of-month, month, day-of-week) that defines a recurring schedule for a job scheduler. Each field can hold a wildcard, a specific value, a range, a list, or a step, and the scheduler runs the job whenever the current time matches all the fields.

What is cron expression syntax based on? It's based on the original Unix cron utility from the 1970s. The five-field format has stayed essentially unchanged since then and is now used well beyond Linux Kubernetes, GitHub Actions, AWS EventBridge, and most managed schedulers all use the same core syntax, sometimes with platform-specific extensions.

How do I read cron? Split the expression into its five fields and read them left to right minute, hour, day-of-month, month, day-of-week checking each one for a wildcard, value, range, list, or step. If both day-of-month and day-of-week are restricted at once, remember they combine as OR, not AND. The full walkthrough with ten worked examples is in How to Read a Cron Expression above.

How do I read a cron schedule I didn't write? Use the same left-to-right process, but also confirm the timezone the scheduler runs in before trusting the result the fields alone don't tell you that. When in doubt, decode it with the Cron Expression Parser, which shows both the plain-English meaning and the next several run times.

Conclusion

Cron expressions look cryptic mainly because five fields are packed with no visual separation between "what" and "when." Once you internalize the field order minute, hour, day-of-month, month, day-of-week and the two gotchas that catch almost everyone (the day-of-month/day-of-week OR behavior, and timezone handling), reading an unfamiliar schedule stops being guesswork.

For anything beyond a quick mental check, don't hand-parse a five-field string under time pressure paste it into the Cron Expression Parser and confirm both the plain-English meaning and the next run times before you trust it in production.

Try these next