How-to
Automating Reporting WorkflowsDeep dive

Run a Report on the Last Business Day of the Month

Cron cannot say "the last working day of the month", and a plain month-end offset forgets public holidays. Schedule the job every weekday and let Python decide: find the last business day with numpy and a holiday calendar, handle the nth business day for close reporting, use the business's time zone, and catch up if the server was down on the day.

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.

The daily check that decides whether to run A decision chart the script runs every weekday. If today is the last business day of the month according to the holiday calendar, generate the report. If the month end has passed and the period has no report yet, because the server was down, catch up now. Otherwise exit without doing anything. What day is it for the business? last business day run report period = this month missed month end catch up period = last month any other day exit quietly nothing to do

Prerequisites

Bash
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:

Python
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.

Which date tool knows about holidays A table of date tools. pandas BMonthEnd skips weekends but not holidays, so it can pick a bank holiday. pandas CustomBusinessMonthEnd with a holidays argument and numpy busday_offset with a holidays argument both skip weekends and holidays. Cron alone knows neither business days nor holidays. Skips weekends Skips holidays pandas BMonthEnd no — can pick a holiday CustomBusinessMonthEnd(holidays=) numpy busday_offset(holidays=) cron on its own The end of August 2026 in England The last days of August 2026. Thursday 27 and Friday 28 are working days. Saturday 29 and Sunday 30 are the weekend. Monday 31 is the Late Summer Bank Holiday. A weekends-only offset picks Monday 31; the holiday-aware calculation picks Friday 28. Thu 27 Aug working day Fri 28 Aug last business day Sat 29 – Sun 30 weekend Mon 31 Aug bank holiday — BMonthEnd picks this

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:

Python
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:

Bash
# 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:

Python
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:

Python
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

  • BMonthEnd for business days. It ignores holidays; pass a holiday list to busday_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.

More on scheduling & orchestration

More on scheduling & orchestration →