The cron expression 0 0 1 * * runs a command at midnight on the 1st of every month. Twelve runs per year, no exceptions. Simple enough until someone asks for "the first business day" instead of "the first day." The 1st falls on a Saturday sometimes, and your finance team doesn't process invoices on weekends. That's where it gets interesting.
Below: how the monthly expression works, the @monthly shorthand, how to target the first weekday or the first Monday, and a couple of traps that cost people hours of debugging. For a broader look at cron syntax, start with the complete cron jobs guide.
Schedule a cron job for the first day of every month
Five fields, left to right: minute, hour, day of month, month, and day of week. The 1 sits in the third field (day of month), meaning the first day. The two zeros set the time to midnight. Stars in the month and day-of-week fields mean "every month, any day of the week."

Twelve fires per year: January 1, February 1, March 1, and so on. Always midnight, always the 1st, regardless of whether that's a Tuesday or a Sunday. You can build and preview the next fire dates before committing to crontab.
@monthly: the shorthand (and when it breaks)
Most cron implementations support @monthly as an alias for 0 0 1 * *. Same schedule, shorter syntax:
@monthly /usr/bin/python3 /opt/scripts/monthly-report.py >> /var/log/monthly.log 2>&1
Not every system recognizes it, though. Older versions of Solaris cron and some container-based cron daemons (like busybox crond) ignore @ directives silently: no error, no execution. If you deploy with @monthly and nothing happens, switch to the explicit five-field expression. The validator will flag unsupported syntax.
Choosing a better time than midnight
Same issue as with weekly schedules: midnight on the 1st is a popular slot. Backups, log rotations, certificate renewals, billing runs. They all cluster at 0 0. If your monthly job touches a database, it competes with everything else for connections and disk I/O.
0 6 1 * * # 6:00 AM on the 1st
0 9 1 * * # 9:00 AM, start of business
0 3 1 * * # 3:00 AM, quieter than midnight
For a report that somebody reads, 9 AM on the 1st puts it at the top of their inbox when they open it. For a cleanup script, 3 AM avoids the midnight crowd. Pick the time that matches who consumes the output.
Running on a specific day of the month
Not every monthly job belongs on the 1st. Payroll on the 15th, billing on the 28th, database maintenance on the last quiet day before month-end:
0 9 15 * * # 15th of every month at 9 AM
0 0 28 * * # 28th at midnight
0 6 5 * * # 5th at 6 AM
The day-of-month field accepts any number from 1 to 31. If you set it to 31 and the month only has 30 days (or 28 in February), cron skips that month. It doesn't roll back to the 30th or the 28th. The job just doesn't fire. For a schedule that must run on the last day of every month, see the next section.
Last day of the month
Standard cron has no "last day" keyword. February ends on the 28th (or 29th), April on the 30th, July on the 31st. There's no single number you can put in the day-of-month field that covers all of them.
Workaround: schedule the job to run every day, but exit unless today is the last day.
0 9 28-31 * * [ $(date -d tomorrow +\%d) -eq 1 ] && /opt/scripts/month-end.sh
Fires on the 28th through 31st of every month. The date -d tomorrow check asks: "Is tomorrow the 1st?" If yes, today is the last day of the month, and the script runs. If not, the command after && is skipped. On macOS, the date syntax is different: date -v+1d +\%d.
We use a variant of this on our own infrastructure. Works every month, handles leap years, handles 30-day and 31-day months. No external dependencies.
First weekday of the month
Here's where people spend the most time. Your finance script needs to run on the first business day: the first Monday through Friday of the month. If January 1st is a Saturday, the script should wait until Monday the 3rd.
Standard cron cannot express this directly. You might try combining the day-of-month and day-of-week fields, something like 0 9 1-3 * 1-5. But remember: when both fields have specific values, cron uses OR logic, not AND. That expression runs on the 1st, 2nd, and 3rd of every month plus every weekday. Not what you want.
Reliable approach: run on the 1st through 3rd and check the day of the week in the command.
0 9 1-3 * * DOW=$(date +\%u); DOM=$(date +\%d); [ "$DOW" -le 5 ] && [ "$DOM" -le 3 ] && /opt/scripts/first-weekday.sh
Breaking this down: date +\%u gives the day of the week as a number (1 = Monday through 7 = Sunday). date +\%d gives the day of the month. The script runs only when both conditions pass: it's a weekday AND it's within the first 3 days.
Walk through an example. Say January 1st is a Saturday. The cron fires on the 1st (Saturday, DOW=6), the 2nd (Sunday, DOW=7), and the 3rd (Monday, DOW=1). Only the 3rd passes both checks. If the 1st were a Friday, it would pass immediately. You never need to go past the 3rd to find the first weekday of any month.
First Monday of the month
Covered briefly in the cron every Monday guide, but worth repeating here. The first Monday falls somewhere between the 1st and the 7th:
0 9 * * MON [ $(date +\%d) -le 7 ] && /opt/scripts/first-monday.sh
Runs every Monday, but the date check kills it unless the day of the month is 7 or less. Only the first Monday of each month passes. We've debugged this pattern for enough users that we can say confidently: the 0 9 1-7 * 1 approach (setting both day-of-month and day-of-week) does not work as expected. Cron treats it as "1st through 7th OR any Monday." Always use the date-check method.
Adding it to crontab
Open the crontab file:
crontab -e
Add your entry with absolute paths. Cron doesn't load your shell profile, so it won't find programs by short name:
0 9 1 * * /usr/bin/python3 /opt/scripts/monthly-report.py >> /var/log/monthly-report.log 2>&1
The >> appends output to a log file. Without it, cron routes output to the server's mail system, which on most machines means the output vanishes. Verify the entry took:
crontab -l
For the date-check variants (first weekday, first Monday, last day), remember to escape the percent sign. Cron interprets bare % as a newline. Write \%d, not %d. A missing backslash won't throw an error. The expression just silently produces garbage, and you'll waste an afternoon figuring out why. A cheat sheet helps with this kind of detail.
Three mistakes that break monthly cron jobs
Setting day to 31 and expecting every month. Months with fewer than 31 days simply skip. February, April, June, September, November: five months without a 31st. Your "monthly" job runs only seven times a year. Use 1 or the last-day workaround instead.
The OR trap. Putting specific values in both day-of-month and day-of-week fields. 0 9 1 * 1 doesn't mean "the 1st if it's a Monday." It means "every 1st of the month AND every Monday." About 14 runs per month instead of one. Even experienced sysadmins get burned by this one.
Timezone edge cases. A 0 0 1 * * job on a UTC server fires at midnight UTC. If your team is in Tokyo (UTC+9), that's 9 AM on the 1st, convenient. If they're in Los Angeles (UTC-7), it's 5 PM on the last day of the previous month. Your "first of the month" report arrives a day early. Use the timezone converter to get the UTC hour right.
How do you know the monthly job actually ran?
Monthly jobs have the longest feedback loop of any cron schedule. If the January run fails, you won't know until February unless you're watching. Thirty days of missing data before the next attempt.
grep CRON /var/log/syslog | grep "monthly-report" | tail -5
That shows whether cron invoked the command. But for monthly jobs, passive log-checking isn't a strategy. Nobody remembers to check logs on the 2nd of every month.
A heartbeat monitor solves this: the script pings a URL after each successful run. If the ping doesn't arrive within the expected 31-day window plus a grace period, you get an alert through Slack, Telegram, email, or whatever channel works for your team. For a job that runs once a month, catching the first failure matters more than for any other schedule. WatchCron supports 10+ notification channels.
Related schedules
| Schedule | Expression | Runs per year |
|---|---|---|
| Every day | 0 0 * * * | 365 |
| Every Monday | 0 0 * * 1 | 52 |
| 1st of every month | 0 0 1 * * | 12 |
| 15th of every month | 0 0 15 * * | 12 |
| Every quarter (Jan/Apr/Jul/Oct) | 0 0 1 1,4,7,10 * | 4 |
| Once a year (Jan 1) | 0 0 1 1 * | 1 |
Browse more patterns in the cron expression examples library, or use the cron expression builder to preview exact fire dates.
When crontab isn't an option
Containers without a persistent cron daemon, serverless functions, managed hosting where you can't edit crontab. An external scheduler handles this: you provide a URL and the schedule (0 9 1 * *), and it calls your endpoint on the 1st of every month from its own infrastructure.
If the scheduler also monitors the response, retries on timeout, alerts on errors, and tracks whether the job completed, you get execution and monitoring from one configuration. WatchCron's Cloud Cron works this way, with three free jobs on the free plan.
Run and monitor your monthly cron job
Three Cloud Cron jobs and 20 monitoring checks on the free plan. No credit card.