# Cron Last Day of Month, Last Weekday, and First Monday

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/cron-last-day-of-month

> Standard cron has no L. Run a job on the last day of the month, the last weekday, or the first Monday with a crontab date test or native syntax, and monitor it.

**To run a cron job on the last day of the month, schedule it on the 28th through the 31st and have the command check that tomorrow is the 1st, because standard five-field cron has no syntax for "last day."** With GNU date, the crontab line is `0 9 28-31 * * [ "$(date -d tomorrow +\%d)" = 01 ] && /opt/app/month-end.sh`. Quartz, Hangfire, Spring, and AWS EventBridge Scheduler have an `L` for "last," and systemd timers count back from the month's end with `~`.

The same kind of date test handles the last weekday and the first Monday, with one trap: when both day fields are restricted, Linux cron runs on either. Behavior here was checked on October 25, 2026 against cronie 1.7.2 (the cron daemon on Fedora, RHEL, and Arch), Debian's cron 3.0pl1-197 (Debian, Ubuntu), and BusyBox 1.37.0 (Alpine). For cron syntax in general, see [cron expression syntax](https://cronradar.com/blog/cron-expression).

## Why Five-Field Cron Has No Last-Day Syntax

POSIX gives each crontab field an asterisk, numbers, ranges, and lists ([crontab](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/crontab.html)), and none of them depends on the month's length. The Linux daemons add steps and names, but not `L`. cronie rejects it as a "bad day-of-month" ([entry.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L245-L256)). BusyBox crond logs a parse error and keeps the line with an empty day-of-month, so `0 9 L * *` never runs ([crond.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L320-L324)). Debian's [crontab(5)](https://manpages.debian.org/trixie/cron/crontab.5.en.html) lists the last weekday of a month among its limitations and says "the best approach would be to have the program itself check."

## Schedulers With Native Last-Day Syntax

Quartz-style syntax adds `L` (last), `W` (nearest weekday), and `#` (nth weekday of the month). Each expression below runs at 09:00:

| Scheduler                                                                                                                                                             | Last day            | Last weekday                                      | Last Friday          | First Monday           |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------- | -------------------- | ---------------------- |
| [Quartz and Quartz.NET](https://github.com/quartznet/quartznet/blob/v4.6.0/src/Quartz/CronExpression.cs#L136-L180), seconds first                                     | `0 0 9 L * ?`       | `0 0 9 LW * ?`                                    | `0 0 9 ? * 6L`       | `0 0 9 ? * 2#1`        |
| [Hangfire 1.8 (Cronos)](https://github.com/HangfireIO/Cronos/blob/v0.11.1/README.md)                                                                                  | `0 9 L * *`         | `0 9 LW * *`                                      | `0 9 * * 5L`         | `0 9 * * MON#1`        |
| [Spring `CronExpression`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/support/CronExpression.html), seconds first | `0 0 9 L * *`       | `0 0 9 LW * *`                                    | `0 0 9 * * 5L`       | `0 0 9 ? * MON#1`      |
| [AWS EventBridge Scheduler](https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html)                                                               | `cron(0 9 L * ? *)` | Not documented                                    | `cron(0 9 ? * 6L *)` | `cron(0 9 ? * 2#1 *)`  |
| [systemd `OnCalendar=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html)                                                                    | `*-*~01 09:00`      | `Mon..Thu *-*~01 09:00` plus `Fri *-*~03/1 09:00` | `Fri *-*~07/1 09:00` | `Mon *-*-01..07 09:00` |
| Linux cron, Kubernetes CronJob, GitHub Actions                                                                                                                        | None                | None                                              | None                 | None                   |

Weekday numbers differ: Quartz and EventBridge count Sunday as 1, so Friday is `6`, while Spring and Cronos count Sunday as 0, so Friday is `5`. Quartz warns against combining `L` with lists or ranges. [Hangfire cron expressions](https://cronradar.com/blog/hangfire-cron-time-zones) covers Cronos's `L-2`, `W`, and day-field rules.

systemd's `~` counts back from the month's end ([calendarspec.c](https://github.com/systemd/systemd/blob/v262.1/src/shared/calendarspec.c#L1105-L1166)), and systemd.time(7) gives `Mon *-05~07/1` as "the last Monday in May" (systemd 262, checked October 25, 2026). With no last-weekday token, the two lines catch a last day from Monday to Thursday and a Friday in the last three days. Preview any of them with `systemd-analyze calendar --iterations=3`, and see [systemd timers](https://cronradar.com/blog/systemd-timers) for the units.

Kubernetes 1.37.1 parses `schedule` with robfig/cron v3.0.1's standard parser ([parsers.go](https://github.com/kubernetes/kubernetes/blob/v1.37.1/pkg/util/parsers/parsers.go#L59-L69)), which has no `L` and fails validation on it ([parser.go](https://github.com/robfig/cron/blob/v3.0.1/parser.go#L331-L337)). GitHub's [schedule docs](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule) list only the `*`, `,`, `-`, and `/` operators. Both were checked October 25, 2026.

## The Crontab Workaround: Days 28-31 Plus a Date Test

```text
0 9 28-31 * * [ "$(date -d tomorrow +\%d)" = 01 ] && /opt/app/month-end.sh
```

The 28th through the 31st include every month's last day, so cron starts the line one to four times a month, and `&&` runs the job only when tomorrow's day of the month is `01`.

Write `\%`: cronie and Debian's cron turn a bare `%` into a newline, and cronie strips the backslash from `\%` before the shell runs ([do_command.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/do_command.c#L165-L199)). BusyBox crond passes the line to `sh -c` unchanged ([crond.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L868)), so the shell removes the backslash, which it does only outside quotes. An unquoted `+\%d` works in all three.

### Tomorrow's Date Without GNU date

`-d tomorrow` is GNU coreutils syntax ([manual](https://www.gnu.org/software/coreutils/manual/html_node/Relative-items-in-date-strings.html)):

| `date` from                                       | Tomorrow's day of the month               | Caveat                                                                                                                                                                                                       |
| ------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| GNU coreutils: Debian, Ubuntu, Fedora, RHEL, Arch | `date -d tomorrow +%d`                    | Can be wrong from 23:00 on the night before clocks go forward, below                                                                                                                                         |
| FreeBSD, macOS                                    | `date -v+1d +%d`                          | Day adjustments ignore daylight saving time ([date(1)](https://man.freebsd.org/cgi/man.cgi?query=date&sektion=1))                                                                                            |
| BusyBox: Alpine, minimal images                   | `date -d @$(( $(date +%s) + 86400 )) +%d` | `-d` takes absolute times and `@seconds` only ([date.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/coreutils/date.c#L113-L121)); exact only in a zone without daylight saving time, such as UTC |

GNU date moves the calendar date but keeps today's daylight-saving flag ([parse-datetime.y](https://github.com/coreutils/gnulib/blob/9670f9bf65dd64c90e15a8e01dec579ea7237e3b/lib/parse-datetime.y#L2158-L2190)), and glibc applies the UTC offset that flag implies ([mktime.c](https://github.com/bminor/glibc/blob/glibc-2.41/time/mktime.c#L426-L436)). So on the night before clocks go forward, a run at 23:00 or later lands past midnight: in Berlin, where that happens on March 31, 2030, a 23:30 run on March 30 sees tomorrow as April 1 and does the work a day early, then again on the 31st. Keep the job an hour from midnight, run the host on UTC, or use the check below.

### A Portable Check With No Date Arithmetic

This script computes the month's length instead. It's POSIX sh, so it runs under dash, bash, BusyBox ash, and macOS, and it reads the date once, so a run that straddles midnight can't mix two days:

```bash
#!/bin/sh
# /usr/local/bin/today-is: exit 0 if today matches the rule, 1 if not, 2 on bad usage.
# Uses the zone in TZ, or the system zone when TZ is unset. TODAY overrides the date for testing.
rule=$1
set -- ${TODAY:-$(date '+%Y %m %d %u')}
year=$1
month=${2#0}
day=${3#0}
weekday=$4 # 1 is Monday, 7 is Sunday

case $month in
  4 | 6 | 9 | 11) last=30 ;;
  2)
    if [ $((year % 4)) -eq 0 ] && { [ $((year % 100)) -ne 0 ] || [ $((year % 400)) -eq 0 ]; }; then
      last=29
    else
      last=28
    fi
    ;;
  *) last=31 ;;
esac

case $rule in
  last-day) [ "$day" -eq "$last" ] ;;
  last-weekday)
    # Monday-Thursday counts only on the last day; Friday counts when only a weekend is left.
    if [ "$weekday" -le 4 ]; then
      [ "$day" -eq "$last" ]
    elif [ "$weekday" -eq 5 ]; then
      [ "$day" -ge $((last - 2)) ]
    else
      exit 1
    fi
    ;;
  last-friday) [ "$weekday" -eq 5 ] && [ $((day + 7)) -gt "$last" ] ;;
  *)
    echo "usage: today-is last-day|last-weekday|last-friday" >&2
    exit 2
    ;;
