# Cron Expression Syntax: A Practical Guide with Examples

> Read and write cron schedules for minutes, hours, days and weekdays, avoid the classic day-field trap, and verify expressions before deploying them.

**Quick answer:** A standard cron expression has five fields: minute, hour, day of month, month and day of week. Each field accepts numbers, ranges, lists, steps and wildcards. Read it left to right, remember that day of month and day of week are combined with OR, and convert the schedule to a known timezone before deploying.

## Key takeaways

- Five fields are minute, hour, day of month, month and day of week, in that order.
- Step values such as */5 apply to the field they appear in, not to the whole schedule.
- Day of month and day of week are OR-ed in classic cron; setting both rarely means what people expect.
- Cron uses the server's local timezone; DST transitions can skip or repeat jobs.
- Test the expression and confirm the next run times before trusting it in production.

---

Cron expressions are compact but easy to misread. A single character changes the schedule, and the same expression can behave differently across cron implementations. This guide covers the syntax that matters in practice and the checks that catch mistakes before a job runs at the wrong time.

## The five fields

A standard crontab line has five time fields followed by the command:

```text
* * * * * command
│ │ │ │ │
│ │ │ │ └── day of week (0-7, Sunday is 0 or 7)
│ │ │ └──── month (1-12)
│ │ └────── day of month (1-31)
│ └──────── hour (0-23)
└────────── minute (0-59)
```

The fields are positional. `0 9 * * 1` means 09:00 on Monday, not 09:00 on the first day of the month.

The valid ranges are easy to misremember: minute 0–59, hour 0–23, day of month 1–31, month 1–12, and day of week 0–7 where both 0 and 7 mean Sunday. Out-of-range values are usually rejected at install time, but the error may only appear in the system log, so a crontab can look installed while the line is silently ignored.

A crontab also accepts shortcuts and syntax outside the five fields. `@daily`, `@hourly`, `@weekly`, `@monthly` and `@reboot` are widely supported, but they are not part of the POSIX specification and some minimal cron implementations reject them. Lines beginning with `#` are comments. Inside the command, an unescaped `%` is special: cron treats it as a newline and sends the following text as standard input, which is why a `date +%F` command can truncate itself. Escape it as `\%` or move the command into a script.

## Field syntax

Each field supports:

| Syntax | Meaning | Example |
| --- | --- | --- |
| `*` | Every value | `*` in minute means every minute |
| `5` | One value | Minute 5 |
| `1-5` | Range | Hours 1 through 5 |
| `1,15,30` | List | Minutes 1, 15 and 30 |
| `*/15` | Step from the field start | Every 15 minutes |
| `10-50/10` | Step within a range | Minutes 10, 20, 30, 40, 50 |

Steps restart at the beginning of each unit. `*/15` in the minute field runs at 0, 15, 30 and 45. It does not mean "15 minutes after the last run".

Vixie-derived crontabs also accept three-letter names for months and weekdays, such as `JAN`, `MON` and `FRI`, but not all implementations do. Ranges can include names, and lists can mix names with numbers, which makes a line harder to read than the numeric form. Pick one style and stay with it. A step with an explicit start, such as `5/15`, is interpreted differently across implementations: some read it as "start at 5, then every 15", others treat it as a syntax error. Write `5-59/15` when the start matters.

`10-50/10` is worth reading carefully: it means 10, 20, 30, 40 and 50, not every tenth value from the start of the field. If the range does not divide evenly by the step, the final addition may overshoot the range end; that value is dropped, and there is no wraparound within a single field.

## Common schedules

- Every minute: `* * * * *`
- Every five minutes: `*/5 * * * *`
- Every hour at minute 0: `0 * * * *`
- Every day at 03:30: `30 3 * * *`
- Weekdays at 09:00: `0 9 * * 1-5`
- First day of the month at midnight: `0 0 1 * *`
- Every Sunday at 23:00: `0 23 * * 0`
- Every 15 minutes during business hours: `*/15 9-17 * * 1-5`

These examples assume the server uses the timezone you expect. That assumption is worth checking.

Some schedules people ask for cannot be expressed cleanly. "The last day of the month" is not a cron field: day 31 does not run in shorter months. "The last weekday of the month", "the third Thursday" and "every 90 minutes" all fall outside the five-field grammar. Workarounds are to run daily and test the date in the command, or to use a scheduler with calendar or interval support. Trying to approximate these with clever ranges usually produces a line that is wrong once a year, which is exactly when nobody is watching.

## The day-of-month and day-of-week trap

In classic cron, if both day fields are restricted, the job runs when **either** condition matches. This expression:

```text
0 0 13 * 5
```

