---
title: Cron job ran on the wrong day? Test it with the Cron Expression Tester
description: A "first Monday of the month" job ran four days in a row. Cron treats day-of-month and day-of-week as OR when both are set, so 0 9 1-7 * 1 means the 1st to the 7th and every Monday. To test a cron expression, read it back in plain English and check its next run times before you deploy. Here's how with the free Cron Expression Tester, the fix, and how to check a schedule on the command line.
canonical: https://watchfor.io/blog/how-to-test-a-cron-expression
---

# Cron job ran on the wrong day? Test it with the Cron Expression Tester

2026-10-04 · WatchFor Team · Guides · guides, devops, automation

A "first Monday of the month" job ran four days in a row. Cron treats day-of-month and day-of-week as OR when both are set, so 0 9 1-7 * 1 means the 1st to the 7th and every Monday. To test a cron expression, read it back in plain English and check its next run times before you deploy. Here's how with the free Cron Expression Tester, the fix, and how to check a schedule on the command line.

To test a cron expression, read it back in plain English and list its next
few run times, then compare them with a calendar before the job goes live.
Most cron surprises show up immediately that way: a job that fires on
Sunday, at 09:00 UTC instead of 09:00 local, or every minute of an hour
instead of once.

The free [Cron Expression Tester](/cron-expression-tester) explains any
standard five-field expression, breaks it into its fields and shows the
next five runs in your timezone, as you type.

## What this tool answers

*When will this cron expression actually run?* Reach for it before adding a
line to a crontab, a Kubernetes CronJob, a CI schedule or a cloud
scheduler, and after a job ran when it shouldn't have (or didn't run when
it should). It settles the questions people get wrong from memory: field
order, `*/15` versus `15`, whether Sunday is 0 or 7, and what happens when
both day fields are set.

## A real example

### Testing a cron expression that runs on more days than you meant

The job: a monthly report, sent on the first Monday of each month at 09:00.
The expression someone wrote reads naturally, "days 1 to 7, Monday":

```text
0 9 1-7 * 1
```

The complaint after the first month: the report arrived several mornings
in a row. We typed the expression into the
[Cron Expression Tester](/cron-expression-tester), in a browser set to UTC
like most servers, on Saturday 3 October 2026:

![The Cron Expression Tester with 0 9 1-7 * 1 entered: the description reads "At 09:00, on the 1st, 2nd, 3rd, 4th, 5th, 6th and 7th of the month or on Monday", the field cards show 0, 9, 1-7, * and 1, and the next 5 runs in UTC are Sun Oct 4, Mon Oct 5, Tue Oct 6, Wed Oct 7 and Mon Oct 12, 2026, all at 09:00](/blog/how-to-test-a-cron-expression-result.webp)

The description says it outright: **"At 09:00, on the 1st, 2nd, 3rd, 4th,
5th, 6th and 7th of the month _or_ on Monday."** The next runs confirm it:
**Sunday 4, Monday 5, Tuesday 6, Wednesday 7 and Monday 12 October**. Only
one of those five is the first Monday.

This is documented behaviour, not a quirk of one tool. The crontab manual
(`man 5 crontab`) says that if both day-of-month and day-of-week are
restricted, "the command will be run when either field matches the current
time", and gives `30 4 1,15 * 5` (the 1st, the 15th, plus every Friday) as
its example.

**The answer and the fix:** standard cron can't express "first Monday" in
the schedule. Restrict one day field and test the other inside the command.
Run on days 1–7 and let the command continue only on a Monday:

```text
0 9 1-7 * * [ "$(date +\%u)" -eq 1 ] && /usr/local/bin/monthly-report.sh
```

`date +%u` prints 1 for Monday. The `%` is escaped as `\%` because cron
turns an unescaped `%` in the command into a newline. The tester shows
`0 9 1-7 * *` as "At 09:00, on the 1st … 7th of the month": seven
triggers a month, one of which does the work.

## How to read the results

| What you see | What it means |
| --- | --- |
| **Cron expression field** | Five fields separated by spaces: minute, hour, day of month, month, day of week. Updates on every keystroke. |
| **Preset chips** | Common schedules (every 5 minutes, hourly, weekdays at 9:00, 1st of the month…). The active one is highlighted. |
| **Description banner** | The schedule in English. An "or" between a date and a weekday is the OR rule at work. |
| **Field cards** | Each field as you wrote it, with its allowed range: 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, where 0 and 7 both mean Sunday. |
| **Next 5 runs · zone** | The next five times it fires, in your browser's timezone (named in the heading), with "in 11 h" or "in 3 days". |
| **Red error box** | Which field is invalid and why, e.g. a value out of range or a wrong number of fields. |
| **No runs found in the next 5 years** | The expression is valid but never matches, such as 31 February. |

