Cron on 29 February: 0 0 29 2 * and the leap year rule
0 0 29 2 * matches only in leap years, because 29 February does not exist in the other three years out of four. There is no error and no deferred run.
At a glance
- Expression
- 0 0 29 2 *
- Fires
- Once every four years, with an eight year gap around most century years.
- Restricted fields
- Minute, hour, day of month and month all pinned. Day of week open.
- Equivalent macro
- None.
- Leap year rule
- Divisible by 4, except years divisible by 100, unless also divisible by 400.
- Next skipped century
- 2100 is divisible by 100 and not by 400, so it has no 29 February.
In detail
This is the extreme case of a date that does not always exist. In a common year there is no 29 February, so nothing matches, and cron neither errors nor defers the run to 1 March. The job simply does not happen, three years in every four.
The rule for which years have the date is the Gregorian one: a year is a leap year if it divides by 4, except that century years are not leap years unless they also divide by 400. So 2000 had a leap day and 2100 will not, which means the interval between 29 February 2096 and 29 February 2104 is eight years.
Almost nobody wants this schedule deliberately. Where it turns up is by accident, in a job meant to run at the end of February that was pinned to the 29th, or in test fixtures written on a leap day. If the intent was the last day of February, the correct expression is 0 0 28 2 * for the guaranteed date, or the general last day of month approach if the 29th matters in leap years.
Where it is genuinely useful is as a test of date handling. A system that cannot schedule, log or report on 29 February correctly usually has the same bug in less visible places, and a job pinned to the date is a cheap way to find out once every four years.
Examples
0 0 29 2 *Matches only in leap years. No run at all in a common year.
0 0 28 2 *Runs every year, including leap years, where it is not the last day.
0 23 28,29 2 * [ "$(date -d tomorrow +\%d)" = "01" ] && /opt/app/bin/feb-close.shScheduled on both candidate dates, guarded so only the real last day does the work.
Things that catch people out
- There is no fallback to 1 March. A missed leap day run is simply missed.
- 2100 is not a leap year. Any code that treats every fourth year as a leap year is wrong from that point on.
- A job written and tested on 29 February may never run again for four years, which is long enough for the whole environment to change around it.
Try it: Cron Expression Parser
Paste any five or six field expression to get a plain English description, a per field breakdown and the next run times in your local time.
Cron Expression Parser
LOCAL · Browser-OnlyParse cron expressions and see the next scheduled run times
Supports 5-field (minute hour day month weekday), 6-field (with seconds), named values (JAN, MON), and @macros (@daily, @hourly, ...)
Use the dropdowns below to build your cron expression visually, or type a custom value in each field.
Human-Readable Description
Every 5 minutes
Field Breakdown
| Field | Allowed | Value | Meaning |
|---|---|---|---|
| Minute | 0-59 | */5 | every 5 minute(s) |
| Hour | 0-23 | * | every hour |
| Day of Month | 1-31 | * | every day of month |
| Month | 1-12 | * | every month |
| Day of Week | 0-7 | * | every day of week |
Related cron expressions
All cron expressions →Cron on the 31st: 0 0 31 * * only fires seven times a year
0 0 31 * * runs only in months that have 31 days, so it fires seven times a year, not twelve. The same trap hits day 30 and day 29.
Cron once a year: 0 0 1 1 * with @yearly and @annually
0 0 1 1 * runs at midnight on 1 January, once a year, and is what both @yearly and @annually expand to. Any date works, not just January.
Cron on the last day of the month: the L workaround
This parser rejects the Quartz L character, so the last day of the month needs a date range plus a script guard that checks whether tomorrow is the 1st.
The cron day of month field: 1 to 31 and missing dates
The day of month field runs 1 to 31, and a date that does not exist in a given month simply never matches. Steps start at 1, giving odd days.
Cron at a random time each day: jitter without a random field
Cron has no random field, so jitter is added in the command. A leading sleep of a random number of seconds spreads a fleet across a window.
The cron asterisk: what * means in each field
An asterisk matches every value in a field's own range, which differs per field. Why * */1 * * * is every minute and not every hour.
Tools for this
- Cron Expression Parser Free online cron expression parser - parse cron expressions and see the next scheduled run times
- Crontab Generator Free online crontab generator - build cron expressions visually with an intuitive interface