does not mean "Friday the 13th". It means "the 13th of the month OR any Friday". That is roughly 60 runs per year instead of one or two.

Rules of thumb:

- Restrict exactly one day field.
- Use `?` only in schedulers that support it, such as Quartz.
- If you need both conditions, schedule daily and check the date in the command, or use a scheduler with explicit calendar logic.

The arithmetic is worth doing once. Twelve monthdays plus roughly 52 Fridays is 64 candidate runs, minus the one or two months when the 13th falls on a Friday, so about 62 runs per year. If the intent was Friday the 13th, the expression is wrong by a factor of about 30.

This is not a bug in one implementation; POSIX specifies that when both day fields are restricted, the job runs when either matches. Vixie cron and its descendants implement that. Quartz takes the opposite approach and requires `?` in one of the two fields, which is why expressions copied from Linux to Quartz may be rejected. If both day fields are `*`, every day matches and the OR is harmless.

## Timezones and daylight saving time

Cron runs in the server's local timezone unless the implementation explicitly supports a timezone setting. That creates two problems:

- Servers in different regions run the same crontab at different absolute times.
- DST transitions can skip an hour or repeat it.

For schedules that must be stable, prefer UTC. If a business schedule must follow a local timezone, set the timezone explicitly where the scheduler allows it, and test the transition dates. A job scheduled at 02:30 local time may not run on the day clocks move forward.

Take a concrete case. In the United States, clocks move forward on 2026-03-08 at 02:00 local time, so 02:00–02:59 does not exist. A job at `30 2 * * *` in `America/New_York` has no matching instant that day; depending on the implementation it is skipped, or it runs at 03:00 along with the jobs scheduled for 03:00. On 2026-11-01 the clock repeats 01:00–01:59, so a job at `30 1 * * *` may run twice, with the same wall-clock label but two different UTC instants. Europe has its own dates, 2026-03-29 and 2026-10-25, and the skipped hour is 02:00–02:59 as well. If the job charges cards, sends reminders or rotates credentials, running twice is usually worse than running once late.

Implementation behavior varies, which is why testing matters. Vixie cron and its descendants watch for clock changes and have explicit DST handling, but the exact result differs by version and distribution: a job in the skipped interval may run once after the jump, and a job in the repeated interval may run twice. Debian's cron, cronie and BusyBox cron are not identical here. systemd timers resolve the next elapse using calendar semantics and document their DST behavior, but the result still depends on the `OnCalendar` expression. Kubernetes CronJob honors `timeZone` in recent versions but leaves missed-run behavior to `startingDeadlineSeconds`. The safe pattern is to schedule in UTC when the job's meaning is absolute, and to make the job idempotent when it must follow local business hours.

## Cron is not an interval timer

Cron describes wall-clock times, not elapsed intervals. A job that takes 40 minutes and is scheduled every 30 minutes will overlap. A job scheduled at midnight will not run at the same interval after a DST change.

If you need "every 90 minutes" or "20 minutes after the last completion", cron is the wrong tool. Use a queue, a worker loop, or a scheduler with interval and concurrency controls. If you must stay with cron, add a lock so two runs cannot overlap.

The overlap is easy to picture. A job started at 00:00 that takes 40 minutes is still running when cron starts the next one at 00:30. Both write to the same output file, both increment the same counter, and the second may read a half-written state. A lock file with `flock -n` makes the second run exit immediately, which is usually better than queueing, but the skipped run must be acceptable. If it is not, cron is the wrong layer and a queue is the right one.

systemd timers offer a middle ground: `OnUnitActiveSec=90min` schedules relative to the last activation rather than wall-clock slots, and systemd will not start a second instance of the same service while the first is active. This is often the cleanest fix on a modern Linux host without adding a queue. It also gives you `systemctl list-timers` as a verification tool.

## Environment and path differences

A crontab runs with a minimal environment. Common failures:

- `command not found` because `PATH` is shorter than in an interactive shell.
- Relative paths resolving from the wrong directory.
- Environment variables from `.bashrc` or `.zshrc` not being loaded.
- Output disappearing because mail is not configured.

Use absolute paths, set the variables you need inside the crontab or a wrapper script, and redirect output to a log file.

The default environment is thinner than most people expect. `PATH` is commonly `/usr/bin:/bin`, `SHELL` is `/bin/sh`, and `HOME` may be set while `LANG`, `NODE_ENV` and application secrets are not. A command that works in your interactive shell can fail because it lives in `/usr/local/bin`, because a version manager shim is missing, or because the working directory is the user's home directory rather than the project. Cron also does not read `.bashrc` or `.zshrc`; those files are for interactive shells.

