Run a Report on the Last Business Day of the Month
Month-end reports usually have to go out on the last working day of the month — or the third working day after it, when the books are closed. Cron can schedule "the 31st" or "every Friday" but not "the last day that is not a weekend or public holiday", and the obvious pandas shortcut, BMonthEnd, knows about weekends but not holidays. The reliable pattern splits the job in two: the scheduler runs the script every weekday, and the script decides from a holiday calendar whether today is the day. This page is part of Scheduling Python Excel Scripts with Cron.
Prerequisites
pip install numpy pandas holidays
holidays provides public holiday calendars for most countries and regions. You can equally keep your own list of company closure days in a file; all that matters is a list of dates.
Step 1 — Find the last business day, with holidays
numpy.busday_offset moves a date by a number of business days, skipping weekends and any holidays passed in. Step back one business day from the first day of the next month and you have the last business day of this one:
from datetime import date
import holidays
import numpy as np
import pandas as pd
HOLIDAYS = list(holidays.country_holidays("GB", subdiv="ENG", years=range(2026, 2028)))
def last_business_day(year, month, holidays=HOLIDAYS):
first_of_next = (pd.Timestamp(year, month, 1) + pd.offsets.MonthBegin(1)).date()
return np.busday_offset(first_of_next, -1, roll="forward", holidays=holidays).astype(object)
rows = []
for m in range(1, 13):
naive = (pd.Timestamp(2026, m, 1) + pd.offsets.BMonthEnd(0)).date()
rows.append({"month": f"2026-{m:02d}", "weekends only": naive, "with holidays": last_business_day(2026, m)})
table = pd.DataFrame(rows)
print(table[table["weekends only"] != table["with holidays"]].to_string(index=False))
print(len(table), "months checked")
For eleven months of 2026 the two agree, but August differs: the last weekday is Monday 31 August, a bank holiday in England, so the report must run on Friday 28 August. BMonthEnd would have scheduled it for a day the office is shut. roll="forward" handles a first-of-month that is itself a weekend or holiday: it moves to the next business day before stepping back one, which still lands on the last business day of the previous month.
Step 2 — Guard the script, and give it a date for testing
The report script checks the date first and exits immediately on any other day. Make "today" a parameter, so the guard can be tested for any date and a run can be forced for a specific one:
from datetime import datetime
from zoneinfo import ZoneInfo
BUSINESS_TZ = ZoneInfo("Europe/London")
def business_today():
return datetime.now(BUSINESS_TZ).date()
def should_run(today):
return today == last_business_day(today.year, today.month)
run_days = [d.date() for d in pd.date_range("2026-01-01", "2026-12-31") if should_run(d.date())]
print(len(run_days), "runs in 2026:", [f"{d:%d %b}" for d in run_days])
Exactly one run per month, and 28 August among them. business_today() uses the business's own time zone: servers usually run on UTC, and a job scheduled for 23:30 UTC on 30 September is already 1 October in Sydney. Evaluating the date where the business is avoids reporting the wrong month.
Step 3 — Schedule it every weekday
With the decision in the script, the schedule is simple: run on weekdays at the time the report is due. On most days the script starts, finds nothing to do and exits within a second:
# m h dom mon dow command
0 18 * * 1-5 /opt/reporting/venv/bin/python /opt/reporting/month_end.py >> /opt/reporting/logs/month_end.log 2>&1
Resist the urge to narrow it to the last week with 24-31 in the day-of-month field and 1-5 in day-of-week. When both fields are restricted, cron runs the job when either matches — every weekday of the month and every day from the 24th — which is the opposite of what the line appears to say. Restrict one field only and let the script do the rest. On Windows, the equivalent is a daily weekday trigger in Task Scheduler; with systemd, OnCalendar=Mon..Fri 18:00.
Step 4 — The nth business day, for reports after month end
Finance close reports often run a few working days after the month ends, once late postings are in: "on the third business day". That is an offset forward from the first day of the month, counting business days only:
def nth_business_day(year, month, n, holidays=HOLIDAYS):
first = date(year, month, 1)
return np.busday_offset(first, n - 1, roll="forward", holidays=holidays).astype(object)
close = pd.DataFrame({"month": [f"2026-{m:02d}" for m in range(1, 13)],
"3rd business day": [nth_business_day(2026, m, 3) for m in range(1, 13)]})
print(close.iloc[[0, 3, 4, 8]].to_string(index=False))
In January, 1 January is a holiday, so the third business day is Tuesday 6 January; in April, Good Friday on the 3rd and Easter Monday on the 6th push it to Tuesday 7 April; in May, the May Day holiday on the 4th pushes it to Wednesday 6 May. The guard is the same as before, comparing today with this function's result, and the report is for the previous month.
Step 5 — Catch up when a run is missed
If the server is down, or the job fails, on the one day it was due, a date-equality guard waits a whole month for its next chance. Record which period was last reported, and run whenever the latest month end has passed without a report:
import json
from pathlib import Path
STATE = Path("month_end_state.json")
def period_due(today):
this_end = last_business_day(today.year, today.month)
if today >= this_end:
return f"{today:%Y-%m}"
prev = pd.Timestamp(today).replace(day=1) - pd.Timedelta(days=1)
return f"{prev:%Y-%m}"
def run_if_due(today):
done = json.loads(STATE.read_text())["last_period"] if STATE.exists() else None
due = period_due(today)
if due == done:
return f"{today}: nothing to do (last reported {done})"
STATE.write_text(json.dumps({"last_period": due, "ran_on": str(today)}))
return f"{today}: generate report for {due}"
STATE.write_text(json.dumps({"last_period": "2026-08"}))
for d in [date(2026, 9, 29), date(2026, 10, 1), date(2026, 10, 2), date(2026, 10, 30)]:
print(run_if_due(d))
On 29 September there is nothing to do: September's last business day has not arrived and August is already reported. The server was then down on 30 September. On 1 October the script sees that September's month end has passed and September has no report, so it runs it then; on 2 October there is nothing to do; on 30 October — October's last business day — it produces October's report as normal. Write the state only after the report has been generated and delivered successfully, so a failed run is retried the next day.
Common pitfalls
BMonthEndfor business days. It ignores holidays; pass a holiday list tobusday_offset.- Both cron day fields restricted. Cron ORs them; restrict one and decide in Python.
- Server time zone. Decide the date in the business's time zone with
zoneinfo. - A holiday calendar that runs out. Generate holidays for the years ahead and alert when the list ends.
- No catch-up. A missed day should not mean a missed month; record the last period reported.
- Company closures. Add office closure days to the public holiday list.
Performance notes
The guard runs in milliseconds. Generate the holiday list once per run for a few years ahead rather than on every date calculation, and log the decision — "not the last business day, exiting" — at debug level so the log does not fill with a line every weekday.
Conclusion
Schedule month-end jobs every weekday and let the script decide whether to run. Find the last business day with numpy.busday_offset stepping back from the first of the next month, with a holiday calendar; find the nth business day by stepping forward from the first. Evaluate "today" in the business's time zone, avoid restricting both cron day fields, and record the last period reported so a missed run is caught up the next day.
Frequently asked questions
Can cron run a job on the last business day of the month? Not by itself. Cron has no notion of business days or holidays. Schedule the job for every weekday, or the last week of each month, and have the script exit early unless today is the last business day according to a holiday calendar.
Why does pandas BMonthEnd give the wrong date? BMonthEnd skips weekends only. When the last weekday of the month is a public holiday — 31 August 2026 in England, for example — it still returns that day. Use numpy.busday_offset or pandas CustomBusinessMonthEnd with a holidays list.
Why does my cron line run every weekday instead of at month end? When both the day-of-month and day-of-week fields are restricted, cron runs the job when either matches. "0 18 24-31 * 1-5" means every day from the 24th to the 31st and also every Monday to Friday. Restrict only one of the two fields.
What if the server is down on the last business day? Record the last period reported. On each run, if the most recent month end has passed and that period has no report yet, run it — so a missed day is caught up on the next run instead of being skipped for a month.
Related
- Up one level: Scheduling Python Excel Scripts with Cron — cron in full.
- Backfill Excel Reports for Past Dates — rerunning past periods.
- Schedule an Excel Report with systemd Timers — persistent timers that catch up.
- Building Finance and Operations Reports — the reports that run at month end.
More on scheduling & orchestration
- How-toDownload New Excel Files from an SFTP Server
- How-toRead Excel Attachments into a Database Nightly
- How-toRefresh an Excel Report from a Database on a Schedule
- How-toRun a Report When an Excel File Changes in OneDrive
- How-toSend Report Emails at Each Recipient's Local Time
- How-toDetect a Scheduled Report That Did Not Run