Last of the Month Cron Expression
Runs on the last day of every month — Unix vs Quartz approaches.
0 0 28-31 * * in POSIX cron
Expression
0 0 28-31 * *Try it in the tester →Field Breakdown
How It Works
Standard Unix cron has no native “last day” token, so the common workaround is `0 0 28-31 * *` combined with a script that checks `date -d tomorrow +%d` == 01. Quartz has a dedicated `L` token: use `0 0 0 L * ?` instead.
Example Run Times
- Jan 28 00:00:00
- Jan 29 00:00:00
- Jan 30 00:00:00
- Jan 31 00:00:00
- Feb 28 00:00:00
Frequently Asked Questions
Why does Unix cron not support `L`?
`L` (last) is a Quartz extension. Unix cron has minute-granular pattern matching only — no “relative to month end” token. The classic workaround is a shell guard inside the job.
What is the Quartz equivalent?
`0 0 0 L * ?` — fires at midnight on the last day of every month, automatically handling 28/29/30/31.
0 0 28-31 * * in Quartz / Spring
The L token tells Quartz to fire at midnight on the true last day of each month — the 31st of January, the 28th (or 29th) of February, the 30th of April — exactly twelve firings a year with zero guesswork. This is a schedule plain five-field cron simply cannot express in one line.
Quartz / Spring expression
0 0 0 L * ?Try it in the tester →At 00:00:00 on the last day of the month
Unix / POSIX equivalent: 0 0 28-31 * *
Field Breakdown
Using It with Quartz / Spring
Month-end close work lives here: freezing the period's ledger entries, capturing closing balances, generating statements dated to the month's final day, or kicking off an export the accounting team expects on the last calendar day. Spring 5.3 and later parse L natively, so @Scheduled(cron = "0 0 0 L * ?") works in a modern Boot app; standalone Quartz has supported it for decades via CronTrigger. Teams migrating from Unix cron should delete their last-day guard scripts entirely — the token replaces both the cron line and the shell logic around it.
Quartz-Specific Notes
- →The POSIX workaround 0 0 28-31 * * is not equivalent on its own: a bare range matches every day in it, so that line fires four mornings in a row each 31-day month and relies on a wrapper script checking whether tomorrow is the 1st. L fires once, on the right day, with no wrapper.
- →L adjusts to the calendar automatically — February 28th in common years, the 29th in leap years — so leap-year handling costs you nothing.
- →Quartz also accepts an offset: L-2 in day-of-month means two days before month end (the 29th of a 31-day month, the 26th of February). LW means the last weekday of the month.
- →Spring versions before 5.3 reject L; on older Spring, delegate the trigger to an embedded Quartz scheduler instead of @Scheduled.
Frequently Asked Questions
Do I still need a script that checks if tomorrow is the 1st?
No. That guard exists only because Unix cron's 28-31 range fires on every day in the range. Quartz's L resolves the correct final day per month internally, so the expression alone is the complete schedule.
What does this fire on in February?
The 28th, or the 29th in leap years. L always resolves to the month's actual last date, so 2028's February run lands on the 29th without any configuration change.
Can I run a few days before month end instead?
Yes — Quartz supports offsets from L: 0 0 0 L-3 * ? fires three days before the last day of each month, tracking month length automatically (the 28th of a 31-day month, the 25th of a 28-day February).
0 0 28-31 * * in AWS EventBridge
The L token in day-of-month means the last calendar day, so this rule fires exactly once per month at 00:00 UTC — on the 31st of January, the 28th or 29th of February, the 30th of April, and so on. This is one schedule EventBridge expresses more cleanly than standard cron ever could.
AWS EventBridge expression
cron(0 0 L * ? *)Try it in the tester →At 00:00 UTC on the last day of the month
Unix / POSIX equivalent: 0 0 28-31 * *
Field Breakdown
Using It with AWS EventBridge
Month-end is when finance-flavored automation clusters: closing the books with a Step Functions workflow, snapshotting balances before the rollover, computing usage for invoices issued on the 1st, or expiring monthly entitlements. The L expression goes into ScheduleExpression like any other and needs no special permissions. The portable-cron workaround it replaces — 0 0 28-31 * * — actually matches every day from the 28th through the 31st, four firings in a 31-day month, and forces every target to begin with a guard script checking whether tomorrow is the 1st. With L the schedule itself encodes the calendar logic: one firing, always on the true final day, leap years included.
AWS-Specific Notes
- →L belongs in day-of-month here, and the day-of-week field must hold the ? — the one-of-two-days rule applies to L just as it does to numeric values.
- →February is handled automatically: the rule fires on the 28th in common years and the 29th in leap years such as 2028, with no extra configuration.
- →Pair L with a different time of day freely; cron(30 23 L * ? *) runs at 23:30 UTC on the last day, half an hour before the month closes in UTC.
Frequently Asked Questions
Why not just schedule 28-31 and let extra runs no-op?
Because the range fires up to four times per month and pushes the is-it-really-the-last-day check into your code, where it can rot. L moves that logic into the scheduler: one invocation, zero guard code, and no risk of a forgotten guard double-billing customers.
Does cron(0 0 L * ? *) run on February 29th in leap years?
Yes. L always resolves to the actual final date of the month being evaluated, so February yields the 28th in 2026 and 2027 but the 29th in 2028.
I need the run a few days before month end — can L help?
The plain L token marks the final day itself. For a fixed earlier date use a normal day-of-month value like 25, which exists in every month; offset-from-end syntax is not part of EventBridge's documented cron grammar.
0 0 28-31 * * in GitHub Actions
GitHub's 5-field cron has no last-day-of-month token, so the idiom is to over-fire and filter: the 28-31 range triggers the workflow on each of the month's final days, and a guard step inside the job lets only the true last day proceed. The trigger is deliberately noisy; the job supplies the precision.
GitHub Actions expression
0 0 28-31 * *Try it in the tester →At 00:00 UTC on day 28, 29, 30, 31 of the month
Unix / POSIX equivalent: 0 0 28-31 * *
Field Breakdown
Using It with GitHub Actions
The YAML side is plain — on: schedule: - cron: "0 0 28-31 * *" — and the intelligence lives in the first step of the job, something like: if [ "$(date -u -d tomorrow +%d)" != "01" ]; then echo "not the last day"; exit 0; fi. When tomorrow is the 1st, today is the last day, and the remaining steps run; otherwise the job ends in seconds. The pattern serves month-close work: generating usage and billing reports, reconciling spend against cloud invoices, or tagging a month-end data snapshot.
GitHub-Specific Notes
- →Expect 41 triggers in a normal year and 42 in a leap year — four per 31-day month, three per 30-day month, and one or two in February — of which exactly 12 pass the guard and do real work.
- →Use date -u in the guard. The schedule fires on UTC dates, and a self-hosted runner configured for a western timezone would otherwise evaluate "tomorrow" against the wrong calendar day near midnight.
- →Quartz's L token and EventBridge's L are not valid here — a workflow file containing 0 0 L * * is rejected as invalid cron rather than treated as last-day.
Frequently Asked Questions
My last-of-month workflow ran on the 28th and exited immediately — is that a bug?
No, that is the design working. The 28-31 range fires on every one of those dates; the guard step exits early on all but the month's actual final day. Only the run where tomorrow is the 1st does the real work.
Do the discarded runs cost anything?
On private repositories each job bills a minimum of one minute, so roughly 29 no-op runs a year cost about 29 billable minutes — negligible, but nonzero. On public repositories with GitHub-hosted runners they cost nothing.
Could I just schedule day 31 and skip the guard?
Only if firing solely in 31-day months is acceptable — "0 0 31 * *" matches just seven months a year and never fires in February, April, June, September, or November. For a true month-end you need the 28-31 range plus the guard.