Output handling is part of the job. Standard output and standard error are mailed to the user if a mail system is configured, and silently discarded if it is not. Redirect both to a log file with timestamps, or wrap the command in a script that logs to syslog or a structured logger. Without this, the first sign of failure is a stale dashboard, which is much later than you want.

## Verify before deploying

1. Convert the expression to a readable sentence.
2. Check the next ten run times in the target timezone.
3. Confirm the scheduler dialect: 5 fields or 6 fields.
4. Check the day-field behavior.
5. Test the command manually with the same user and environment.
6. Add logging and a failure alert.

The [cron expression generator](/tools/cron) shows upcoming run times and helps translate an expression into plain language. When a schedule crosses timezones, convert the timestamps with the [Unix timestamp converter](/tools/unix-timestamp) to compare absolute moments.

Two verification tools catch different classes of error. The generator confirms the expression's meaning, but it does not know your server's timezone. `systemd-analyze calendar '*-*-* 02:30:00'` prints the next elapses for a systemd expression and handles DST explicitly; run it around the transition dates. For a classic crontab, temporarily change the schedule to a near-future minute, watch the log, then restore it. A dry run through the actual scheduler is the only way to catch environment and permission problems.

## Cron variants

Not every cron is classic cron:

- **Vixie cron** and most Linux crontabs use five fields and the OR behavior described above.
- **Quartz** uses six or seven fields, including seconds, and supports `?`, `L` and `W`.
- **Kubernetes CronJob** uses five fields and a separate `timeZone` field in recent versions.
- **Cloud schedulers** often add timezone, retry and catch-up options.

Copying an expression between systems without checking the dialect is a reliable way to get a schedule that runs too often or never.

Two more dialects are common in deployment pipelines. GitHub Actions uses five-field POSIX syntax but always evaluates in UTC, and its shortest interval is five minutes; an expression copied from a local-time server will drift by the UTC offset. AWS EventBridge uses six fields with a year field and requires `?` in either day-of-month or day-of-week, and it evaluates in the schedule's configured timezone. Jenkins uses a different syntax again, with `H` for hashed values and a separate `@daily` form. The expression may look familiar, but the parser behind it is not the one you tested.

## A deployment checklist

- The expression has the expected number of fields.
- Only one day field is restricted.
- The timezone is explicit and correct.
- The next run times match the requirement.
- The command uses absolute paths.
- Output is logged.
- Overlapping runs are prevented.
- A failed run produces an alert.
- The DST transition dates were checked, not just today's next run.
- The job is idempotent or safe to retry.

A cron expression is a contract with the clock. Read it field by field, check the dialect and timezone, and verify the next runs before it reaches production.

Cron is the wrong tool when a job must run exactly every N minutes, when runs routinely take longer than the interval, when the schedule depends on an event rather than a time, or when missed runs must be caught up after downtime. In those cases use a queue with a visibility timeout, a workflow engine with durable timers, or systemd timers with explicit catch-up semantics. Cron is excellent at one thing: starting a short, independent command at a wall-clock time. Use it for that and put everything else on a scheduler that models the requirement.

## Frequently asked questions

### What does */5 * * * * mean?

Every five minutes: the minute field is */5, and all other fields are wildcards. It runs at minute 0, 5, 10 and so on, in the server's local timezone.

### Why does 0 0 13 * 5 run more often than expected?

Classic cron treats day of month and day of week as a union. The expression runs on the 13th of the month OR on every Friday, not only on Friday the 13th. Use a single day field or a different scheduler when you need both conditions.

### How do I run a job every 90 minutes?

A single cron line cannot express a 90-minute interval cleanly, because the minute and hour fields repeat over a fixed 24-hour cycle. Use two staggered entries, a wrapper script with a loop, or a scheduler that supports interval syntax.

### Does cron handle daylight saving time?

Cron follows the system clock. When clocks move forward, a job scheduled in the skipped hour may not run. When clocks move back, it may run twice. For critical jobs, use UTC or a scheduler with explicit timezone and DST handling.

### What is the difference between 5-field and 6-field cron?

Five-field cron covers minute through day of week. Six-field variants, such as Quartz, usually add a seconds field at the front. Some also add a year field. Confirm the format of the scheduler you are using before copying an expression.

## References

- [crontab(5) - Linux manual page](https://man7.org/linux/man-pages/man5/crontab.5.html)
- [cron - Arch Linux wiki](https://wiki.archlinux.org/title/Cron)
- [systemd.timer - manual page](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html)
- [RFC 3339 - Date and Time on the Internet](https://www.rfc-editor.org/rfc/rfc3339)

---

- Canonical: https://www.jsonmind.com/guides/cron-expression-guide
- Locale: en
- Published: 2026-11-10
- Updated: 2026-11-10
- Tools: cron, unix-timestamp