esac
```

The crontab line then needs no `%`:

```text
0 9 28-31 * * /usr/local/bin/today-is last-day && /opt/app/month-end.sh
```

To test a rule, set `TODAY`. February 26, 2027 is a Friday and February 28 is a Sunday, so this prints `0`:

```bash
TODAY='2027 02 26 5' /usr/local/bin/today-is last-weekday; echo $?
```

### Or Run on the 1st

If the job summarizes a month that ends at midnight, run it at 00:05 on the 1st (`5 0 1 * *`): no test, and every scheduler and monitor can express it. To name the previous month, GNU's manual counts back from the 15th, since `-1 month` from the 31st can land in the same month: `date -d "$(date +%Y-%m-15) -1 month" +%Y-%m`.

## Run the Test in the Schedule's Time Zone

The test uses the zone in the job's `TZ`, or the system zone when `TZ` is unset. The schedule may follow another: cronie's `CRON_TZ`, a Kubernetes CronJob's `.spec.timeZone`, GitHub Actions' `timezone`, or an EventBridge schedule's zone. Then the test answers for the wrong date. On a server set to UTC:

```text
CRON_TZ=America/New_York
0 22 28-31 * * /usr/local/bin/today-is last-day && /opt/app/month-end.sh
```

At 22:00 in New York on October 30, 2026, UTC is already at 02:00 on October 31, so the job runs a day early; on the 31st, UTC has reached November 1. cronie uses `CRON_TZ` only to match the schedule and doesn't set `TZ` for the command ([cron.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/cron.c#L563-L603)), so the test sees the system zone. Add `TZ=America/New_York` below `CRON_TZ`; elsewhere, set `TZ` in the job's environment to the schedule's zone. [Cron timezone](https://cronradar.com/blog/cron-timezone) covers `CRON_TZ` and the daemons that lack it.

## Last Weekday, First Monday, and Other Nth Weekdays

The last weekday always falls in the month's last three days, so the 26th is the earliest, in a common-year February that ends on a Sunday. The last Friday is the one with no Friday a week later:

```text
0 18 26-31 * * /usr/local/bin/today-is last-weekday && /opt/app/payroll-export.sh
0 9 * * 5 /usr/local/bin/today-is last-friday && /opt/app/team-report.sh
```

`0 9 1-7 * 1` looks like the first Monday, but when both day fields are restricted, Linux cron runs on dates that match either. That's true of cronie ([cron.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/cron.c#L578-L584)), Debian's cron (per its crontab(5)), BusyBox ([crond.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L909-L914)), and the robfig/cron parser behind Kubernetes ([spec.go](https://github.com/robfig/cron/blob/v3.0.1/spec.go#L179-L188)), so the line runs on the 1st through the 7th and on every Monday. Restrict the day of month and test the weekday:

```text
0 9 1-7 * * [ "$(date +\%u)" = 1 ] && /opt/app/monthly-review.sh
```

`%u` prints 1 for Monday through 7 for Sunday. For the second through fourth occurrence, use 8-14, 15-21, or 22-28; a fifth falls on 29-31 in months that have one.

## Monitor Every Candidate Date, Not Just the Last One

A monthly job is easy to lose: if a deploy drops the line, nothing complains until someone needs the output. A [dead man's switch](https://cronradar.com/blog/cron-dead-mans-switch) alerts when an expected check-in is late.

CronRadar reads standard five-field cron in UTC and rejects `L`, `W`, and `#` with `400 INVALID_REQUEST` ([schedule syntax](https://docs.cronradar.com/schedules)). Its documented approach mirrors the crontab: give the monitor every candidate date, `0 9 28-31 * *`, and check in on every invocation, including the ones that exit early.

