Month-end jobs are everywhere: billing runs, reconciliation, quota resets, report generation. And standard Unix cron has no way to express them.
The day-of-month field takes 1 to 31. It cannot say "whichever is last", because that varies — 31 days in January, 30 in April, 28 or 29 in February. Writing `0 0 31 * *` gives you a job that silently skips February, April, June, September and November. That is seven runs a year instead of twelve, and the failure is invisible until someone notices the missing reports.
Here are the three approaches that actually work.
1. The date-check idiom (standard cron)
This is the portable answer, and the one to use unless you are on a scheduler that supports `L`.
0 0 28-31 * * [ "$(date -d tomorrow +%d)" = "01" ] && /path/to/job.shHow it works: cron fires on every candidate day — the 28th, 29th, 30th and 31st. The shell test then asks a simple question: *is tomorrow the 1st?* Only on the actual last day of the month is that true, so the job runs exactly once.
It handles February automatically, leap years included, because it never needs to know how long the month is.
On BSD and macOS, `date -d` is not available. Use:
0 0 28-31 * * [ "$(date -v+1d +%d)" = "01" ] && /path/to/job.shA caution about `%`: cron treats an unescaped `%` as a newline. Any `date` format string in a crontab needs it escaped as `\%`, or the command will be truncated. This is the single most common reason a correct-looking idiom fails to run.
2. The `L` operator (Quartz and friends)
If your scheduler supports it, this is far more readable:
0 0 0 L * ? ← Quartz: midnight on the last day of every month`L` in the day-of-month field means "last". Support is genuinely widespread — but only outside standard Unix cron:
| Scheduler | Supports `L` |
|---|---|
| Quartz (Java) | Yes |
| Jenkins | Yes |
| AWS EventBridge | Yes |
| Azure Functions (NCRONTAB) | Yes |
| robfig/cron (Go) | Yes |
| dragonmantank/cron-expression (PHP) | Yes |
| Standard Unix crontab | No |
| Kubernetes CronJob | No |
| GitHub Actions | No |
Note the field count. Quartz uses six or seven fields with seconds first, so `0 0 0 L * ?` is second, minute, hour, day-of-month, month, day-of-week. Copying that into a Unix crontab will not work.
Quartz also supports `L-3` for "three days before the end of the month", and `LW` for "the last weekday of the month" — useful for finance jobs that must not land on a Saturday.
3. Enumerating the months
If you cannot use a shell test and cannot use `L`, you can spell it out with three entries:
0 0 31 1,3,5,7,8,10,12 * /path/to/job.sh # 31-day months
0 0 30 4,6,9,11 * /path/to/job.sh # 30-day months
0 0 28 2 * /path/to/job.sh # FebruaryIt works, but note the February line runs on the 28th every year — including leap years, when the last day is the 29th. If that matters, you are back to the date-check idiom. Three separate entries also means three places to update when the script path changes.
Which one to use
- Standard cron, any Unix: the date-check idiom. Portable and correct, including leap years.
- Quartz, Jenkins, EventBridge, Azure: use `L`. Clearer and self-documenting.
- Kubernetes CronJob: schedule `0 0 28-31 * *` and put the date check in the container's entrypoint. Kubernetes does not support `L`.
- Enumeration: only when you cannot run a shell test at all, and you accept the February caveat.
You can sanity-check the candidate-day part of any of these with the cron expression builder, which shows the next run dates so you can confirm it fires on the 28th through 31st as expected.
The timezone trap
Month-end jobs are unusually sensitive to timezones, because "the last day of the month" is a different instant in different places. A job running at 00:00 local on 31 March is already 1 April in some regions.
If the job produces financial or reporting data, decide explicitly whether the boundary is local time or UTC, and set the scheduler's timezone rather than relying on the host's. Most managed schedulers let you specify one; plain crontab uses the system timezone, which someone may change without telling you.
Frequently asked questions
Will `0 0 31 * *` just skip short months?
Yes, silently. It runs seven times a year, and nothing logs an error. This is the bug this page exists to prevent.
Does the date-check idiom cost anything by firing on the 28th to 30th?
Almost nothing — cron starts a shell, runs one `date` call, and exits. On non-final days it does no work.
How do I run on the last weekday** of the month?**
In Quartz, `LW`. In standard cron, extend the test: fire on 28-31 weekdays only with `0 0 28-31 * 1-5` and check inside the script whether the next weekday falls in the following month.
What about the *first* day of the month?
That one is easy, because every month has a 1st: `0 0 1 * *`, or the shorthand `@monthly`.
Can I test this without waiting a month?
Temporarily change the test to compare against a different day, or run the command manually with a faked date. Do not wait for a real month-end to find out it does not work.
Related reading
The cron cheat sheet covers the twenty schedules that come up most often, including the day-of-month and day-of-week OR trap. If your jobs log Unix timestamps, the timestamp converter turns them into readable dates for debugging.