[{"data":1,"prerenderedAt":2013},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-report-on-the-last-business-day-of-the-month":3,"surround:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-report-on-the-last-business-day-of-the-month":1979,"ancestors:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-report-on-the-last-business-day-of-the-month":1988,"children:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-report-on-the-last-business-day-of-the-month":1993,"related:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-report-on-the-last-business-day-of-the-month":1994},{"id":4,"title":5,"body":6,"dateModified":1945,"datePublished":1945,"description":1946,"extension":1947,"faq":1948,"keyword":1957,"kind":1958,"meta":1959,"navigation":313,"path":1968,"seo":1969,"slug":1972,"stem":1973,"topics":1974,"type":1977,"__hash__":1978},"docs\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-report-on-the-last-business-day-of-the-month\u002Findex.md","Run a Report on the Last Business Day of the Month",{"type":7,"value":8,"toc":1932},"minimark",[9,29,200,205,235,241,245,251,652,662,789,887,891,894,1085,1092,1096,1099,1141,1172,1176,1183,1370,1377,1381,1384,1795,1798,1802,1850,1854,1857,1861,1867,1871,1877,1883,1889,1895,1899,1928],[10,11,12,13,17,18,22,23,28],"p",{},"Month-end reports usually have to go out on the last ",[14,15,16],"em",{},"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, ",[19,20,21],"code",{},"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 ",[24,25,27],"a",{"href":26},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002F","Scheduling Python Excel Scripts with Cron",".",[30,31,39,40,39,44,39,48,39,56,39,66,39,77,39,83,39,88,39,92,39,95,39,100,39,109,39,114,39,122,39,129,39,134,39,139,39,141,39,144,39,148,39,151,39,156,39,163,39,167,39,170,39,172,39,175,39,179,39,182,39,186,39,193,39,197],"svg",{"viewBox":32,"role":33,"ariaLabelledBy":34,"xmlns":37,"style":38},"4 0 752 272","img",[35,36],"lbd-decide-t","lbd-decide-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[41,42,43],"title",{"id":35},"The daily check that decides whether to run",[45,46,47],"desc",{"id":36},"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.",[49,50],"rect",{"x":51,"y":52,"width":53,"height":54,"fill":55},"4","0","752","272","#ffffff",[49,57],{"x":58,"y":59,"width":60,"height":61,"rx":62,"fill":63,"stroke":64,"style":65},"240.0","16.0","280.0","75.5","12","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[67,68,70],"g",{"transform":69},"translate(368.0,29.0) scale(1.000)",[71,72],"path",{"d":73,"fill":74,"stroke":75,"style":76},"M4 5h16v15H4z M4 10h16 M8 3v4 M16 3v4","none","var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[78,79,82],"text",{"x":80,"y":61,"style":81},"380.0","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","What day is it for the business?",[84,85],"line",{"x1":80,"y1":86,"x2":80,"y2":87,"stroke":64,"style":65},"91.5","111.5",[84,89],{"x1":90,"y1":87,"x2":91,"y2":87,"stroke":64,"style":65},"134.7","625.3",[84,93],{"x1":90,"y1":87,"x2":90,"y2":94,"stroke":64,"style":65},"157.5",[96,97],"polygon",{"points":98,"fill":99},"134.7,161.5 129.7,152.5 139.7,152.5","#5b5cf0",[49,101],{"x":102,"y":103,"width":104,"height":105,"rx":106,"fill":55,"stroke":107,"style":108},"74.4","121.5","120.5","21.0","10","var(--line,#cdd5e6)","stroke-width:1.2px",[78,110,113],{"x":90,"y":111,"style":112},"136.0","font-size:11.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","last business day",[49,115],{"x":116,"y":117,"width":118,"height":119,"rx":62,"fill":120,"stroke":121,"style":65},"20.0","163.5","229.3","93.0","#d9f4f1","var(--teal,#0f9488)",[67,123,125],{"transform":124},"translate(122.7,176.5) scale(1.000)",[71,126],{"d":127,"fill":74,"stroke":128,"style":76},"M5 12.5l4.5 4.5L19 7","var(--teal-ink,#0b6157)",[78,130,133],{"x":90,"y":131,"style":132},"223.0","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","run report",[78,135,138],{"x":90,"y":136,"style":137},"242.5","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","period = this month",[84,140],{"x1":80,"y1":87,"x2":80,"y2":94,"stroke":64,"style":65},[96,142],{"points":143,"fill":99},"380.0,161.5 375.0,152.5 385.0,152.5",[49,145],{"x":146,"y":103,"width":147,"height":105,"rx":106,"fill":55,"stroke":107,"style":108},"316.7","126.6",[78,149,150],{"x":80,"y":111,"style":112},"missed month end",[49,152],{"x":153,"y":117,"width":118,"height":119,"rx":62,"fill":154,"stroke":155,"style":65},"265.3","#fdefd8","var(--gold,#b4740a)",[67,157,159],{"transform":158},"translate(368.0,176.5) scale(1.000)",[71,160],{"d":161,"fill":74,"stroke":162,"style":76},"M20 11a8 8 0 1 0-2.3 5.7 M20 4v7h-7","var(--gold-ink,#7a4e06)",[78,164,166],{"x":80,"y":131,"style":165},"font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","catch up",[78,168,169],{"x":80,"y":136,"style":137},"period = last month",[84,171],{"x1":91,"y1":87,"x2":91,"y2":94,"stroke":64,"style":65},[96,173],{"points":174,"fill":99},"625.3,161.5 620.3,152.5 630.3,152.5",[49,176],{"x":177,"y":103,"width":178,"height":105,"rx":106,"fill":55,"stroke":107,"style":108},"575.9","98.9",[78,180,181],{"x":91,"y":111,"style":112},"any other day",[49,183],{"x":184,"y":117,"width":118,"height":119,"rx":62,"fill":185,"stroke":107,"style":65},"510.7","#e7ebef",[67,187,189],{"transform":188},"translate(613.3,176.5) scale(1.000)",[71,190],{"d":191,"fill":74,"stroke":192,"style":76},"M12 3a9 9 0 1 0 .01 0z M12 7v5l3.5 2","var(--text,#172033)",[78,194,196],{"x":91,"y":131,"style":195},"font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","exit quietly",[78,198,199],{"x":91,"y":136,"style":137},"nothing to do",[201,202,204],"h2",{"id":203},"prerequisites","Prerequisites",[206,207,212],"pre",{"className":208,"code":209,"language":210,"meta":211,"style":211},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install numpy pandas holidays\n","bash","",[19,213,214],{"__ignoreMap":211},[215,216,218,222,226,229,232],"span",{"class":84,"line":217},1,[215,219,221],{"class":220},"sMTad","pip",[215,223,225],{"class":224},"srMev"," install",[215,227,228],{"class":224}," numpy",[215,230,231],{"class":224}," pandas",[215,233,234],{"class":224}," holidays\n",[10,236,237,240],{},[19,238,239],{},"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.",[201,242,244],{"id":243},"step-1-find-the-last-business-day-with-holidays","Step 1 — Find the last business day, with holidays",[10,246,247,250],{},[19,248,249],{},"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:",[206,252,256],{"className":253,"code":254,"language":255,"meta":211,"style":211},"language-python shiki shiki-themes github-light github-dark-high-contrast","from datetime import date\nimport holidays\nimport numpy as np\nimport pandas as pd\n\nHOLIDAYS = list(holidays.country_holidays(\"GB\", subdiv=\"ENG\", years=range(2026, 2028)))\n\ndef last_business_day(year, month, holidays=HOLIDAYS):\n    first_of_next = (pd.Timestamp(year, month, 1) + pd.offsets.MonthBegin(1)).date()\n    return np.busday_offset(first_of_next, -1, roll=\"forward\", holidays=holidays).astype(object)\n\nrows = []\nfor m in range(1, 13):\n    naive = (pd.Timestamp(2026, m, 1) + pd.offsets.BMonthEnd(0)).date()\n    rows.append({\"month\": f\"2026-{m:02d}\", \"weekends only\": naive, \"with holidays\": last_business_day(2026, m)})\ntable = pd.DataFrame(rows)\nprint(table[table[\"weekends only\"] != table[\"with holidays\"]].to_string(index=False))\nprint(len(table), \"months checked\")\n","python",[19,257,258,274,281,295,308,315,371,376,396,424,463,468,479,505,534,587,598,634],{"__ignoreMap":211},[215,259,260,264,268,271],{"class":84,"line":217},[215,261,263],{"class":262},"s-kum","from",[215,265,267],{"class":266},"skGVy"," datetime ",[215,269,270],{"class":262},"import",[215,272,273],{"class":266}," date\n",[215,275,277,279],{"class":84,"line":276},2,[215,278,270],{"class":262},[215,280,234],{"class":266},[215,282,284,286,289,292],{"class":84,"line":283},3,[215,285,270],{"class":262},[215,287,288],{"class":266}," numpy ",[215,290,291],{"class":262},"as",[215,293,294],{"class":266}," np\n",[215,296,298,300,303,305],{"class":84,"line":297},4,[215,299,270],{"class":262},[215,301,302],{"class":266}," pandas ",[215,304,291],{"class":262},[215,306,307],{"class":266}," pd\n",[215,309,311],{"class":84,"line":310},5,[215,312,314],{"emptyLinePlaceholder":313},true,"\n",[215,316,318,322,325,328,331,334,337,341,344,347,349,352,354,357,360,363,365,368],{"class":84,"line":317},6,[215,319,321],{"class":320},"sP0c6","HOLIDAYS",[215,323,324],{"class":262}," =",[215,326,327],{"class":320}," list",[215,329,330],{"class":266},"(holidays.country_holidays(",[215,332,333],{"class":224},"\"GB\"",[215,335,336],{"class":266},", ",[215,338,340],{"class":339},"sa561","subdiv",[215,342,343],{"class":262},"=",[215,345,346],{"class":224},"\"ENG\"",[215,348,336],{"class":266},[215,350,351],{"class":339},"years",[215,353,343],{"class":262},[215,355,356],{"class":320},"range",[215,358,359],{"class":266},"(",[215,361,362],{"class":320},"2026",[215,364,336],{"class":266},[215,366,367],{"class":320},"2028",[215,369,370],{"class":266},")))\n",[215,372,374],{"class":84,"line":373},7,[215,375,314],{"emptyLinePlaceholder":313},[215,377,379,382,386,389,391,393],{"class":84,"line":378},8,[215,380,381],{"class":262},"def",[215,383,385],{"class":384},"s_Opv"," last_business_day",[215,387,388],{"class":266},"(year, month, holidays",[215,390,343],{"class":262},[215,392,321],{"class":320},[215,394,395],{"class":266},"):\n",[215,397,399,402,404,407,410,413,416,419,421],{"class":84,"line":398},9,[215,400,401],{"class":266},"    first_of_next ",[215,403,343],{"class":262},[215,405,406],{"class":266}," (pd.Timestamp(year, month, ",[215,408,409],{"class":320},"1",[215,411,412],{"class":266},") ",[215,414,415],{"class":262},"+",[215,417,418],{"class":266}," pd.offsets.MonthBegin(",[215,420,409],{"class":320},[215,422,423],{"class":266},")).date()\n",[215,425,427,430,433,436,438,440,443,445,448,450,452,454,457,460],{"class":84,"line":426},10,[215,428,429],{"class":262},"    return",[215,431,432],{"class":266}," np.busday_offset(first_of_next, ",[215,434,435],{"class":262},"-",[215,437,409],{"class":320},[215,439,336],{"class":266},[215,441,442],{"class":339},"roll",[215,444,343],{"class":262},[215,446,447],{"class":224},"\"forward\"",[215,449,336],{"class":266},[215,451,239],{"class":339},[215,453,343],{"class":262},[215,455,456],{"class":266},"holidays).astype(",[215,458,459],{"class":320},"object",[215,461,462],{"class":266},")\n",[215,464,466],{"class":84,"line":465},11,[215,467,314],{"emptyLinePlaceholder":313},[215,469,471,474,476],{"class":84,"line":470},12,[215,472,473],{"class":266},"rows ",[215,475,343],{"class":262},[215,477,478],{"class":266}," []\n",[215,480,482,485,488,491,494,496,498,500,503],{"class":84,"line":481},13,[215,483,484],{"class":262},"for",[215,486,487],{"class":266}," m ",[215,489,490],{"class":262},"in",[215,492,493],{"class":320}," range",[215,495,359],{"class":266},[215,497,409],{"class":320},[215,499,336],{"class":266},[215,501,502],{"class":320},"13",[215,504,395],{"class":266},[215,506,508,511,513,516,518,521,523,525,527,530,532],{"class":84,"line":507},14,[215,509,510],{"class":266},"    naive ",[215,512,343],{"class":262},[215,514,515],{"class":266}," (pd.Timestamp(",[215,517,362],{"class":320},[215,519,520],{"class":266},", m, ",[215,522,409],{"class":320},[215,524,412],{"class":266},[215,526,415],{"class":262},[215,528,529],{"class":266}," pd.offsets.BMonthEnd(",[215,531,52],{"class":320},[215,533,423],{"class":266},[215,535,537,540,543,546,549,552,556,559,562,565,568,570,573,576,579,582,584],{"class":84,"line":536},15,[215,538,539],{"class":266},"    rows.append({",[215,541,542],{"class":224},"\"month\"",[215,544,545],{"class":266},": ",[215,547,548],{"class":262},"f",[215,550,551],{"class":224},"\"2026-",[215,553,555],{"class":554},"sSjpA","{",[215,557,558],{"class":266},"m",[215,560,561],{"class":262},":02d",[215,563,564],{"class":554},"}",[215,566,567],{"class":224},"\"",[215,569,336],{"class":266},[215,571,572],{"class":224},"\"weekends only\"",[215,574,575],{"class":266},": naive, ",[215,577,578],{"class":224},"\"with holidays\"",[215,580,581],{"class":266},": last_business_day(",[215,583,362],{"class":320},[215,585,586],{"class":266},", m)})\n",[215,588,590,593,595],{"class":84,"line":589},16,[215,591,592],{"class":266},"table ",[215,594,343],{"class":262},[215,596,597],{"class":266}," pd.DataFrame(rows)\n",[215,599,601,604,607,609,612,615,618,620,623,626,628,631],{"class":84,"line":600},17,[215,602,603],{"class":320},"print",[215,605,606],{"class":266},"(table[table[",[215,608,572],{"class":224},[215,610,611],{"class":266},"] ",[215,613,614],{"class":262},"!=",[215,616,617],{"class":266}," table[",[215,619,578],{"class":224},[215,621,622],{"class":266},"]].to_string(",[215,624,625],{"class":339},"index",[215,627,343],{"class":262},[215,629,630],{"class":320},"False",[215,632,633],{"class":266},"))\n",[215,635,637,639,641,644,647,650],{"class":84,"line":636},18,[215,638,603],{"class":320},[215,640,359],{"class":266},[215,642,643],{"class":320},"len",[215,645,646],{"class":266},"(table), ",[215,648,649],{"class":224},"\"months checked\"",[215,651,462],{"class":266},[10,653,654,655,657,658,661],{},"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. ",[19,656,21],{}," would have scheduled it for a day the office is shut. ",[19,659,660],{},"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.",[30,663,39,668,39,671,39,674,39,677,39,682,39,686,39,692,39,695,39,699,39,703,39,709,39,711,39,717,39,719,39,723,39,726,39,730,39,732,39,737,39,739,39,744,39,747,39,751,39,753,39,758,39,760,39,765,39,768,39,772,39,774,39,782,39,784],{"viewBox":664,"role":33,"ariaLabelledBy":665,"xmlns":37,"style":38},"4 0 752 208",[666,667],"lbd-tools-t","lbd-tools-d",[41,669,670],{"id":666},"Which date tool knows about holidays",[45,672,673],{"id":667},"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.",[49,675],{"x":51,"y":52,"width":53,"height":676,"fill":55},"208",[49,678],{"x":116,"y":59,"width":679,"height":680,"rx":52,"fill":55,"stroke":107,"style":681},"275.7","40.0","stroke-width:1px",[49,683],{"x":684,"y":59,"width":685,"height":680,"rx":52,"fill":63,"stroke":64,"style":108},"295.7","222.2",[78,687,691],{"x":688,"y":689,"style":690},"406.8","41.0","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Skips weekends",[49,693],{"x":694,"y":59,"width":685,"height":680,"rx":52,"fill":63,"stroke":64,"style":108},"517.8",[78,696,698],{"x":697,"y":689,"style":690},"628.9","Skips holidays",[49,700],{"x":116,"y":701,"width":679,"height":702,"rx":52,"fill":185,"stroke":107,"style":681},"56.0","34.0",[78,704,708],{"x":705,"y":706,"style":707},"32.0","78.0","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:start","pandas BMonthEnd",[49,710],{"x":684,"y":701,"width":685,"height":702,"rx":52,"fill":55,"stroke":107,"style":681},[67,712,714],{"transform":713},"translate(397.8,64.0) scale(0.750)",[71,715],{"d":127,"fill":74,"stroke":121,"style":716},"stroke-width:2.40px;stroke-linecap:round;stroke-linejoin:round",[49,718],{"x":694,"y":701,"width":685,"height":702,"rx":52,"fill":55,"stroke":107,"style":681},[78,720,722],{"x":697,"y":721,"style":137},"77.0","no — can pick a holiday",[49,724],{"x":116,"y":725,"width":679,"height":702,"rx":52,"fill":185,"stroke":107,"style":681},"90.0",[78,727,729],{"x":705,"y":728,"style":707},"112.0","CustomBusinessMonthEnd(holidays=)",[49,731],{"x":684,"y":725,"width":685,"height":702,"rx":52,"fill":55,"stroke":107,"style":681},[67,733,735],{"transform":734},"translate(397.8,98.0) scale(0.750)",[71,736],{"d":127,"fill":74,"stroke":121,"style":716},[49,738],{"x":694,"y":725,"width":685,"height":702,"rx":52,"fill":55,"stroke":107,"style":681},[67,740,742],{"transform":741},"translate(619.9,98.0) scale(0.750)",[71,743],{"d":127,"fill":74,"stroke":121,"style":716},[49,745],{"x":116,"y":746,"width":679,"height":702,"rx":52,"fill":185,"stroke":107,"style":681},"124.0",[78,748,750],{"x":705,"y":749,"style":707},"146.0","numpy busday_offset(holidays=)",[49,752],{"x":684,"y":746,"width":685,"height":702,"rx":52,"fill":55,"stroke":107,"style":681},[67,754,756],{"transform":755},"translate(397.8,132.0) scale(0.750)",[71,757],{"d":127,"fill":74,"stroke":121,"style":716},[49,759],{"x":694,"y":746,"width":685,"height":702,"rx":52,"fill":55,"stroke":107,"style":681},[67,761,763],{"transform":762},"translate(619.9,132.0) scale(0.750)",[71,764],{"d":127,"fill":74,"stroke":121,"style":716},[49,766],{"x":116,"y":767,"width":679,"height":702,"rx":52,"fill":185,"stroke":107,"style":681},"158.0",[78,769,771],{"x":705,"y":770,"style":707},"180.0","cron on its own",[49,773],{"x":684,"y":767,"width":685,"height":702,"rx":52,"fill":55,"stroke":107,"style":681},[67,775,777],{"transform":776},"translate(398.8,167.0) scale(0.667)",[71,778],{"d":779,"fill":74,"stroke":780,"style":781},"M6 6l12 12 M18 6L6 18","var(--accent,#f43f8f)","stroke-width:2.70px;stroke-linecap:round;stroke-linejoin:round",[49,783],{"x":694,"y":767,"width":685,"height":702,"rx":52,"fill":55,"stroke":107,"style":681},[67,785,787],{"transform":786},"translate(620.9,167.0) scale(0.667)",[71,788],{"d":779,"fill":74,"stroke":780,"style":781},[30,790,39,795,39,798,39,801,39,804,39,811,39,815,39,821,39,826,39,830,39,834,39,838,39,844,39,847,39,849,39,851,39,854,39,858,39,861,39,863,39,865,39,869,39,875,39,879,39,883,39,885],{"viewBox":791,"role":33,"ariaLabelledBy":792,"xmlns":37,"style":38},"4 0 752 245",[793,794],"lbd-aug-t","lbd-aug-d",[41,796,797],{"id":793},"The end of August 2026 in England",[45,799,800],{"id":794},"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.",[49,802],{"x":51,"y":52,"width":53,"height":803,"fill":55},"245",[84,805],{"x1":806,"y1":807,"x2":808,"y2":807,"stroke":809,"style":810},"94.0","122.5","666.0","var(--muted,#5b6780)","stroke-width:2.5px",[49,812],{"x":116,"y":59,"width":813,"height":814,"rx":62,"fill":185,"stroke":107,"style":65},"172.0","74.5",[78,816,820],{"x":817,"y":818,"style":819},"106.0","49.0","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Thu 27 Aug",[78,822,825],{"x":817,"y":823,"style":824},"67.5","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","working day",[84,827],{"x1":817,"y1":828,"x2":817,"y2":807,"stroke":809,"style":829},"90.5","stroke-width:1.5px",[831,832],"circle",{"cx":817,"cy":807,"r":833,"fill":809,"stroke":55,"style":65},"7.0",[49,835],{"x":836,"y":837,"width":813,"height":814,"rx":62,"fill":120,"stroke":121,"style":65},"202.7","154.5",[78,839,843],{"x":840,"y":841,"style":842},"288.7","187.5","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Fri 28 Aug",[78,845,113],{"x":840,"y":846,"style":824},"206.0",[84,848],{"x1":840,"y1":807,"x2":840,"y2":837,"stroke":809,"style":829},[831,850],{"cx":840,"cy":807,"r":833,"fill":121,"stroke":55,"style":65},[49,852],{"x":853,"y":59,"width":813,"height":814,"rx":62,"fill":185,"stroke":107,"style":65},"385.3",[78,855,857],{"x":856,"y":818,"style":819},"471.3","Sat 29 – Sun 30",[78,859,860],{"x":856,"y":823,"style":824},"weekend",[84,862],{"x1":856,"y1":828,"x2":856,"y2":807,"stroke":809,"style":829},[831,864],{"cx":856,"cy":807,"r":833,"fill":809,"stroke":55,"style":65},[49,866],{"x":867,"y":837,"width":813,"height":814,"rx":62,"fill":868,"stroke":780,"style":65},"568.0","#fee8f2",[78,870,874],{"x":871,"y":872,"style":873},"654.0","179.0","font-size:12.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Mon 31 Aug",[78,876,878],{"x":871,"y":877,"style":824},"197.5","bank holiday —",[78,880,882],{"x":871,"y":881,"style":824},"214.5","BMonthEnd picks this",[84,884],{"x1":871,"y1":807,"x2":871,"y2":837,"stroke":809,"style":829},[831,886],{"cx":871,"cy":807,"r":833,"fill":780,"stroke":55,"style":65},[201,888,890],{"id":889},"step-2-guard-the-script-and-give-it-a-date-for-testing","Step 2 — Guard the script, and give it a date for testing",[10,892,893],{},"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:",[206,895,897],{"className":253,"code":896,"language":255,"meta":211,"style":211},"from datetime import datetime\nfrom zoneinfo import ZoneInfo\n\nBUSINESS_TZ = ZoneInfo(\"Europe\u002FLondon\")\n\ndef business_today():\n    return datetime.now(BUSINESS_TZ).date()\n\ndef should_run(today):\n    return today == last_business_day(today.year, today.month)\n\nrun_days = [d.date() for d in pd.date_range(\"2026-01-01\", \"2026-12-31\") if should_run(d.date())]\nprint(len(run_days), \"runs in 2026:\", [f\"{d:%d %b}\" for d in run_days])\n",[19,898,899,910,922,926,941,945,955,967,971,981,994,998,1034],{"__ignoreMap":211},[215,900,901,903,905,907],{"class":84,"line":217},[215,902,263],{"class":262},[215,904,267],{"class":266},[215,906,270],{"class":262},[215,908,909],{"class":266}," datetime\n",[215,911,912,914,917,919],{"class":84,"line":276},[215,913,263],{"class":262},[215,915,916],{"class":266}," zoneinfo ",[215,918,270],{"class":262},[215,920,921],{"class":266}," ZoneInfo\n",[215,923,924],{"class":84,"line":283},[215,925,314],{"emptyLinePlaceholder":313},[215,927,928,931,933,936,939],{"class":84,"line":297},[215,929,930],{"class":320},"BUSINESS_TZ",[215,932,324],{"class":262},[215,934,935],{"class":266}," ZoneInfo(",[215,937,938],{"class":224},"\"Europe\u002FLondon\"",[215,940,462],{"class":266},[215,942,943],{"class":84,"line":310},[215,944,314],{"emptyLinePlaceholder":313},[215,946,947,949,952],{"class":84,"line":317},[215,948,381],{"class":262},[215,950,951],{"class":384}," business_today",[215,953,954],{"class":266},"():\n",[215,956,957,959,962,964],{"class":84,"line":373},[215,958,429],{"class":262},[215,960,961],{"class":266}," datetime.now(",[215,963,930],{"class":320},[215,965,966],{"class":266},").date()\n",[215,968,969],{"class":84,"line":378},[215,970,314],{"emptyLinePlaceholder":313},[215,972,973,975,978],{"class":84,"line":398},[215,974,381],{"class":262},[215,976,977],{"class":384}," should_run",[215,979,980],{"class":266},"(today):\n",[215,982,983,985,988,991],{"class":84,"line":426},[215,984,429],{"class":262},[215,986,987],{"class":266}," today ",[215,989,990],{"class":262},"==",[215,992,993],{"class":266}," last_business_day(today.year, today.month)\n",[215,995,996],{"class":84,"line":465},[215,997,314],{"emptyLinePlaceholder":313},[215,999,1000,1003,1005,1008,1010,1013,1015,1018,1021,1023,1026,1028,1031],{"class":84,"line":470},[215,1001,1002],{"class":266},"run_days ",[215,1004,343],{"class":262},[215,1006,1007],{"class":266}," [d.date() ",[215,1009,484],{"class":262},[215,1011,1012],{"class":266}," d ",[215,1014,490],{"class":262},[215,1016,1017],{"class":266}," pd.date_range(",[215,1019,1020],{"class":224},"\"2026-01-01\"",[215,1022,336],{"class":266},[215,1024,1025],{"class":224},"\"2026-12-31\"",[215,1027,412],{"class":266},[215,1029,1030],{"class":262},"if",[215,1032,1033],{"class":266}," should_run(d.date())]\n",[215,1035,1036,1038,1040,1042,1045,1048,1051,1053,1055,1057,1060,1063,1066,1068,1071,1073,1075,1078,1080,1082],{"class":84,"line":481},[215,1037,603],{"class":320},[215,1039,359],{"class":266},[215,1041,643],{"class":320},[215,1043,1044],{"class":266},"(run_days), ",[215,1046,1047],{"class":224},"\"runs in 2026:\"",[215,1049,1050],{"class":266},", [",[215,1052,548],{"class":262},[215,1054,567],{"class":224},[215,1056,555],{"class":554},[215,1058,1059],{"class":266},"d:",[215,1061,1062],{"class":262},"%",[215,1064,1065],{"class":266},"d ",[215,1067,1062],{"class":262},[215,1069,1070],{"class":266},"b",[215,1072,564],{"class":554},[215,1074,567],{"class":224},[215,1076,1077],{"class":262}," for",[215,1079,1012],{"class":266},[215,1081,490],{"class":262},[215,1083,1084],{"class":266}," run_days])\n",[10,1086,1087,1088,1091],{},"Exactly one run per month, and 28 August among them. ",[19,1089,1090],{},"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.",[201,1093,1095],{"id":1094},"step-3-schedule-it-every-weekday","Step 3 — Schedule it every weekday",[10,1097,1098],{},"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:",[206,1100,1102],{"className":208,"code":1101,"language":210,"meta":211,"style":211},"# m  h  dom mon dow  command\n0   18  *   *   1-5  \u002Fopt\u002Freporting\u002Fvenv\u002Fbin\u002Fpython \u002Fopt\u002Freporting\u002Fmonth_end.py >> \u002Fopt\u002Freporting\u002Flogs\u002Fmonth_end.log 2>&1\n",[19,1103,1104,1110],{"__ignoreMap":211},[215,1105,1106],{"class":84,"line":217},[215,1107,1109],{"class":1108},"s-wDw","# m  h  dom mon dow  command\n",[215,1111,1112,1114,1117,1120,1123,1126,1129,1132,1135,1138],{"class":84,"line":276},[215,1113,52],{"class":220},[215,1115,1116],{"class":320},"   18",[215,1118,1119],{"class":320},"  *",[215,1121,1122],{"class":320},"   *",[215,1124,1125],{"class":224},"   1-5",[215,1127,1128],{"class":224},"  \u002Fopt\u002Freporting\u002Fvenv\u002Fbin\u002Fpython",[215,1130,1131],{"class":224}," \u002Fopt\u002Freporting\u002Fmonth_end.py",[215,1133,1134],{"class":262}," >>",[215,1136,1137],{"class":224}," \u002Fopt\u002Freporting\u002Flogs\u002Fmonth_end.log",[215,1139,1140],{"class":262}," 2>&1\n",[10,1142,1143,1144,1147,1148,1151,1152,1155,1156,1160,1161,1163,1164,1168,1169,28],{},"Resist the urge to narrow it to the last week with ",[19,1145,1146],{},"24-31"," in the day-of-month field ",[14,1149,1150],{},"and"," ",[19,1153,1154],{},"1-5"," in day-of-week. When both fields are restricted, cron runs the job when ",[1157,1158,1159],"strong",{},"either"," matches — every weekday of the month ",[14,1162,1150],{}," 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 ",[24,1165,1167],{"href":1166},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-python-excel-script-on-windows-task-scheduler\u002F","Task Scheduler","; with systemd, ",[19,1170,1171],{},"OnCalendar=Mon..Fri 18:00",[201,1173,1175],{"id":1174},"step-4-the-nth-business-day-for-reports-after-month-end","Step 4 — The nth business day, for reports after month end",[10,1177,1178,1179,1182],{},"Finance close reports often run a few working days ",[14,1180,1181],{},"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:",[206,1184,1186],{"className":253,"code":1185,"language":255,"meta":211,"style":211},"def nth_business_day(year, month, n, holidays=HOLIDAYS):\n    first = date(year, month, 1)\n    return np.busday_offset(first, n - 1, roll=\"forward\", holidays=holidays).astype(object)\n\nclose = pd.DataFrame({\"month\": [f\"2026-{m:02d}\" for m in range(1, 13)],\n                      \"3rd business day\": [nth_business_day(2026, m, 3) for m in range(1, 13)]})\nprint(close.iloc[[0, 3, 4, 8]].to_string(index=False))\n",[19,1187,1188,1204,1218,1250,1254,1302,1338],{"__ignoreMap":211},[215,1189,1190,1192,1195,1198,1200,1202],{"class":84,"line":217},[215,1191,381],{"class":262},[215,1193,1194],{"class":384}," nth_business_day",[215,1196,1197],{"class":266},"(year, month, n, holidays",[215,1199,343],{"class":262},[215,1201,321],{"class":320},[215,1203,395],{"class":266},[215,1205,1206,1209,1211,1214,1216],{"class":84,"line":276},[215,1207,1208],{"class":266},"    first ",[215,1210,343],{"class":262},[215,1212,1213],{"class":266}," date(year, month, ",[215,1215,409],{"class":320},[215,1217,462],{"class":266},[215,1219,1220,1222,1225,1227,1230,1232,1234,1236,1238,1240,1242,1244,1246,1248],{"class":84,"line":283},[215,1221,429],{"class":262},[215,1223,1224],{"class":266}," np.busday_offset(first, n ",[215,1226,435],{"class":262},[215,1228,1229],{"class":320}," 1",[215,1231,336],{"class":266},[215,1233,442],{"class":339},[215,1235,343],{"class":262},[215,1237,447],{"class":224},[215,1239,336],{"class":266},[215,1241,239],{"class":339},[215,1243,343],{"class":262},[215,1245,456],{"class":266},[215,1247,459],{"class":320},[215,1249,462],{"class":266},[215,1251,1252],{"class":84,"line":297},[215,1253,314],{"emptyLinePlaceholder":313},[215,1255,1256,1259,1261,1264,1266,1269,1271,1273,1275,1277,1279,1281,1283,1285,1287,1289,1291,1293,1295,1297,1299],{"class":84,"line":310},[215,1257,1258],{"class":266},"close ",[215,1260,343],{"class":262},[215,1262,1263],{"class":266}," pd.DataFrame({",[215,1265,542],{"class":224},[215,1267,1268],{"class":266},": [",[215,1270,548],{"class":262},[215,1272,551],{"class":224},[215,1274,555],{"class":554},[215,1276,558],{"class":266},[215,1278,561],{"class":262},[215,1280,564],{"class":554},[215,1282,567],{"class":224},[215,1284,1077],{"class":262},[215,1286,487],{"class":266},[215,1288,490],{"class":262},[215,1290,493],{"class":320},[215,1292,359],{"class":266},[215,1294,409],{"class":320},[215,1296,336],{"class":266},[215,1298,502],{"class":320},[215,1300,1301],{"class":266},")],\n",[215,1303,1304,1307,1310,1312,1314,1317,1319,1321,1323,1325,1327,1329,1331,1333,1335],{"class":84,"line":317},[215,1305,1306],{"class":224},"                      \"3rd business day\"",[215,1308,1309],{"class":266},": [nth_business_day(",[215,1311,362],{"class":320},[215,1313,520],{"class":266},[215,1315,1316],{"class":320},"3",[215,1318,412],{"class":266},[215,1320,484],{"class":262},[215,1322,487],{"class":266},[215,1324,490],{"class":262},[215,1326,493],{"class":320},[215,1328,359],{"class":266},[215,1330,409],{"class":320},[215,1332,336],{"class":266},[215,1334,502],{"class":320},[215,1336,1337],{"class":266},")]})\n",[215,1339,1340,1342,1345,1347,1349,1351,1353,1355,1357,1360,1362,1364,1366,1368],{"class":84,"line":373},[215,1341,603],{"class":320},[215,1343,1344],{"class":266},"(close.iloc[[",[215,1346,52],{"class":320},[215,1348,336],{"class":266},[215,1350,1316],{"class":320},[215,1352,336],{"class":266},[215,1354,51],{"class":320},[215,1356,336],{"class":266},[215,1358,1359],{"class":320},"8",[215,1361,622],{"class":266},[215,1363,625],{"class":339},[215,1365,343],{"class":262},[215,1367,630],{"class":320},[215,1369,633],{"class":266},[10,1371,1372,1373,1376],{},"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 ",[14,1374,1375],{},"previous"," month.",[201,1378,1380],{"id":1379},"step-5-catch-up-when-a-run-is-missed","Step 5 — Catch up when a run is missed",[10,1382,1383],{},"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:",[206,1385,1387],{"className":253,"code":1386,"language":255,"meta":211,"style":211},"import json\nfrom pathlib import Path\n\nSTATE = Path(\"month_end_state.json\")\n\ndef period_due(today):\n    this_end = last_business_day(today.year, today.month)\n    if today >= this_end:\n        return f\"{today:%Y-%m}\"\n    prev = pd.Timestamp(today).replace(day=1) - pd.Timedelta(days=1)\n    return f\"{prev:%Y-%m}\"\n\ndef run_if_due(today):\n    done = json.loads(STATE.read_text())[\"last_period\"] if STATE.exists() else None\n    due = period_due(today)\n    if due == done:\n        return f\"{today}: nothing to do (last reported {done})\"\n    STATE.write_text(json.dumps({\"last_period\": due, \"ran_on\": str(today)}))\n    return f\"{today}: generate report for {due}\"\n\nSTATE.write_text(json.dumps({\"last_period\": \"2026-08\"}))\nfor d in [date(2026, 9, 29), date(2026, 10, 1), date(2026, 10, 2), date(2026, 10, 30)]:\n    print(run_if_due(d))\n",[19,1388,1389,1396,1408,1412,1427,1431,1440,1449,1462,1492,1525,1550,1554,1563,1597,1607,1619,1647,1671,1698,1703,1720,1786],{"__ignoreMap":211},[215,1390,1391,1393],{"class":84,"line":217},[215,1392,270],{"class":262},[215,1394,1395],{"class":266}," json\n",[215,1397,1398,1400,1403,1405],{"class":84,"line":276},[215,1399,263],{"class":262},[215,1401,1402],{"class":266}," pathlib ",[215,1404,270],{"class":262},[215,1406,1407],{"class":266}," Path\n",[215,1409,1410],{"class":84,"line":283},[215,1411,314],{"emptyLinePlaceholder":313},[215,1413,1414,1417,1419,1422,1425],{"class":84,"line":297},[215,1415,1416],{"class":320},"STATE",[215,1418,324],{"class":262},[215,1420,1421],{"class":266}," Path(",[215,1423,1424],{"class":224},"\"month_end_state.json\"",[215,1426,462],{"class":266},[215,1428,1429],{"class":84,"line":310},[215,1430,314],{"emptyLinePlaceholder":313},[215,1432,1433,1435,1438],{"class":84,"line":317},[215,1434,381],{"class":262},[215,1436,1437],{"class":384}," period_due",[215,1439,980],{"class":266},[215,1441,1442,1445,1447],{"class":84,"line":373},[215,1443,1444],{"class":266},"    this_end ",[215,1446,343],{"class":262},[215,1448,993],{"class":266},[215,1450,1451,1454,1456,1459],{"class":84,"line":378},[215,1452,1453],{"class":262},"    if",[215,1455,987],{"class":266},[215,1457,1458],{"class":262},">=",[215,1460,1461],{"class":266}," this_end:\n",[215,1463,1464,1467,1470,1472,1474,1477,1479,1482,1485,1487,1489],{"class":84,"line":398},[215,1465,1466],{"class":262},"        return",[215,1468,1469],{"class":262}," f",[215,1471,567],{"class":224},[215,1473,555],{"class":554},[215,1475,1476],{"class":266},"today:",[215,1478,1062],{"class":262},[215,1480,1481],{"class":266},"Y",[215,1483,1484],{"class":262},"-%",[215,1486,558],{"class":266},[215,1488,564],{"class":554},[215,1490,1491],{"class":224},"\"\n",[215,1493,1494,1497,1499,1502,1505,1507,1509,1511,1513,1516,1519,1521,1523],{"class":84,"line":426},[215,1495,1496],{"class":266},"    prev ",[215,1498,343],{"class":262},[215,1500,1501],{"class":266}," pd.Timestamp(today).replace(",[215,1503,1504],{"class":339},"day",[215,1506,343],{"class":262},[215,1508,409],{"class":320},[215,1510,412],{"class":266},[215,1512,435],{"class":262},[215,1514,1515],{"class":266}," pd.Timedelta(",[215,1517,1518],{"class":339},"days",[215,1520,343],{"class":262},[215,1522,409],{"class":320},[215,1524,462],{"class":266},[215,1526,1527,1529,1531,1533,1535,1538,1540,1542,1544,1546,1548],{"class":84,"line":465},[215,1528,429],{"class":262},[215,1530,1469],{"class":262},[215,1532,567],{"class":224},[215,1534,555],{"class":554},[215,1536,1537],{"class":266},"prev:",[215,1539,1062],{"class":262},[215,1541,1481],{"class":266},[215,1543,1484],{"class":262},[215,1545,558],{"class":266},[215,1547,564],{"class":554},[215,1549,1491],{"class":224},[215,1551,1552],{"class":84,"line":470},[215,1553,314],{"emptyLinePlaceholder":313},[215,1555,1556,1558,1561],{"class":84,"line":481},[215,1557,381],{"class":262},[215,1559,1560],{"class":384}," run_if_due",[215,1562,980],{"class":266},[215,1564,1565,1568,1570,1573,1575,1578,1581,1583,1585,1588,1591,1594],{"class":84,"line":507},[215,1566,1567],{"class":266},"    done ",[215,1569,343],{"class":262},[215,1571,1572],{"class":266}," json.loads(",[215,1574,1416],{"class":320},[215,1576,1577],{"class":266},".read_text())[",[215,1579,1580],{"class":224},"\"last_period\"",[215,1582,611],{"class":266},[215,1584,1030],{"class":262},[215,1586,1587],{"class":320}," STATE",[215,1589,1590],{"class":266},".exists() ",[215,1592,1593],{"class":262},"else",[215,1595,1596],{"class":320}," None\n",[215,1598,1599,1602,1604],{"class":84,"line":536},[215,1600,1601],{"class":266},"    due ",[215,1603,343],{"class":262},[215,1605,1606],{"class":266}," period_due(today)\n",[215,1608,1609,1611,1614,1616],{"class":84,"line":589},[215,1610,1453],{"class":262},[215,1612,1613],{"class":266}," due ",[215,1615,990],{"class":262},[215,1617,1618],{"class":266}," done:\n",[215,1620,1621,1623,1625,1627,1629,1632,1634,1637,1639,1642,1644],{"class":84,"line":600},[215,1622,1466],{"class":262},[215,1624,1469],{"class":262},[215,1626,567],{"class":224},[215,1628,555],{"class":554},[215,1630,1631],{"class":266},"today",[215,1633,564],{"class":554},[215,1635,1636],{"class":224},": nothing to do (last reported ",[215,1638,555],{"class":554},[215,1640,1641],{"class":266},"done",[215,1643,564],{"class":554},[215,1645,1646],{"class":224},")\"\n",[215,1648,1649,1652,1655,1657,1660,1663,1665,1668],{"class":84,"line":636},[215,1650,1651],{"class":320},"    STATE",[215,1653,1654],{"class":266},".write_text(json.dumps({",[215,1656,1580],{"class":224},[215,1658,1659],{"class":266},": due, ",[215,1661,1662],{"class":224},"\"ran_on\"",[215,1664,545],{"class":266},[215,1666,1667],{"class":320},"str",[215,1669,1670],{"class":266},"(today)}))\n",[215,1672,1674,1676,1678,1680,1682,1684,1686,1689,1691,1694,1696],{"class":84,"line":1673},19,[215,1675,429],{"class":262},[215,1677,1469],{"class":262},[215,1679,567],{"class":224},[215,1681,555],{"class":554},[215,1683,1631],{"class":266},[215,1685,564],{"class":554},[215,1687,1688],{"class":224},": generate report for ",[215,1690,555],{"class":554},[215,1692,1693],{"class":266},"due",[215,1695,564],{"class":554},[215,1697,1491],{"class":224},[215,1699,1701],{"class":84,"line":1700},20,[215,1702,314],{"emptyLinePlaceholder":313},[215,1704,1706,1708,1710,1712,1714,1717],{"class":84,"line":1705},21,[215,1707,1416],{"class":320},[215,1709,1654],{"class":266},[215,1711,1580],{"class":224},[215,1713,545],{"class":266},[215,1715,1716],{"class":224},"\"2026-08\"",[215,1718,1719],{"class":266},"}))\n",[215,1721,1723,1725,1727,1729,1732,1734,1736,1739,1741,1744,1747,1749,1751,1753,1755,1757,1759,1761,1763,1765,1767,1770,1772,1774,1776,1778,1780,1783],{"class":84,"line":1722},22,[215,1724,484],{"class":262},[215,1726,1012],{"class":266},[215,1728,490],{"class":262},[215,1730,1731],{"class":266}," [date(",[215,1733,362],{"class":320},[215,1735,336],{"class":266},[215,1737,1738],{"class":320},"9",[215,1740,336],{"class":266},[215,1742,1743],{"class":320},"29",[215,1745,1746],{"class":266},"), date(",[215,1748,362],{"class":320},[215,1750,336],{"class":266},[215,1752,106],{"class":320},[215,1754,336],{"class":266},[215,1756,409],{"class":320},[215,1758,1746],{"class":266},[215,1760,362],{"class":320},[215,1762,336],{"class":266},[215,1764,106],{"class":320},[215,1766,336],{"class":266},[215,1768,1769],{"class":320},"2",[215,1771,1746],{"class":266},[215,1773,362],{"class":320},[215,1775,336],{"class":266},[215,1777,106],{"class":320},[215,1779,336],{"class":266},[215,1781,1782],{"class":320},"30",[215,1784,1785],{"class":266},")]:\n",[215,1787,1789,1792],{"class":84,"line":1788},23,[215,1790,1791],{"class":320},"    print",[215,1793,1794],{"class":266},"(run_if_due(d))\n",[10,1796,1797],{},"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.",[201,1799,1801],{"id":1800},"common-pitfalls","Common pitfalls",[1803,1804,1805,1817,1823,1832,1838,1844],"ul",{},[1806,1807,1808,1813,1814,28],"li",{},[1157,1809,1810,1812],{},[19,1811,21],{}," for business days."," It ignores holidays; pass a holiday list to ",[19,1815,1816],{},"busday_offset",[1806,1818,1819,1822],{},[1157,1820,1821],{},"Both cron day fields restricted."," Cron ORs them; restrict one and decide in Python.",[1806,1824,1825,1828,1829,28],{},[1157,1826,1827],{},"Server time zone."," Decide the date in the business's time zone with ",[19,1830,1831],{},"zoneinfo",[1806,1833,1834,1837],{},[1157,1835,1836],{},"A holiday calendar that runs out."," Generate holidays for the years ahead and alert when the list ends.",[1806,1839,1840,1843],{},[1157,1841,1842],{},"No catch-up."," A missed day should not mean a missed month; record the last period reported.",[1806,1845,1846,1849],{},[1157,1847,1848],{},"Company closures."," Add office closure days to the public holiday list.",[201,1851,1853],{"id":1852},"performance-notes","Performance notes",[10,1855,1856],{},"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.",[201,1858,1860],{"id":1859},"conclusion","Conclusion",[10,1862,1863,1864,1866],{},"Schedule month-end jobs every weekday and let the script decide whether to run. Find the last business day with ",[19,1865,249],{}," 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.",[201,1868,1870],{"id":1869},"frequently-asked-questions","Frequently asked questions",[10,1872,1873,1876],{},[1157,1874,1875],{},"Can cron run a job on the last business day of the month?","\nNot 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.",[10,1878,1879,1882],{},[1157,1880,1881],{},"Why does pandas BMonthEnd give the wrong date?","\nBMonthEnd 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.",[10,1884,1885,1888],{},[1157,1886,1887],{},"Why does my cron line run every weekday instead of at month end?","\nWhen 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.",[10,1890,1891,1894],{},[1157,1892,1893],{},"What if the server is down on the last business day?","\nRecord 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.",[201,1896,1898],{"id":1897},"related","Related",[1803,1900,1901,1907,1914,1921],{},[1806,1902,1903,1904,1906],{},"Up one level: ",[24,1905,27],{"href":26}," — cron in full.",[1806,1908,1909,1913],{},[24,1910,1912],{"href":1911},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fbackfill-excel-reports-for-past-dates\u002F","Backfill Excel Reports for Past Dates"," — rerunning past periods.",[1806,1915,1916,1920],{},[24,1917,1919],{"href":1918},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-an-excel-report-with-systemd-timers\u002F","Schedule an Excel Report with systemd Timers"," — persistent timers that catch up.",[1806,1922,1923,1927],{},[24,1924,1926],{"href":1925},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002F","Building Finance and Operations Reports"," — the reports that run at month end.",[1929,1930,1931],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}",{"title":211,"searchDepth":276,"depth":276,"links":1933},[1934,1935,1936,1937,1938,1939,1940,1941,1942,1943,1944],{"id":203,"depth":276,"text":204},{"id":243,"depth":276,"text":244},{"id":889,"depth":276,"text":890},{"id":1094,"depth":276,"text":1095},{"id":1174,"depth":276,"text":1175},{"id":1379,"depth":276,"text":1380},{"id":1800,"depth":276,"text":1801},{"id":1852,"depth":276,"text":1853},{"id":1859,"depth":276,"text":1860},{"id":1869,"depth":276,"text":1870},{"id":1897,"depth":276,"text":1898},"2026-09-25","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.","md",[1949,1951,1953,1955],{"q":1875,"a":1950},"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.",{"q":1881,"a":1952},"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.",{"q":1887,"a":1954},"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.",{"q":1893,"a":1956},"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.","run a report on the last business day of the month",null,{"breadcrumb":1960},[1961,1964,1967],{"name":1962,"item":1963},"Home","\u002F",{"name":1965,"item":1966},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":27,"item":26},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-report-on-the-last-business-day-of-the-month",{"title":1970,"description":1971},"Schedule a Python Report on the Last Business Day of the Month","Run an Excel report on the last business day or nth business day of the month: numpy busday_offset with holidays, pandas BMonthEnd and why it ignores holidays, the cron day-of-month OR day-of-week trap, time zones and catch-up runs.","run-a-report-on-the-last-business-day-of-the-month","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-report-on-the-last-business-day-of-the-month\u002Findex",[1975,1976],"scheduling","dates-and-times","how-to","QICTSZeVxsVlBcIaRHqcwcirL7U0AkhkA5zgHuPO6WY",[1980,1984],{"title":1981,"path":1982,"stem":1983,"children":-1},"Run a Python Excel Report in GitHub Actions","\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-github-actions","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-github-actions\u002Findex",{"title":1985,"path":1986,"stem":1987,"children":-1},"Run an Excel Report as a Kubernetes CronJob","\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-an-excel-report-as-a-kubernetes-cronjob","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-an-excel-report-as-a-kubernetes-cronjob\u002Findex",[1989,1991],{"path":1990,"title":1965},"\u002Fautomating-reporting-workflows",{"path":1992,"title":27},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron",[],[1995,1998,2001,2004,2007,2010],{"path":1996,"title":1997,"kind":1977},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fdownload-new-excel-files-from-an-sftp-server","Download New Excel Files from an SFTP Server",{"path":1999,"title":2000,"kind":1977},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fread-excel-attachments-into-a-database-nightly","Read Excel Attachments into a Database Nightly",{"path":2002,"title":2003,"kind":1977},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Frefresh-an-excel-report-from-a-database-on-a-schedule","Refresh an Excel Report from a Database on a Schedule",{"path":2005,"title":2006,"kind":1977},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Frun-a-report-when-an-excel-file-changes-in-onedrive","Run a Report When an Excel File Changes in OneDrive",{"path":2008,"title":2009,"kind":1977},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-report-emails-at-each-recipients-local-time","Send Report Emails at Each Recipient's Local Time",{"path":2011,"title":2012,"kind":1977},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run","Detect a Scheduled Report That Did Not Run",1790972138788]