CronRadar then expects a run at 09:00 UTC on each of those dates, because it can't know which is last. If only the last day checks in, the 28th stays silent: the monitor turns `warning` after 09:00 and `critical`, with an incident, once the grace period passes. That's up to three false missed-run alerts a month, and no narrower five-field schedule avoids them.

There's no CronRadar SDK for cron and no auto-discovery, so each job reports with its own HTTP check-in. This wrapper reports `start`, then `complete` straight away if today isn't the last day. On the last day it runs the job and reports its result:

```bash
#!/bin/sh
# /opt/app/month-end-monitored.sh: cron runs this at 09:00 UTC on the 28th-31st.
url="https://cron.life/api/v1/ping/month-end-close"
run_id="month-end-close-$(date +%s)-$$"

report() {
  event=$1
  shift
  curl --fail --silent --show-error --max-time 5 --output /dev/null \
    --request POST --get --user "$CRONRADAR_API_KEY:" \
    --data-urlencode "schedule=0 9 28-31 * *" \
    --data-urlencode "gracePeriod=900" \
    --data-urlencode "run_id=$run_id" \
    "$@" "$url/$event" || true
}

report start
/usr/local/bin/today-is last-day
case $? in
  0) ;; # The last day: do the work below.
  1)
    # Not the last day. Report a finished run so this invocation isn't counted as missed.
    report complete
    exit 0
    ;;
  *)
    report fail --data-urlencode "message=today-is could not run"
    exit 1
    ;;
esac

/opt/app/month-end.sh
rc=$?
if [ "$rc" -eq 0 ]; then
  report complete
else
  report fail --data-urlencode "message=month-end.sh exited with code $rc"
fi
exit "$rc"
```