It accepts lists (`1,15`), ranges (`9-17`), steps (`*/5`, `10-30/5`),
month and weekday names, and the aliases `@hourly`, `@daily`, `@weekly`,
`@monthly` and `@yearly`. `@reboot` has no schedule, so it can't be
previewed. There is no seconds field: six-field expressions come from Quartz
and some cloud schedulers.

## More ways to use it

- **Before a deploy.** Paste every new schedule from a Kubernetes CronJob or
  GitHub Actions `on: schedule` and read the five run times back. Both use
  five-field syntax; GitHub Actions schedules run in UTC.
- **After "it didn't run".** Type the expression from the server's crontab
  and check that the time you expected is in the list at all.
- **Explaining someone else's crontab.** `17 */4 * * 1-5` is easier to
  review as "Every 4 hours at minute 17, on weekdays".
- **Spreading load.** Jobs written as `0 * * * *` all fire at the top of
  the hour; move each one to a different minute and check nothing collides.

## Tips and gotchas

- **The server's timezone decides.** Cron uses the clock of the machine
  running it, often UTC. On Debian and Ubuntu a `TZ=` line in a crontab
  only changes the commands' environment, not when they fire (`man 5
  crontab`, LIMITATIONS); cronie (RHEL, Fedora) supports `CRON_TZ=`. Check
  with `timedatectl`.
- **Daylight saving shifts local schedules.** With a local timezone, a job
  at 02:30 can be skipped or delayed on the spring-forward night. Vixie
  cron runs jobs in skipped time soon after the change and doesn't repeat
  them when the clock goes back (`man 8 cron`); UTC avoids the question.
- **`%` in the command needs a backslash.** Unescaped, it ends the command
  and the rest becomes standard input. `date +%F` in a crontab must be
  written `date +\%F`.
- **Cron has a minimal environment.** `PATH` is short and your shell
  profile isn't loaded, so use absolute paths and redirect output
  (`>> /var/log/report.log 2>&1`), or failures vanish.
- **`*/7` doesn't mean "every 7 days".** In the day-of-month field it means
  days 1, 8, 15, 22 and 29, then the count starts again on the 1st.

## Check it from the command line too

There's no standard command to preview a crontab line, but systemd timers
can express "first Monday" directly, and `systemd-analyze` lists the next
times for any calendar spec:

```bash
TZ=UTC systemd-analyze calendar --iterations=3 'Mon *-*-01..07 09:00:00'
```

```text
Normalized form: Mon *-*-01..07 09:00:00
    Next elapse: Mon 2026-10-05 09:00:00 UTC
       From now: 1 day 10h left
   Iteration #2: Mon 2026-11-02 09:00:00 UTC
       From now: 4 weeks 1 day left
   Iteration #3: Mon 2026-12-07 09:00:00 UTC
       From now: 2 months 3 days left
```

In systemd syntax a weekday and a date range are combined with AND, so
this is the real first Monday: 5 October, 2 November, 7 December. If the
job runs on a systemd host, an `OnCalendar=Mon *-*-01..07 09:00:00` timer
replaces the cron line and the date test entirely. To see when cron last
ran something, `grep CRON /var/log/syslog` on Debian and Ubuntu or
`journalctl -u cron`.

## Keep watching it

A correct expression is half the job; the other half is knowing it ran. A
[heartbeat monitor](/heartbeat-monitoring) in **Cron job** mode takes the
same five-field expression and an IANA timezone, expects a ping from your
job at each scheduled time plus a grace period, and opens an incident when
the ping doesn't arrive or reports a non-zero exit code. Add
`&& curl -fsS <ping URL>` after the command. It validates the expression
when you save; unlike this tester it rejects `@daily`-style aliases and `7`
for Sunday (use `0`). [Cron monitoring recipes](/blog/cron-monitoring-recipes)
has copy-paste examples for backups, queues and renewals.

## FAQ

**How do I test a cron expression before deploying it?**
Paste it into a [cron expression tester](/cron-expression-tester), read
the English description and check the next run times against a calendar in
the server's timezone, not yours.

**What happens if both day of month and day of week are set in cron?**
The job runs when either matches. `0 9 1-7 * 1` runs on the 1st to the 7th
and on every Monday. Use one day field and test the other in the command.

**How do I run a cron job on the first Monday of the month?**
Schedule `0 9 1-7 * *` and start the command with
`[ "$(date +\%u)" -eq 1 ] &&`, so it only does work on the Monday among
those seven days.

---

Canonical page: https://watchfor.io/blog/how-to-test-a-cron-expression · All articles: https://watchfor.io/blog/all.md · Site guide: https://watchfor.io/llms.txt