Schedule the wrapper instead of the job: `0 9 28-31 * * /opt/app/month-end-monitored.sh`. Each report gives up after five seconds and ignores errors, and the wrapper exits with the job's own code. A missing or broken `today-is` reports `fail` rather than a quiet success while the work never runs. The first report creates the monitor; its schedule and grace period apply only then, so change an existing monitor in its settings.

The schedule assumes a UTC server. Otherwise, convert it to UTC, shifting the day fields if the conversion crosses midnight. With daylight saving time, use the earlier of the two UTC times and a grace period of at least an hour plus the job's usual duration.

A `complete` on the 28th proves the job ran and decided, not that it decided right: a test reading the wrong zone reports on time while working on the wrong day. Test the rule with `TODAY`, and check the output after the first month end.

### First Monday Monitors Differently

CronRadar requires a date to match both day fields, so to CronRadar, `0 9 1-7 * 1` is the first Monday. Keep the crontab line from the previous section, put the check-in in the script it starts, and send `schedule=0 9 1-7 * 1`. The script then checks in exactly when the monitor expects it. `0 9 8-14 * 2` is the second Tuesday the same way.

A crontab line that restricts both fields on purpose, such as `0 9 1 * 1` for every 1st and every Monday, needs two monitors: split it into `0 9 1 * *` and `0 9 * * 1`, each with its own key. Both run on a Monday the 1st, so if the job mustn't run twice, keep one line and have its wrapper report to each monitor whose day matches.

## Frequently Asked Questions

### How do I run a cron job on the last day of the month?

Schedule it on days 28-31 and run the command only when tomorrow is the 1st: `0 9 28-31 * * [ "$(date -d tomorrow +\%d)" = 01 ] && /opt/app/month-end.sh`. FreeBSD and macOS use `date -v+1d` instead, and Quartz-style schedulers take `L`.

### Does crontab support L?

No. `L` comes from Quartz-style syntax, not POSIX cron: cronie rejects it, BusyBox crond logs a parse error, and Kubernetes CronJobs fail validation. Quartz, Hangfire, Spring, and AWS EventBridge Scheduler accept it.

### How do I run a cron job on the last weekday of the month?

Schedule it on days 26-31 and let a test decide: run on the last day if it's Monday through Thursday, or on a Friday with only a weekend left. Quartz, Hangfire, and Spring accept `LW` in the day-of-month field.

### How do I run a cron job on the first Monday of the month?

Use `0 9 1-7 * *` with a weekday test: `[ "$(date +\%u)" = 1 ] && /opt/app/monthly-review.sh`. Don't write `0 9 1-7 * 1` in a crontab: Linux cron runs it on the 1st through 7th and on every Monday.

### How do I monitor a job that only runs on the last day of the month?

Give the monitor the same candidate dates as the crontab, such as `0 9 28-31 * *` in UTC, and check in on every invocation, including the ones where the job decides to skip the work. If only the last day checks in, the monitor flags the other candidate days as missed runs.
