[{"data":1,"prerenderedAt":3148},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-excel-report-per-region-in-a-loop":3,"surround:\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-excel-report-per-region-in-a-loop":3140},{"id":4,"title":5,"body":6,"dateModified":3108,"datePublished":3108,"description":3109,"extension":3110,"faq":3111,"meta":3122,"navigation":236,"path":3132,"seo":3133,"slug":3136,"stem":3137,"type":3138,"__hash__":3139},"docs\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-excel-report-per-region-in-a-loop\u002Findex.md","Generate One Excel Report per Region in a Loop",{"type":7,"value":8,"toc":3094},"minimark",[9,13,22,167,172,201,204,208,535,545,549,1170,1183,1186,1190,1197,1900,1921,1925,1932,2079,2082,2109,2179,2182,2186,2189,2504,2521,2629,2633,2636,2853,2864,2868,2994,2998,3001,3004,3008,3014,3018,3025,3031,3037,3043,3047,3050,3059,3062,3090],[10,11,12],"p",{},"A dataset that produces one report for the whole business usually produces twelve for the regions, or forty for the branches, or two hundred for the account managers. The loop itself is three lines. Everything that makes it survivable in a scheduled job is the rest of this page: a filename that cannot collide, a failure that does not take the other eleven with it, and a record of what was actually produced.",[10,14,15,16,21],{},"This guide is part of ",[17,18,20],"a",{"href":19},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002F","Generating Excel Reports from Templates",", and it applies whether each workbook is built from scratch or filled from a template.",[23,24,32,33,32,37,32,41,32,48,32,55,32,64,32,70,32,73,32,77,32,83,32,88,32,95,32,100,32,105,32,109,32,113,32,118,32,121,32,124,32,127,32,130,32,133,32,136,32,139,32,143,32,146,32,149,32,152,32,161],"svg",{"viewBox":25,"role":26,"ariaLabelledBy":27,"xmlns":30,"style":31},"0 0 740 250","img",[28,29],"pr-batch-t","pr-batch-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:740px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[34,35,36],"title",{"id":28},"One failing group should not end the batch",[38,39,40],"desc",{"id":29},"Without per-group error handling, an exception in the third region ends the loop and the last nine reports are never produced, while the two already written are left with no record. With a try around each group, the failure is recorded, the remaining reports are generated, and the run ends with a summary naming exactly which group failed and why.",[42,43],"rect",{"x":44,"y":44,"width":45,"height":46,"fill":47},"0","740","250","#ffffff",[49,50,54],"text",{"x":51,"y":52,"style":53},"184","32","font-size:12.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","no isolation",[42,56],{"x":57,"y":58,"width":59,"height":60,"rx":61,"fill":62,"stroke":63},"24","52","60","34","6","#d9f4f1","var(--teal,#0f9488)",[49,65,69],{"x":66,"y":67,"style":68},"54","74","font-size:11px;fill:var(--text,#172033);text-anchor:middle","North",[42,71],{"x":72,"y":58,"width":59,"height":60,"rx":61,"fill":62,"stroke":63},"92",[49,74,76],{"x":75,"y":67,"style":68},"122","South",[42,78],{"x":79,"y":58,"width":59,"height":60,"rx":61,"fill":80,"stroke":81,"style":82},"160","#fce9e9","var(--accent-ink,#be185d)","stroke-width:2px",[49,84,87],{"x":85,"y":67,"style":86},"190","font-size:11px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","East ✗",[42,89],{"x":90,"y":58,"width":91,"height":60,"rx":61,"fill":92,"stroke":93,"style":94},"228","116","#f0f2f5","var(--line,#cdd5e6)","stroke-dasharray:4 3",[49,96,99],{"x":97,"y":67,"style":98},"286","font-size:11px;fill:var(--muted,#5b6780);text-anchor:middle","nine never run",[49,101,104],{"x":51,"y":102,"style":103},"118","font-size:11.5px;fill:var(--text,#172033);text-anchor:middle","two files exist, ten do not",[49,106,108],{"x":51,"y":107,"style":98},"144","and nothing records which two,",[49,110,112],{"x":51,"y":111,"style":98},"162","so the rerun starts from the beginning",[49,114,117],{"x":115,"y":52,"style":116},"556","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","try per group",[42,119],{"x":120,"y":58,"width":59,"height":60,"rx":61,"fill":62,"stroke":63},"396",[49,122,69],{"x":123,"y":67,"style":68},"426",[42,125],{"x":126,"y":58,"width":59,"height":60,"rx":61,"fill":62,"stroke":63},"464",[49,128,76],{"x":129,"y":67,"style":68},"494",[42,131],{"x":132,"y":58,"width":59,"height":60,"rx":61,"fill":80,"stroke":81,"style":82},"532",[49,134,87],{"x":135,"y":67,"style":86},"562",[42,137],{"x":138,"y":58,"width":91,"height":60,"rx":61,"fill":62,"stroke":63},"600",[49,140,142],{"x":141,"y":67,"style":68},"658","nine more ✓",[49,144,145],{"x":115,"y":102,"style":103},"eleven files, one named failure",[49,147,148],{"x":115,"y":107,"style":98},"the manifest says which group failed,",[49,150,151],{"x":115,"y":111,"style":98},"why, and what the rerun needs to cover",[42,153],{"x":154,"y":155,"width":156,"height":157,"rx":158,"fill":159,"stroke":160},"120","192","500","42","10","#f0f4ff","var(--brand,#5b5cf0)",[49,162,166],{"x":163,"y":164,"style":165},"370","218","font-size:11.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","The job still exits non-zero — it just delivers everything it could first",[168,169,171],"h2",{"id":170},"prerequisites","Prerequisites",[173,174,179],"pre",{"className":175,"code":176,"language":177,"meta":178,"style":178},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl\n","bash","",[180,181,182],"code",{"__ignoreMap":178},[183,184,187,191,195,198],"span",{"class":185,"line":186},"line",1,[183,188,190],{"class":189},"sMTad","pip",[183,192,194],{"class":193},"srMev"," install",[183,196,197],{"class":193}," pandas",[183,199,200],{"class":193}," openpyxl\n",[10,202,203],{},"The examples generate their own data, so the guide runs end to end.",[168,205,207],{"id":206},"step-1-group-the-data","Step 1: Group the data",[173,209,213],{"className":210,"code":211,"language":212,"meta":178,"style":178},"language-python shiki shiki-themes github-light github-dark-high-contrast","from pathlib import Path\n\nimport pandas as pd\n\nsales = pd.DataFrame({\n    \"region\": [\"North\", \"South\", \"East\u002FWest\", \"North\", \"South\", \"Nordics & Baltics\"],\n    \"product\": [\"A\", \"B\", \"A\", \"C\", \"A\", \"B\"],\n    \"amount\": [150.25, 274.75, 75.0, 190.4, 88.1, 320.0],\n    \"order_date\": pd.to_datetime([\"2026-07-02\", \"2026-07-05\", \"2026-07-09\",\n                                  \"2026-07-14\", \"2026-07-21\", \"2026-07-28\"]),\n})\n\nOUT = Path(\"reports\u002F2026-07\")\nOUT.mkdir(parents=True, exist_ok=True)\n\ngroups = dict(tuple(sales.groupby(\"region\")))\nprint(list(groups))\n# ['East\u002FWest', 'Nordics & Baltics', 'North', 'South']\n","python",[180,214,215,231,238,252,257,269,308,343,382,407,426,432,437,455,483,488,514,528],{"__ignoreMap":178},[183,216,217,221,225,228],{"class":185,"line":186},[183,218,220],{"class":219},"s-kum","from",[183,222,224],{"class":223},"skGVy"," pathlib ",[183,226,227],{"class":219},"import",[183,229,230],{"class":223}," Path\n",[183,232,234],{"class":185,"line":233},2,[183,235,237],{"emptyLinePlaceholder":236},true,"\n",[183,239,241,243,246,249],{"class":185,"line":240},3,[183,242,227],{"class":219},[183,244,245],{"class":223}," pandas ",[183,247,248],{"class":219},"as",[183,250,251],{"class":223}," pd\n",[183,253,255],{"class":185,"line":254},4,[183,256,237],{"emptyLinePlaceholder":236},[183,258,260,263,266],{"class":185,"line":259},5,[183,261,262],{"class":223},"sales ",[183,264,265],{"class":219},"=",[183,267,268],{"class":223}," pd.DataFrame({\n",[183,270,272,275,278,281,284,287,289,292,294,296,298,300,302,305],{"class":185,"line":271},6,[183,273,274],{"class":193},"    \"region\"",[183,276,277],{"class":223},": [",[183,279,280],{"class":193},"\"North\"",[183,282,283],{"class":223},", ",[183,285,286],{"class":193},"\"South\"",[183,288,283],{"class":223},[183,290,291],{"class":193},"\"East\u002FWest\"",[183,293,283],{"class":223},[183,295,280],{"class":193},[183,297,283],{"class":223},[183,299,286],{"class":193},[183,301,283],{"class":223},[183,303,304],{"class":193},"\"Nordics & Baltics\"",[183,306,307],{"class":223},"],\n",[183,309,311,314,316,319,321,324,326,328,330,333,335,337,339,341],{"class":185,"line":310},7,[183,312,313],{"class":193},"    \"product\"",[183,315,277],{"class":223},[183,317,318],{"class":193},"\"A\"",[183,320,283],{"class":223},[183,322,323],{"class":193},"\"B\"",[183,325,283],{"class":223},[183,327,318],{"class":193},[183,329,283],{"class":223},[183,331,332],{"class":193},"\"C\"",[183,334,283],{"class":223},[183,336,318],{"class":193},[183,338,283],{"class":223},[183,340,323],{"class":193},[183,342,307],{"class":223},[183,344,346,349,351,355,357,360,362,365,367,370,372,375,377,380],{"class":185,"line":345},8,[183,347,348],{"class":193},"    \"amount\"",[183,350,277],{"class":223},[183,352,354],{"class":353},"sP0c6","150.25",[183,356,283],{"class":223},[183,358,359],{"class":353},"274.75",[183,361,283],{"class":223},[183,363,364],{"class":353},"75.0",[183,366,283],{"class":223},[183,368,369],{"class":353},"190.4",[183,371,283],{"class":223},[183,373,374],{"class":353},"88.1",[183,376,283],{"class":223},[183,378,379],{"class":353},"320.0",[183,381,307],{"class":223},[183,383,385,388,391,394,396,399,401,404],{"class":185,"line":384},9,[183,386,387],{"class":193},"    \"order_date\"",[183,389,390],{"class":223},": pd.to_datetime([",[183,392,393],{"class":193},"\"2026-07-02\"",[183,395,283],{"class":223},[183,397,398],{"class":193},"\"2026-07-05\"",[183,400,283],{"class":223},[183,402,403],{"class":193},"\"2026-07-09\"",[183,405,406],{"class":223},",\n",[183,408,410,413,415,418,420,423],{"class":185,"line":409},10,[183,411,412],{"class":193},"                                  \"2026-07-14\"",[183,414,283],{"class":223},[183,416,417],{"class":193},"\"2026-07-21\"",[183,419,283],{"class":223},[183,421,422],{"class":193},"\"2026-07-28\"",[183,424,425],{"class":223},"]),\n",[183,427,429],{"class":185,"line":428},11,[183,430,431],{"class":223},"})\n",[183,433,435],{"class":185,"line":434},12,[183,436,237],{"emptyLinePlaceholder":236},[183,438,440,443,446,449,452],{"class":185,"line":439},13,[183,441,442],{"class":353},"OUT",[183,444,445],{"class":219}," =",[183,447,448],{"class":223}," Path(",[183,450,451],{"class":193},"\"reports\u002F2026-07\"",[183,453,454],{"class":223},")\n",[183,456,458,460,463,467,469,472,474,477,479,481],{"class":185,"line":457},14,[183,459,442],{"class":353},[183,461,462],{"class":223},".mkdir(",[183,464,466],{"class":465},"sa561","parents",[183,468,265],{"class":219},[183,470,471],{"class":353},"True",[183,473,283],{"class":223},[183,475,476],{"class":465},"exist_ok",[183,478,265],{"class":219},[183,480,471],{"class":353},[183,482,454],{"class":223},[183,484,486],{"class":185,"line":485},15,[183,487,237],{"emptyLinePlaceholder":236},[183,489,491,494,496,499,502,505,508,511],{"class":185,"line":490},16,[183,492,493],{"class":223},"groups ",[183,495,265],{"class":219},[183,497,498],{"class":353}," dict",[183,500,501],{"class":223},"(",[183,503,504],{"class":353},"tuple",[183,506,507],{"class":223},"(sales.groupby(",[183,509,510],{"class":193},"\"region\"",[183,512,513],{"class":223},")))\n",[183,515,517,520,522,525],{"class":185,"line":516},17,[183,518,519],{"class":353},"print",[183,521,501],{"class":223},[183,523,524],{"class":353},"list",[183,526,527],{"class":223},"(groups))\n",[183,529,531],{"class":185,"line":530},18,[183,532,534],{"class":533},"s-wDw","# ['East\u002FWest', 'Nordics & Baltics', 'North', 'South']\n",[10,536,537,540,541,544],{},[180,538,539],{},"East\u002FWest"," and ",[180,542,543],{},"Nordics & Baltics"," are there on purpose. Both are perfectly reasonable region names and both break a naive filename — the first introduces a directory separator, the second an ampersand and spaces.",[168,546,548],{"id":547},"step-2-build-a-filename-that-cannot-hurt-you","Step 2: Build a filename that cannot hurt you",[173,550,552],{"className":210,"code":551,"language":212,"meta":178,"style":178},"import re\nimport unicodedata\n\nILLEGAL = re.compile(r'[\u003C>:\"\u002F\\\\|?*\\x00-\\x1f]')\nWINDOWS_RESERVED = {\"CON\", \"PRN\", \"AUX\", \"NUL\",\n                    *(f\"COM{i}\" for i in range(1, 10)),\n                    *(f\"LPT{i}\" for i in range(1, 10))}\n\n\ndef slugify(name, max_length=60):\n    \"\"\"A filename fragment that is safe on every filesystem.\"\"\"\n    text = unicodedata.normalize(\"NFKD\", str(name))\n    text = text.encode(\"ascii\", \"ignore\").decode()      # drop accents\n    text = ILLEGAL.sub(\"-\", text)\n    text = re.sub(r\"[\\s&]+\", \"-\", text).strip(\"-. \")\n    text = re.sub(r\"-{2,}\", \"-\", text)\n    if text.upper().split(\".\")[0] in WINDOWS_RESERVED:\n        text = f\"{text}-report\"\n    return (text[:max_length].strip(\"-. \") or \"unnamed\").lower()\n\n\ndef unique_path(directory, stem, suffix=\".xlsx\", used=None):\n    \"\"\"A path that does not collide, even if two names slugify identically.\"\"\"\n    used = used if used is not None else set()\n    candidate, n = stem, 1\n    while candidate in used or (directory \u002F f\"{candidate}{suffix}\").exists():\n        n += 1\n        candidate = f\"{stem}-{n}\"\n    used.add(candidate)\n    return directory \u002F f\"{candidate}{suffix}\"\n",[180,553,554,561,568,572,618,648,697,735,739,743,762,767,788,811,829,867,891,918,939,962,967,972,998,1004,1038,1052,1095,1107,1138,1144],{"__ignoreMap":178},[183,555,556,558],{"class":185,"line":186},[183,557,227],{"class":219},[183,559,560],{"class":223}," re\n",[183,562,563,565],{"class":185,"line":233},[183,564,227],{"class":219},[183,566,567],{"class":223}," unicodedata\n",[183,569,570],{"class":185,"line":240},[183,571,237],{"emptyLinePlaceholder":236},[183,573,574,577,579,582,585,588,591,595,599,602,605,608,611,614,616],{"class":185,"line":254},[183,575,576],{"class":353},"ILLEGAL",[183,578,445],{"class":219},[183,580,581],{"class":223}," re.compile(",[183,583,584],{"class":219},"r",[183,586,587],{"class":193},"'",[183,589,590],{"class":353},"[",[183,592,594],{"class":593},"sSjpA","\u003C>:\"\u002F",[183,596,598],{"class":597},"s_b0D","\\\\",[183,600,601],{"class":593},"|?*",[183,603,604],{"class":597},"\\x00",[183,606,607],{"class":593},"-",[183,609,610],{"class":597},"\\x1f",[183,612,613],{"class":353},"]",[183,615,587],{"class":193},[183,617,454],{"class":223},[183,619,620,623,625,628,631,633,636,638,641,643,646],{"class":185,"line":259},[183,621,622],{"class":353},"WINDOWS_RESERVED",[183,624,445],{"class":219},[183,626,627],{"class":223}," {",[183,629,630],{"class":193},"\"CON\"",[183,632,283],{"class":223},[183,634,635],{"class":193},"\"PRN\"",[183,637,283],{"class":223},[183,639,640],{"class":193},"\"AUX\"",[183,642,283],{"class":223},[183,644,645],{"class":193},"\"NUL\"",[183,647,406],{"class":223},[183,649,650,653,655,658,661,664,667,670,673,676,679,682,685,687,690,692,694],{"class":185,"line":271},[183,651,652],{"class":219},"                    *",[183,654,501],{"class":223},[183,656,657],{"class":219},"f",[183,659,660],{"class":193},"\"COM",[183,662,663],{"class":593},"{",[183,665,666],{"class":223},"i",[183,668,669],{"class":593},"}",[183,671,672],{"class":193},"\"",[183,674,675],{"class":219}," for",[183,677,678],{"class":223}," i ",[183,680,681],{"class":219},"in",[183,683,684],{"class":353}," range",[183,686,501],{"class":223},[183,688,689],{"class":353},"1",[183,691,283],{"class":223},[183,693,158],{"class":353},[183,695,696],{"class":223},")),\n",[183,698,699,701,703,705,708,710,712,714,716,718,720,722,724,726,728,730,732],{"class":185,"line":310},[183,700,652],{"class":219},[183,702,501],{"class":223},[183,704,657],{"class":219},[183,706,707],{"class":193},"\"LPT",[183,709,663],{"class":593},[183,711,666],{"class":223},[183,713,669],{"class":593},[183,715,672],{"class":193},[183,717,675],{"class":219},[183,719,678],{"class":223},[183,721,681],{"class":219},[183,723,684],{"class":353},[183,725,501],{"class":223},[183,727,689],{"class":353},[183,729,283],{"class":223},[183,731,158],{"class":353},[183,733,734],{"class":223},"))}\n",[183,736,737],{"class":185,"line":345},[183,738,237],{"emptyLinePlaceholder":236},[183,740,741],{"class":185,"line":384},[183,742,237],{"emptyLinePlaceholder":236},[183,744,745,748,752,755,757,759],{"class":185,"line":409},[183,746,747],{"class":219},"def",[183,749,751],{"class":750},"s_Opv"," slugify",[183,753,754],{"class":223},"(name, max_length",[183,756,265],{"class":219},[183,758,59],{"class":353},[183,760,761],{"class":223},"):\n",[183,763,764],{"class":185,"line":428},[183,765,766],{"class":193},"    \"\"\"A filename fragment that is safe on every filesystem.\"\"\"\n",[183,768,769,772,774,777,780,782,785],{"class":185,"line":434},[183,770,771],{"class":223},"    text ",[183,773,265],{"class":219},[183,775,776],{"class":223}," unicodedata.normalize(",[183,778,779],{"class":193},"\"NFKD\"",[183,781,283],{"class":223},[183,783,784],{"class":353},"str",[183,786,787],{"class":223},"(name))\n",[183,789,790,792,794,797,800,802,805,808],{"class":185,"line":439},[183,791,771],{"class":223},[183,793,265],{"class":219},[183,795,796],{"class":223}," text.encode(",[183,798,799],{"class":193},"\"ascii\"",[183,801,283],{"class":223},[183,803,804],{"class":193},"\"ignore\"",[183,806,807],{"class":223},").decode()      ",[183,809,810],{"class":533},"# drop accents\n",[183,812,813,815,817,820,823,826],{"class":185,"line":457},[183,814,771],{"class":223},[183,816,265],{"class":219},[183,818,819],{"class":353}," ILLEGAL",[183,821,822],{"class":223},".sub(",[183,824,825],{"class":193},"\"-\"",[183,827,828],{"class":223},", text)\n",[183,830,831,833,835,838,840,842,845,848,850,853,855,857,859,862,865],{"class":185,"line":485},[183,832,771],{"class":223},[183,834,265],{"class":219},[183,836,837],{"class":223}," re.sub(",[183,839,584],{"class":219},[183,841,672],{"class":193},[183,843,844],{"class":353},"[\\s",[183,846,847],{"class":593},"&",[183,849,613],{"class":353},[183,851,852],{"class":219},"+",[183,854,672],{"class":193},[183,856,283],{"class":223},[183,858,825],{"class":193},[183,860,861],{"class":223},", text).strip(",[183,863,864],{"class":193},"\"-. \"",[183,866,454],{"class":223},[183,868,869,871,873,875,877,880,883,885,887,889],{"class":185,"line":490},[183,870,771],{"class":223},[183,872,265],{"class":219},[183,874,837],{"class":223},[183,876,584],{"class":219},[183,878,879],{"class":193},"\"-",[183,881,882],{"class":219},"{2,}",[183,884,672],{"class":193},[183,886,283],{"class":223},[183,888,825],{"class":193},[183,890,828],{"class":223},[183,892,893,896,899,902,905,907,910,912,915],{"class":185,"line":516},[183,894,895],{"class":219},"    if",[183,897,898],{"class":223}," text.upper().split(",[183,900,901],{"class":193},"\".\"",[183,903,904],{"class":223},")[",[183,906,44],{"class":353},[183,908,909],{"class":223},"] ",[183,911,681],{"class":219},[183,913,914],{"class":353}," WINDOWS_RESERVED",[183,916,917],{"class":223},":\n",[183,919,920,923,925,928,930,932,934,936],{"class":185,"line":530},[183,921,922],{"class":223},"        text ",[183,924,265],{"class":219},[183,926,927],{"class":219}," f",[183,929,672],{"class":193},[183,931,663],{"class":593},[183,933,49],{"class":223},[183,935,669],{"class":593},[183,937,938],{"class":193},"-report\"\n",[183,940,942,945,948,950,953,956,959],{"class":185,"line":941},19,[183,943,944],{"class":219},"    return",[183,946,947],{"class":223}," (text[:max_length].strip(",[183,949,864],{"class":193},[183,951,952],{"class":223},") ",[183,954,955],{"class":219},"or",[183,957,958],{"class":193}," \"unnamed\"",[183,960,961],{"class":223},").lower()\n",[183,963,965],{"class":185,"line":964},20,[183,966,237],{"emptyLinePlaceholder":236},[183,968,970],{"class":185,"line":969},21,[183,971,237],{"emptyLinePlaceholder":236},[183,973,975,977,980,983,985,988,991,993,996],{"class":185,"line":974},22,[183,976,747],{"class":219},[183,978,979],{"class":750}," unique_path",[183,981,982],{"class":223},"(directory, stem, suffix",[183,984,265],{"class":219},[183,986,987],{"class":193},"\".xlsx\"",[183,989,990],{"class":223},", used",[183,992,265],{"class":219},[183,994,995],{"class":353},"None",[183,997,761],{"class":223},[183,999,1001],{"class":185,"line":1000},23,[183,1002,1003],{"class":193},"    \"\"\"A path that does not collide, even if two names slugify identically.\"\"\"\n",[183,1005,1007,1010,1012,1015,1018,1020,1023,1026,1029,1032,1035],{"class":185,"line":1006},24,[183,1008,1009],{"class":223},"    used ",[183,1011,265],{"class":219},[183,1013,1014],{"class":223}," used ",[183,1016,1017],{"class":219},"if",[183,1019,1014],{"class":223},[183,1021,1022],{"class":219},"is",[183,1024,1025],{"class":219}," not",[183,1027,1028],{"class":353}," None",[183,1030,1031],{"class":219}," else",[183,1033,1034],{"class":353}," set",[183,1036,1037],{"class":223},"()\n",[183,1039,1041,1044,1046,1049],{"class":185,"line":1040},25,[183,1042,1043],{"class":223},"    candidate, n ",[183,1045,265],{"class":219},[183,1047,1048],{"class":223}," stem, ",[183,1050,1051],{"class":353},"1\n",[183,1053,1055,1058,1061,1063,1065,1067,1070,1073,1075,1077,1079,1082,1085,1088,1090,1092],{"class":185,"line":1054},26,[183,1056,1057],{"class":219},"    while",[183,1059,1060],{"class":223}," candidate ",[183,1062,681],{"class":219},[183,1064,1014],{"class":223},[183,1066,955],{"class":219},[183,1068,1069],{"class":223}," (directory ",[183,1071,1072],{"class":219},"\u002F",[183,1074,927],{"class":219},[183,1076,672],{"class":193},[183,1078,663],{"class":593},[183,1080,1081],{"class":223},"candidate",[183,1083,1084],{"class":593},"}{",[183,1086,1087],{"class":223},"suffix",[183,1089,669],{"class":593},[183,1091,672],{"class":193},[183,1093,1094],{"class":223},").exists():\n",[183,1096,1098,1101,1104],{"class":185,"line":1097},27,[183,1099,1100],{"class":223},"        n ",[183,1102,1103],{"class":219},"+=",[183,1105,1106],{"class":353}," 1\n",[183,1108,1110,1113,1115,1117,1119,1121,1124,1126,1128,1130,1133,1135],{"class":185,"line":1109},28,[183,1111,1112],{"class":223},"        candidate ",[183,1114,265],{"class":219},[183,1116,927],{"class":219},[183,1118,672],{"class":193},[183,1120,663],{"class":593},[183,1122,1123],{"class":223},"stem",[183,1125,669],{"class":593},[183,1127,607],{"class":193},[183,1129,663],{"class":593},[183,1131,1132],{"class":223},"n",[183,1134,669],{"class":593},[183,1136,1137],{"class":193},"\"\n",[183,1139,1141],{"class":185,"line":1140},29,[183,1142,1143],{"class":223},"    used.add(candidate)\n",[183,1145,1147,1149,1152,1154,1156,1158,1160,1162,1164,1166,1168],{"class":185,"line":1146},30,[183,1148,944],{"class":219},[183,1150,1151],{"class":223}," directory ",[183,1153,1072],{"class":219},[183,1155,927],{"class":219},[183,1157,672],{"class":193},[183,1159,663],{"class":593},[183,1161,1081],{"class":223},[183,1163,1084],{"class":593},[183,1165,1087],{"class":223},[183,1167,669],{"class":593},[183,1169,1137],{"class":193},[10,1171,1172,1173,540,1175,1178,1179,1182],{},"The collision check is the part that is easy to skip and expensive to skip. ",[180,1174,539],{},[180,1176,1177],{},"East-West"," both slugify to ",[180,1180,1181],{},"east-west",", and without the check the second silently overwrites the first — a region receives another region's numbers, which is the worst failure mode this whole page exists to prevent.",[10,1184,1185],{},"Stripping trailing dots and spaces matters on Windows, where a file ending in either is legal to create through some APIs and then impossible to open or delete.",[168,1187,1189],{"id":1188},"step-3-loop-with-per-group-isolation","Step 3: Loop with per-group isolation",[10,1191,1192,1193,1196],{},"Each group gets its own ",[180,1194,1195],{},"try",", so one bad region costs one report rather than twelve:",[173,1198,1200],{"className":210,"code":1199,"language":212,"meta":178,"style":178},"import logging\nimport os\nfrom datetime import datetime\n\nlog = logging.getLogger(\"batch\")\n\n\ndef build_workbook(frame, region, path):\n    \"\"\"Write one region's workbook. Atomic: a failure leaves no partial file.\"\"\"\n    tmp = path.with_name(f\".{path.stem}.tmp{path.suffix}\")\n    summary = (frame.groupby(\"product\", as_index=False)[\"amount\"]\n                    .sum().sort_values(\"amount\", ascending=False))\n\n    with pd.ExcelWriter(tmp, engine=\"openpyxl\",\n                        datetime_format=\"yyyy-mm-dd\") as writer:\n        summary.to_excel(writer, sheet_name=\"Summary\", index=False, startrow=2)\n        frame.to_excel(writer, sheet_name=\"Detail\", index=False)\n\n        ws = writer.sheets[\"Summary\"]\n        ws[\"A1\"] = (f\"{region} — July 2026 · {len(frame):,} orders · \"\n                    f\"generated {datetime.now():%Y-%m-%d %H:%M}\")\n        ws.freeze_panes = \"A4\"\n        for cell in ws[\"B\"][3:]:\n            cell.number_format = '#,##0.00'\n\n    os.replace(tmp, path)              # atomic: readers never see a partial file\n    return path\n\n\ndef generate_all(groups, out_dir):\n    written, failed, used = [], [], set()\n\n    for region, frame in sorted(groups.items()):\n        path = unique_path(out_dir, slugify(region), used=used)\n        try:\n            build_workbook(frame, region, path)\n            written.append({\"region\": region, \"path\": str(path),\n                            \"rows\": len(frame),\n                            \"amount\": round(float(frame[\"amount\"].sum()), 2)})\n            log.info(\"wrote %s (%d rows)\", path.name, len(frame))\n        except Exception as exc:                       # one region, one failure\n            failed.append({\"region\": region, \"error\": f\"{type(exc).__name__}: {exc}\"})\n            log.error(\"FAILED %s: %s\", region, exc)\n\n    return written, failed\n",[180,1201,1202,1209,1216,1228,1232,1247,1251,1255,1265,1270,1306,1337,1356,1360,1378,1395,1429,1451,1455,1469,1514,1560,1570,1594,1604,1608,1616,1623,1627,1631,1641,1657,1662,1679,1698,1706,1712,1734,1747,1776,1804,1822,1867,1887,1892],{"__ignoreMap":178},[183,1203,1204,1206],{"class":185,"line":186},[183,1205,227],{"class":219},[183,1207,1208],{"class":223}," logging\n",[183,1210,1211,1213],{"class":185,"line":233},[183,1212,227],{"class":219},[183,1214,1215],{"class":223}," os\n",[183,1217,1218,1220,1223,1225],{"class":185,"line":240},[183,1219,220],{"class":219},[183,1221,1222],{"class":223}," datetime ",[183,1224,227],{"class":219},[183,1226,1227],{"class":223}," datetime\n",[183,1229,1230],{"class":185,"line":254},[183,1231,237],{"emptyLinePlaceholder":236},[183,1233,1234,1237,1239,1242,1245],{"class":185,"line":259},[183,1235,1236],{"class":223},"log ",[183,1238,265],{"class":219},[183,1240,1241],{"class":223}," logging.getLogger(",[183,1243,1244],{"class":193},"\"batch\"",[183,1246,454],{"class":223},[183,1248,1249],{"class":185,"line":271},[183,1250,237],{"emptyLinePlaceholder":236},[183,1252,1253],{"class":185,"line":310},[183,1254,237],{"emptyLinePlaceholder":236},[183,1256,1257,1259,1262],{"class":185,"line":345},[183,1258,747],{"class":219},[183,1260,1261],{"class":750}," build_workbook",[183,1263,1264],{"class":223},"(frame, region, path):\n",[183,1266,1267],{"class":185,"line":384},[183,1268,1269],{"class":193},"    \"\"\"Write one region's workbook. Atomic: a failure leaves no partial file.\"\"\"\n",[183,1271,1272,1275,1277,1280,1282,1285,1287,1290,1292,1295,1297,1300,1302,1304],{"class":185,"line":409},[183,1273,1274],{"class":223},"    tmp ",[183,1276,265],{"class":219},[183,1278,1279],{"class":223}," path.with_name(",[183,1281,657],{"class":219},[183,1283,1284],{"class":193},"\".",[183,1286,663],{"class":593},[183,1288,1289],{"class":223},"path.stem",[183,1291,669],{"class":593},[183,1293,1294],{"class":193},".tmp",[183,1296,663],{"class":593},[183,1298,1299],{"class":223},"path.suffix",[183,1301,669],{"class":593},[183,1303,672],{"class":193},[183,1305,454],{"class":223},[183,1307,1308,1311,1313,1316,1319,1321,1324,1326,1329,1331,1334],{"class":185,"line":428},[183,1309,1310],{"class":223},"    summary ",[183,1312,265],{"class":219},[183,1314,1315],{"class":223}," (frame.groupby(",[183,1317,1318],{"class":193},"\"product\"",[183,1320,283],{"class":223},[183,1322,1323],{"class":465},"as_index",[183,1325,265],{"class":219},[183,1327,1328],{"class":353},"False",[183,1330,904],{"class":223},[183,1332,1333],{"class":193},"\"amount\"",[183,1335,1336],{"class":223},"]\n",[183,1338,1339,1342,1344,1346,1349,1351,1353],{"class":185,"line":434},[183,1340,1341],{"class":223},"                    .sum().sort_values(",[183,1343,1333],{"class":193},[183,1345,283],{"class":223},[183,1347,1348],{"class":465},"ascending",[183,1350,265],{"class":219},[183,1352,1328],{"class":353},[183,1354,1355],{"class":223},"))\n",[183,1357,1358],{"class":185,"line":439},[183,1359,237],{"emptyLinePlaceholder":236},[183,1361,1362,1365,1368,1371,1373,1376],{"class":185,"line":457},[183,1363,1364],{"class":219},"    with",[183,1366,1367],{"class":223}," pd.ExcelWriter(tmp, ",[183,1369,1370],{"class":465},"engine",[183,1372,265],{"class":219},[183,1374,1375],{"class":193},"\"openpyxl\"",[183,1377,406],{"class":223},[183,1379,1380,1383,1385,1388,1390,1392],{"class":185,"line":485},[183,1381,1382],{"class":465},"                        datetime_format",[183,1384,265],{"class":219},[183,1386,1387],{"class":193},"\"yyyy-mm-dd\"",[183,1389,952],{"class":223},[183,1391,248],{"class":219},[183,1393,1394],{"class":223}," writer:\n",[183,1396,1397,1400,1403,1405,1408,1410,1413,1415,1417,1419,1422,1424,1427],{"class":185,"line":490},[183,1398,1399],{"class":223},"        summary.to_excel(writer, ",[183,1401,1402],{"class":465},"sheet_name",[183,1404,265],{"class":219},[183,1406,1407],{"class":193},"\"Summary\"",[183,1409,283],{"class":223},[183,1411,1412],{"class":465},"index",[183,1414,265],{"class":219},[183,1416,1328],{"class":353},[183,1418,283],{"class":223},[183,1420,1421],{"class":465},"startrow",[183,1423,265],{"class":219},[183,1425,1426],{"class":353},"2",[183,1428,454],{"class":223},[183,1430,1431,1434,1436,1438,1441,1443,1445,1447,1449],{"class":185,"line":516},[183,1432,1433],{"class":223},"        frame.to_excel(writer, ",[183,1435,1402],{"class":465},[183,1437,265],{"class":219},[183,1439,1440],{"class":193},"\"Detail\"",[183,1442,283],{"class":223},[183,1444,1412],{"class":465},[183,1446,265],{"class":219},[183,1448,1328],{"class":353},[183,1450,454],{"class":223},[183,1452,1453],{"class":185,"line":530},[183,1454,237],{"emptyLinePlaceholder":236},[183,1456,1457,1460,1462,1465,1467],{"class":185,"line":941},[183,1458,1459],{"class":223},"        ws ",[183,1461,265],{"class":219},[183,1463,1464],{"class":223}," writer.sheets[",[183,1466,1407],{"class":193},[183,1468,1336],{"class":223},[183,1470,1471,1474,1477,1479,1481,1484,1486,1488,1490,1493,1495,1498,1500,1503,1506,1509,1511],{"class":185,"line":964},[183,1472,1473],{"class":223},"        ws[",[183,1475,1476],{"class":193},"\"A1\"",[183,1478,909],{"class":223},[183,1480,265],{"class":219},[183,1482,1483],{"class":223}," (",[183,1485,657],{"class":219},[183,1487,672],{"class":193},[183,1489,663],{"class":593},[183,1491,1492],{"class":223},"region",[183,1494,669],{"class":593},[183,1496,1497],{"class":193}," — July 2026 · ",[183,1499,663],{"class":593},[183,1501,1502],{"class":353},"len",[183,1504,1505],{"class":223},"(frame)",[183,1507,1508],{"class":219},":,",[183,1510,669],{"class":593},[183,1512,1513],{"class":193}," orders · \"\n",[183,1515,1516,1519,1522,1524,1527,1530,1533,1536,1539,1541,1544,1546,1549,1551,1554,1556,1558],{"class":185,"line":969},[183,1517,1518],{"class":219},"                    f",[183,1520,1521],{"class":193},"\"generated ",[183,1523,663],{"class":593},[183,1525,1526],{"class":223},"datetime.now():",[183,1528,1529],{"class":219},"%",[183,1531,1532],{"class":223},"Y",[183,1534,1535],{"class":219},"-%",[183,1537,1538],{"class":223},"m",[183,1540,1535],{"class":219},[183,1542,1543],{"class":223},"d ",[183,1545,1529],{"class":219},[183,1547,1548],{"class":223},"H:",[183,1550,1529],{"class":219},[183,1552,1553],{"class":223},"M",[183,1555,669],{"class":593},[183,1557,672],{"class":193},[183,1559,454],{"class":223},[183,1561,1562,1565,1567],{"class":185,"line":974},[183,1563,1564],{"class":223},"        ws.freeze_panes ",[183,1566,265],{"class":219},[183,1568,1569],{"class":193}," \"A4\"\n",[183,1571,1572,1575,1578,1580,1583,1585,1588,1591],{"class":185,"line":1000},[183,1573,1574],{"class":219},"        for",[183,1576,1577],{"class":223}," cell ",[183,1579,681],{"class":219},[183,1581,1582],{"class":223}," ws[",[183,1584,323],{"class":193},[183,1586,1587],{"class":223},"][",[183,1589,1590],{"class":353},"3",[183,1592,1593],{"class":223},":]:\n",[183,1595,1596,1599,1601],{"class":185,"line":1006},[183,1597,1598],{"class":223},"            cell.number_format ",[183,1600,265],{"class":219},[183,1602,1603],{"class":193}," '#,##0.00'\n",[183,1605,1606],{"class":185,"line":1040},[183,1607,237],{"emptyLinePlaceholder":236},[183,1609,1610,1613],{"class":185,"line":1054},[183,1611,1612],{"class":223},"    os.replace(tmp, path)              ",[183,1614,1615],{"class":533},"# atomic: readers never see a partial file\n",[183,1617,1618,1620],{"class":185,"line":1097},[183,1619,944],{"class":219},[183,1621,1622],{"class":223}," path\n",[183,1624,1625],{"class":185,"line":1109},[183,1626,237],{"emptyLinePlaceholder":236},[183,1628,1629],{"class":185,"line":1140},[183,1630,237],{"emptyLinePlaceholder":236},[183,1632,1633,1635,1638],{"class":185,"line":1146},[183,1634,747],{"class":219},[183,1636,1637],{"class":750}," generate_all",[183,1639,1640],{"class":223},"(groups, out_dir):\n",[183,1642,1644,1647,1649,1652,1655],{"class":185,"line":1643},31,[183,1645,1646],{"class":223},"    written, failed, used ",[183,1648,265],{"class":219},[183,1650,1651],{"class":223}," [], [], ",[183,1653,1654],{"class":353},"set",[183,1656,1037],{"class":223},[183,1658,1660],{"class":185,"line":1659},32,[183,1661,237],{"emptyLinePlaceholder":236},[183,1663,1665,1668,1671,1673,1676],{"class":185,"line":1664},33,[183,1666,1667],{"class":219},"    for",[183,1669,1670],{"class":223}," region, frame ",[183,1672,681],{"class":219},[183,1674,1675],{"class":353}," sorted",[183,1677,1678],{"class":223},"(groups.items()):\n",[183,1680,1682,1685,1687,1690,1693,1695],{"class":185,"line":1681},34,[183,1683,1684],{"class":223},"        path ",[183,1686,265],{"class":219},[183,1688,1689],{"class":223}," unique_path(out_dir, slugify(region), ",[183,1691,1692],{"class":465},"used",[183,1694,265],{"class":219},[183,1696,1697],{"class":223},"used)\n",[183,1699,1701,1704],{"class":185,"line":1700},35,[183,1702,1703],{"class":219},"        try",[183,1705,917],{"class":223},[183,1707,1709],{"class":185,"line":1708},36,[183,1710,1711],{"class":223},"            build_workbook(frame, region, path)\n",[183,1713,1715,1718,1720,1723,1726,1729,1731],{"class":185,"line":1714},37,[183,1716,1717],{"class":223},"            written.append({",[183,1719,510],{"class":193},[183,1721,1722],{"class":223},": region, ",[183,1724,1725],{"class":193},"\"path\"",[183,1727,1728],{"class":223},": ",[183,1730,784],{"class":353},[183,1732,1733],{"class":223},"(path),\n",[183,1735,1737,1740,1742,1744],{"class":185,"line":1736},38,[183,1738,1739],{"class":193},"                            \"rows\"",[183,1741,1728],{"class":223},[183,1743,1502],{"class":353},[183,1745,1746],{"class":223},"(frame),\n",[183,1748,1750,1753,1755,1758,1760,1763,1766,1768,1771,1773],{"class":185,"line":1749},39,[183,1751,1752],{"class":193},"                            \"amount\"",[183,1754,1728],{"class":223},[183,1756,1757],{"class":353},"round",[183,1759,501],{"class":223},[183,1761,1762],{"class":353},"float",[183,1764,1765],{"class":223},"(frame[",[183,1767,1333],{"class":193},[183,1769,1770],{"class":223},"].sum()), ",[183,1772,1426],{"class":353},[183,1774,1775],{"class":223},")})\n",[183,1777,1779,1782,1785,1788,1790,1793,1796,1799,1801],{"class":185,"line":1778},40,[183,1780,1781],{"class":223},"            log.info(",[183,1783,1784],{"class":193},"\"wrote ",[183,1786,1787],{"class":593},"%s",[183,1789,1483],{"class":193},[183,1791,1792],{"class":593},"%d",[183,1794,1795],{"class":193}," rows)\"",[183,1797,1798],{"class":223},", path.name, ",[183,1800,1502],{"class":353},[183,1802,1803],{"class":223},"(frame))\n",[183,1805,1807,1810,1813,1816,1819],{"class":185,"line":1806},41,[183,1808,1809],{"class":219},"        except",[183,1811,1812],{"class":353}," Exception",[183,1814,1815],{"class":219}," as",[183,1817,1818],{"class":223}," exc:                       ",[183,1820,1821],{"class":533},"# one region, one failure\n",[183,1823,1825,1828,1830,1832,1835,1837,1839,1841,1843,1846,1849,1852,1854,1856,1858,1861,1863,1865],{"class":185,"line":1824},42,[183,1826,1827],{"class":223},"            failed.append({",[183,1829,510],{"class":193},[183,1831,1722],{"class":223},[183,1833,1834],{"class":193},"\"error\"",[183,1836,1728],{"class":223},[183,1838,657],{"class":219},[183,1840,672],{"class":193},[183,1842,663],{"class":593},[183,1844,1845],{"class":353},"type",[183,1847,1848],{"class":223},"(exc).",[183,1850,1851],{"class":353},"__name__",[183,1853,669],{"class":593},[183,1855,1728],{"class":193},[183,1857,663],{"class":593},[183,1859,1860],{"class":223},"exc",[183,1862,669],{"class":593},[183,1864,672],{"class":193},[183,1866,431],{"class":223},[183,1868,1870,1873,1876,1878,1880,1882,1884],{"class":185,"line":1869},43,[183,1871,1872],{"class":223},"            log.error(",[183,1874,1875],{"class":193},"\"FAILED ",[183,1877,1787],{"class":593},[183,1879,1728],{"class":193},[183,1881,1787],{"class":593},[183,1883,672],{"class":193},[183,1885,1886],{"class":223},", region, exc)\n",[183,1888,1890],{"class":185,"line":1889},44,[183,1891,237],{"emptyLinePlaceholder":236},[183,1893,1895,1897],{"class":185,"line":1894},45,[183,1896,944],{"class":219},[183,1898,1899],{"class":223}," written, failed\n",[10,1901,1902,1903,1906,1907,1911,1912,1916,1917,1920],{},"Two details do the work. The temporary file plus ",[180,1904,1905],{},"os.replace"," means a crash mid-write leaves no half-formed workbook for someone to open or email — the same atomic-publish pattern used when ",[17,1908,1910],{"href":1909},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Frefresh-an-excel-report-from-a-database-on-a-schedule\u002F","refreshing a report on a schedule",". And catching broadly ",[1913,1914,1915],"em",{},"inside the loop only"," is the one place a bare ",[180,1918,1919],{},"except Exception"," is right: the point is that no single group's problem can end the batch, and every failure is recorded rather than swallowed.",[168,1922,1924],{"id":1923},"step-4-decide-what-an-empty-group-means","Step 4: Decide what an empty group means",[10,1926,1927,1928,1931],{},"A region with no rows produces no group at all from ",[180,1929,1930],{},"groupby",", so it silently vanishes from the output. That is almost never what anyone wants — a missing file reads as \"the job broke\", not \"there was no activity\":",[173,1933,1935],{"className":210,"code":1934,"language":212,"meta":178,"style":178},"ALL_REGIONS = [\"North\", \"South\", \"East\u002FWest\", \"Nordics & Baltics\", \"Highlands\"]\n\n\ndef with_empty_groups(groups, expected, columns):\n    \"\"\"Ensure every expected group is present, with an empty frame if need be.\"\"\"\n    complete = dict(groups)\n    for name in expected:\n        if name not in complete:\n            complete[name] = pd.DataFrame(columns=columns)\n    return complete\n\n\nfull = with_empty_groups(groups, ALL_REGIONS, sales.columns)\n",[180,1936,1937,1968,1972,1976,1986,1991,2003,2015,2031,2049,2056,2060,2064],{"__ignoreMap":178},[183,1938,1939,1942,1944,1947,1949,1951,1953,1955,1957,1959,1961,1963,1966],{"class":185,"line":186},[183,1940,1941],{"class":353},"ALL_REGIONS",[183,1943,445],{"class":219},[183,1945,1946],{"class":223}," [",[183,1948,280],{"class":193},[183,1950,283],{"class":223},[183,1952,286],{"class":193},[183,1954,283],{"class":223},[183,1956,291],{"class":193},[183,1958,283],{"class":223},[183,1960,304],{"class":193},[183,1962,283],{"class":223},[183,1964,1965],{"class":193},"\"Highlands\"",[183,1967,1336],{"class":223},[183,1969,1970],{"class":185,"line":233},[183,1971,237],{"emptyLinePlaceholder":236},[183,1973,1974],{"class":185,"line":240},[183,1975,237],{"emptyLinePlaceholder":236},[183,1977,1978,1980,1983],{"class":185,"line":254},[183,1979,747],{"class":219},[183,1981,1982],{"class":750}," with_empty_groups",[183,1984,1985],{"class":223},"(groups, expected, columns):\n",[183,1987,1988],{"class":185,"line":259},[183,1989,1990],{"class":193},"    \"\"\"Ensure every expected group is present, with an empty frame if need be.\"\"\"\n",[183,1992,1993,1996,1998,2000],{"class":185,"line":271},[183,1994,1995],{"class":223},"    complete ",[183,1997,265],{"class":219},[183,1999,498],{"class":353},[183,2001,2002],{"class":223},"(groups)\n",[183,2004,2005,2007,2010,2012],{"class":185,"line":310},[183,2006,1667],{"class":219},[183,2008,2009],{"class":223}," name ",[183,2011,681],{"class":219},[183,2013,2014],{"class":223}," expected:\n",[183,2016,2017,2020,2022,2025,2028],{"class":185,"line":345},[183,2018,2019],{"class":219},"        if",[183,2021,2009],{"class":223},[183,2023,2024],{"class":219},"not",[183,2026,2027],{"class":219}," in",[183,2029,2030],{"class":223}," complete:\n",[183,2032,2033,2036,2038,2041,2044,2046],{"class":185,"line":384},[183,2034,2035],{"class":223},"            complete[name] ",[183,2037,265],{"class":219},[183,2039,2040],{"class":223}," pd.DataFrame(",[183,2042,2043],{"class":465},"columns",[183,2045,265],{"class":219},[183,2047,2048],{"class":223},"columns)\n",[183,2050,2051,2053],{"class":185,"line":409},[183,2052,944],{"class":219},[183,2054,2055],{"class":223}," complete\n",[183,2057,2058],{"class":185,"line":428},[183,2059,237],{"emptyLinePlaceholder":236},[183,2061,2062],{"class":185,"line":434},[183,2063,237],{"emptyLinePlaceholder":236},[183,2065,2066,2069,2071,2074,2076],{"class":185,"line":439},[183,2067,2068],{"class":223},"full ",[183,2070,265],{"class":219},[183,2072,2073],{"class":223}," with_empty_groups(groups, ",[183,2075,1941],{"class":353},[183,2077,2078],{"class":223},", sales.columns)\n",[10,2080,2081],{},"Then say so on the sheet rather than shipping an empty grid:",[173,2083,2085],{"className":210,"code":2084,"language":212,"meta":178,"style":178},"if frame.empty:\n    ws[\"A3\"] = \"No activity recorded for this region in the period.\"\n",[180,2086,2087,2094],{"__ignoreMap":178},[183,2088,2089,2091],{"class":185,"line":186},[183,2090,1017],{"class":219},[183,2092,2093],{"class":223}," frame.empty:\n",[183,2095,2096,2099,2102,2104,2106],{"class":185,"line":233},[183,2097,2098],{"class":223},"    ws[",[183,2100,2101],{"class":193},"\"A3\"",[183,2103,909],{"class":223},[183,2105,265],{"class":219},[183,2107,2108],{"class":193}," \"No activity recorded for this region in the period.\"\n",[23,2110,32,2115,32,2118,32,2121,32,2123,32,2128,32,2135,32,2141,32,2147,32,2151,32,2155,32,2159,32,2162,32,2167,32,2170,32,2173,32,2176],{"viewBox":2111,"role":26,"ariaLabelledBy":2112,"xmlns":30,"style":31},"0 0 740 218",[2113,2114],"pr-empty-t","pr-empty-d",[34,2116,2117],{"id":2113},"How a region with no rows is read by the person expecting it",[38,2119,2120],{"id":2114},"groupby drops a region with no rows, so no file is produced and the recipient reads the absence as a broken job. Reindexing against the expected list produces a workbook carrying a no-activity note, which reads as an answer instead of a fault.",[42,2122],{"x":44,"y":44,"width":45,"height":164,"fill":47},[49,2124,2127],{"x":163,"y":2125,"style":2126},"30","font-size:12.5px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","Highlands had no orders this period",[42,2129],{"x":2130,"y":2131,"width":2132,"height":2133,"rx":2134,"fill":80,"stroke":81,"style":82},"16","50","344","146","14",[49,2136,2140],{"x":2137,"y":2138,"style":2139},"188","78","font-size:12px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","groupby alone",[42,2142],{"x":58,"y":2143,"width":2144,"height":2125,"rx":2145,"fill":47,"stroke":93,"style":2146},"94","272","5","stroke-dasharray:5 4",[49,2148,2150],{"x":2137,"y":2149,"style":98},"114","no highlands.xlsx in the folder",[49,2152,2154],{"x":2137,"y":2153,"style":68},"148","read as \"the job failed\"",[49,2156,2158],{"x":2137,"y":2157,"style":98},"174","chased on Monday, explained on Thursday",[42,2160],{"x":2161,"y":2131,"width":2132,"height":2133,"rx":2134,"fill":62,"stroke":63,"style":82},"380",[49,2163,2166],{"x":2164,"y":2138,"style":2165},"552","font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","reindexed against the expected list",[42,2168],{"x":2169,"y":2143,"width":2144,"height":2125,"rx":2145,"fill":47,"stroke":63},"416",[49,2171,2172],{"x":2164,"y":2149,"style":68},"highlands.xlsx — \"no activity this period\"",[49,2174,2175],{"x":2164,"y":2153,"style":68},"read as \"the answer is zero\"",[49,2177,2178],{"x":2164,"y":2157,"style":98},"no chase, and the manifest still balances",[10,2180,2181],{},"Whichever way you decide, decide once and write it in the code. The failure this prevents is a regional manager assuming their report was forgotten, chasing it, and discovering three days later that the answer was zero all along.",[168,2183,2185],{"id":2184},"step-5-write-a-manifest-and-fail-loudly-at-the-end","Step 5: Write a manifest and fail loudly at the end",[10,2187,2188],{},"The batch's own record of what it produced is what makes the run auditable and the rerun cheap:",[173,2190,2192],{"className":210,"code":2191,"language":212,"meta":178,"style":178},"import json\n\n\ndef run_batch(groups, out_dir=OUT):\n    written, failed = generate_all(groups, out_dir)\n\n    manifest = {\n        \"generated_at\": datetime.now().isoformat(timespec=\"seconds\"),\n        \"period\": \"2026-07\",\n        \"written\": written,\n        \"failed\": failed,\n        \"totals\": {\"reports\": len(written),\n                   \"rows\": sum(w[\"rows\"] for w in written),\n                   \"amount\": round(sum(w[\"amount\"] for w in written), 2)},\n    }\n    (out_dir \u002F \"manifest.json\").write_text(json.dumps(manifest, indent=2))\n\n    if failed:\n        names = \", \".join(f[\"region\"] for f in failed)\n        raise SystemExit(f\"{len(written)} report(s) written; \"\n                         f\"{len(failed)} failed: {names}\")\n    return manifest\n",[180,2193,2194,2201,2205,2209,2225,2235,2239,2249,2268,2280,2288,2296,2314,2343,2376,2381,2403,2407,2414,2441,2467,2497],{"__ignoreMap":178},[183,2195,2196,2198],{"class":185,"line":186},[183,2197,227],{"class":219},[183,2199,2200],{"class":223}," json\n",[183,2202,2203],{"class":185,"line":233},[183,2204,237],{"emptyLinePlaceholder":236},[183,2206,2207],{"class":185,"line":240},[183,2208,237],{"emptyLinePlaceholder":236},[183,2210,2211,2213,2216,2219,2221,2223],{"class":185,"line":254},[183,2212,747],{"class":219},[183,2214,2215],{"class":750}," run_batch",[183,2217,2218],{"class":223},"(groups, out_dir",[183,2220,265],{"class":219},[183,2222,442],{"class":353},[183,2224,761],{"class":223},[183,2226,2227,2230,2232],{"class":185,"line":259},[183,2228,2229],{"class":223},"    written, failed ",[183,2231,265],{"class":219},[183,2233,2234],{"class":223}," generate_all(groups, out_dir)\n",[183,2236,2237],{"class":185,"line":271},[183,2238,237],{"emptyLinePlaceholder":236},[183,2240,2241,2244,2246],{"class":185,"line":310},[183,2242,2243],{"class":223},"    manifest ",[183,2245,265],{"class":219},[183,2247,2248],{"class":223}," {\n",[183,2250,2251,2254,2257,2260,2262,2265],{"class":185,"line":345},[183,2252,2253],{"class":193},"        \"generated_at\"",[183,2255,2256],{"class":223},": datetime.now().isoformat(",[183,2258,2259],{"class":465},"timespec",[183,2261,265],{"class":219},[183,2263,2264],{"class":193},"\"seconds\"",[183,2266,2267],{"class":223},"),\n",[183,2269,2270,2273,2275,2278],{"class":185,"line":384},[183,2271,2272],{"class":193},"        \"period\"",[183,2274,1728],{"class":223},[183,2276,2277],{"class":193},"\"2026-07\"",[183,2279,406],{"class":223},[183,2281,2282,2285],{"class":185,"line":409},[183,2283,2284],{"class":193},"        \"written\"",[183,2286,2287],{"class":223},": written,\n",[183,2289,2290,2293],{"class":185,"line":428},[183,2291,2292],{"class":193},"        \"failed\"",[183,2294,2295],{"class":223},": failed,\n",[183,2297,2298,2301,2304,2307,2309,2311],{"class":185,"line":434},[183,2299,2300],{"class":193},"        \"totals\"",[183,2302,2303],{"class":223},": {",[183,2305,2306],{"class":193},"\"reports\"",[183,2308,1728],{"class":223},[183,2310,1502],{"class":353},[183,2312,2313],{"class":223},"(written),\n",[183,2315,2316,2319,2321,2324,2327,2330,2332,2335,2338,2340],{"class":185,"line":439},[183,2317,2318],{"class":193},"                   \"rows\"",[183,2320,1728],{"class":223},[183,2322,2323],{"class":353},"sum",[183,2325,2326],{"class":223},"(w[",[183,2328,2329],{"class":193},"\"rows\"",[183,2331,909],{"class":223},[183,2333,2334],{"class":219},"for",[183,2336,2337],{"class":223}," w ",[183,2339,681],{"class":219},[183,2341,2342],{"class":223}," written),\n",[183,2344,2345,2348,2350,2352,2354,2356,2358,2360,2362,2364,2366,2368,2371,2373],{"class":185,"line":457},[183,2346,2347],{"class":193},"                   \"amount\"",[183,2349,1728],{"class":223},[183,2351,1757],{"class":353},[183,2353,501],{"class":223},[183,2355,2323],{"class":353},[183,2357,2326],{"class":223},[183,2359,1333],{"class":193},[183,2361,909],{"class":223},[183,2363,2334],{"class":219},[183,2365,2337],{"class":223},[183,2367,681],{"class":219},[183,2369,2370],{"class":223}," written), ",[183,2372,1426],{"class":353},[183,2374,2375],{"class":223},")},\n",[183,2377,2378],{"class":185,"line":485},[183,2379,2380],{"class":223},"    }\n",[183,2382,2383,2386,2388,2391,2394,2397,2399,2401],{"class":185,"line":490},[183,2384,2385],{"class":223},"    (out_dir ",[183,2387,1072],{"class":219},[183,2389,2390],{"class":193}," \"manifest.json\"",[183,2392,2393],{"class":223},").write_text(json.dumps(manifest, ",[183,2395,2396],{"class":465},"indent",[183,2398,265],{"class":219},[183,2400,1426],{"class":353},[183,2402,1355],{"class":223},[183,2404,2405],{"class":185,"line":516},[183,2406,237],{"emptyLinePlaceholder":236},[183,2408,2409,2411],{"class":185,"line":530},[183,2410,895],{"class":219},[183,2412,2413],{"class":223}," failed:\n",[183,2415,2416,2419,2421,2424,2427,2429,2431,2433,2436,2438],{"class":185,"line":941},[183,2417,2418],{"class":223},"        names ",[183,2420,265],{"class":219},[183,2422,2423],{"class":193}," \", \"",[183,2425,2426],{"class":223},".join(f[",[183,2428,510],{"class":193},[183,2430,909],{"class":223},[183,2432,2334],{"class":219},[183,2434,2435],{"class":223}," f ",[183,2437,681],{"class":219},[183,2439,2440],{"class":223}," failed)\n",[183,2442,2443,2446,2449,2451,2453,2455,2457,2459,2462,2464],{"class":185,"line":964},[183,2444,2445],{"class":219},"        raise",[183,2447,2448],{"class":353}," SystemExit",[183,2450,501],{"class":223},[183,2452,657],{"class":219},[183,2454,672],{"class":193},[183,2456,663],{"class":593},[183,2458,1502],{"class":353},[183,2460,2461],{"class":223},"(written)",[183,2463,669],{"class":593},[183,2465,2466],{"class":193}," report(s) written; \"\n",[183,2468,2469,2472,2474,2476,2478,2481,2483,2486,2488,2491,2493,2495],{"class":185,"line":969},[183,2470,2471],{"class":219},"                         f",[183,2473,672],{"class":193},[183,2475,663],{"class":593},[183,2477,1502],{"class":353},[183,2479,2480],{"class":223},"(failed)",[183,2482,669],{"class":593},[183,2484,2485],{"class":193}," failed: ",[183,2487,663],{"class":593},[183,2489,2490],{"class":223},"names",[183,2492,669],{"class":593},[183,2494,672],{"class":193},[183,2496,454],{"class":223},[183,2498,2499,2501],{"class":185,"line":974},[183,2500,944],{"class":219},[183,2502,2503],{"class":223}," manifest\n",[10,2505,2506,2507,2510,2511,2515,2516,2520],{},"Raising ",[180,2508,2509],{},"SystemExit"," after everything else has been written is the shape that serves both audiences: the eleven regions get their reports, and the scheduler still sees a non-zero exit code so the failure is ",[17,2512,2514],{"href":2513},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002F","alerted"," rather than lost in a log. The manifest also gives the ",[17,2517,2519],{"href":2518},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002F","delivery step"," an exact list of files to send, instead of globbing a directory that may still contain last month's output.",[23,2522,32,2527,32,2530,32,2533,32,2536,32,2541,32,2547,32,2550,32,2554,32,2559,32,2562,32,2567,32,2572,32,2575,32,2578,32,2583,32,2586,32,2591,32,2596,32,2599,32,2602,32,2606,32,2609,32,2615,32,2620,32,2625],{"viewBox":2523,"role":26,"ariaLabelledBy":2524,"xmlns":30,"style":31},"0 0 740 244",[2525,2526],"pr-name-t","pr-name-d",[34,2528,2529],{"id":2525},"Turning a group name into a safe, unique filename",[38,2531,2532],{"id":2526},"The raw name passes through four steps: accents are stripped to ASCII, characters the filesystem rejects are replaced, whitespace and ampersands collapse to hyphens, and the result is capped in length. A final collision check appends a counter, because two different region names can reduce to the same slug and would otherwise overwrite each other.",[42,2534],{"x":44,"y":44,"width":45,"height":2535,"fill":47},"244",[42,2537],{"x":2130,"y":2538,"width":2539,"height":2540,"rx":158,"fill":92,"stroke":93,"style":82},"40","150","56",[49,2542,2546],{"x":2543,"y":2544,"style":2545},"91","64","font-size:11px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","raw name",[49,2548,304],{"x":2543,"y":2549,"style":98},"84",[42,2551],{"x":2552,"y":2538,"width":2539,"height":2540,"rx":158,"fill":2553,"stroke":160,"style":82},"196","#ebebfd",[49,2555,2558],{"x":2556,"y":2544,"style":2557},"271","font-size:11px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","ASCII + strip illegal",[49,2560,2561],{"x":2556,"y":2549,"style":98},"no \u002F \\ : * ? \" \u003C > |",[42,2563],{"x":2564,"y":2538,"width":2539,"height":2540,"rx":158,"fill":2565,"stroke":2566,"style":82},"376","#fdefd8","var(--gold,#b4740a)",[49,2568,2571],{"x":2569,"y":2544,"style":2570},"451","font-size:11px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","collapse + cap",[49,2573,2574],{"x":2569,"y":2549,"style":98},"\"nordics-baltics\"",[42,2576],{"x":115,"y":2538,"width":2577,"height":2540,"rx":158,"fill":62,"stroke":63,"style":82},"168",[49,2579,2582],{"x":2580,"y":2544,"style":2581},"640","font-size:11px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","collision check",[49,2584,2585],{"x":2580,"y":2549,"style":98},"append -2 if taken",[185,2587],{"x1":2588,"y1":2589,"x2":85,"y2":2589,"stroke":2590,"style":82},"170","68","var(--muted,#5b6780)",[2592,2593],"polygon",{"points":2594,"fill":2595},"194,68 184,63 184,73","#5b6780",[185,2597],{"x1":2598,"y1":2589,"x2":163,"y2":2589,"stroke":2590,"style":82},"350",[2592,2600],{"points":2601,"fill":2595},"374,68 364,63 364,73",[185,2603],{"x1":2604,"y1":2589,"x2":2605,"y2":2589,"stroke":2590,"style":82},"530","550",[2592,2607],{"points":2608,"fill":2595},"554,68 544,63 544,73",[42,2610],{"x":2130,"y":2611,"width":2612,"height":2613,"rx":2614,"fill":80,"stroke":81,"style":82},"126","708","98","12",[49,2616,2619],{"x":2538,"y":2617,"style":2618},"152","font-size:12px;font-weight:700;fill:var(--accent-ink,#be185d)","Why the last step is not optional",[49,2621,2624],{"x":2538,"y":2622,"style":2623},"178","font-size:11.5px;fill:var(--text,#172033)","\"East\u002FWest\" and \"East-West\" both reduce to east-west; without the check, the second write overwrites the first,",[49,2626,2628],{"x":2538,"y":2627,"style":2623},"200","and one region receives another's figures — a failure nothing detects, because both files exist and both open.",[168,2630,2632],{"id":2631},"step-6-parallelise-only-if-it-pays","Step 6: Parallelise only if it pays",[10,2634,2635],{},"Writing a workbook is CPU-bound in the Excel writer, so threads help little and processes help a lot:",[173,2637,2639],{"className":210,"code":2638,"language":212,"meta":178,"style":178},"from concurrent.futures import ProcessPoolExecutor, as_completed\n\n\ndef generate_parallel(groups, out_dir, workers=4):\n    written, failed, used = [], [], set()\n    plan = {region: unique_path(out_dir, slugify(region), used=used)\n            for region in sorted(groups)}                  # paths assigned up front\n\n    with ProcessPoolExecutor(max_workers=workers) as pool:\n        futures = {pool.submit(build_workbook, frame, region, plan[region]): region\n                   for region, frame in groups.items()}\n        for future in as_completed(futures):\n            region = futures[future]\n            try:\n                written.append({\"region\": region, \"path\": str(future.result())})\n            except Exception as exc:\n                failed.append({\"region\": region, \"error\": str(exc)})\n    return written, failed\n",[180,2640,2641,2653,2657,2661,2678,2690,2706,2724,2728,2748,2758,2770,2782,2792,2799,2817,2829,2847],{"__ignoreMap":178},[183,2642,2643,2645,2648,2650],{"class":185,"line":186},[183,2644,220],{"class":219},[183,2646,2647],{"class":223}," concurrent.futures ",[183,2649,227],{"class":219},[183,2651,2652],{"class":223}," ProcessPoolExecutor, as_completed\n",[183,2654,2655],{"class":185,"line":233},[183,2656,237],{"emptyLinePlaceholder":236},[183,2658,2659],{"class":185,"line":240},[183,2660,237],{"emptyLinePlaceholder":236},[183,2662,2663,2665,2668,2671,2673,2676],{"class":185,"line":254},[183,2664,747],{"class":219},[183,2666,2667],{"class":750}," generate_parallel",[183,2669,2670],{"class":223},"(groups, out_dir, workers",[183,2672,265],{"class":219},[183,2674,2675],{"class":353},"4",[183,2677,761],{"class":223},[183,2679,2680,2682,2684,2686,2688],{"class":185,"line":259},[183,2681,1646],{"class":223},[183,2683,265],{"class":219},[183,2685,1651],{"class":223},[183,2687,1654],{"class":353},[183,2689,1037],{"class":223},[183,2691,2692,2695,2697,2700,2702,2704],{"class":185,"line":271},[183,2693,2694],{"class":223},"    plan ",[183,2696,265],{"class":219},[183,2698,2699],{"class":223}," {region: unique_path(out_dir, slugify(region), ",[183,2701,1692],{"class":465},[183,2703,265],{"class":219},[183,2705,1697],{"class":223},[183,2707,2708,2711,2714,2716,2718,2721],{"class":185,"line":310},[183,2709,2710],{"class":219},"            for",[183,2712,2713],{"class":223}," region ",[183,2715,681],{"class":219},[183,2717,1675],{"class":353},[183,2719,2720],{"class":223},"(groups)}                  ",[183,2722,2723],{"class":533},"# paths assigned up front\n",[183,2725,2726],{"class":185,"line":345},[183,2727,237],{"emptyLinePlaceholder":236},[183,2729,2730,2732,2735,2738,2740,2743,2745],{"class":185,"line":384},[183,2731,1364],{"class":219},[183,2733,2734],{"class":223}," ProcessPoolExecutor(",[183,2736,2737],{"class":465},"max_workers",[183,2739,265],{"class":219},[183,2741,2742],{"class":223},"workers) ",[183,2744,248],{"class":219},[183,2746,2747],{"class":223}," pool:\n",[183,2749,2750,2753,2755],{"class":185,"line":409},[183,2751,2752],{"class":223},"        futures ",[183,2754,265],{"class":219},[183,2756,2757],{"class":223}," {pool.submit(build_workbook, frame, region, plan[region]): region\n",[183,2759,2760,2763,2765,2767],{"class":185,"line":428},[183,2761,2762],{"class":219},"                   for",[183,2764,1670],{"class":223},[183,2766,681],{"class":219},[183,2768,2769],{"class":223}," groups.items()}\n",[183,2771,2772,2774,2777,2779],{"class":185,"line":434},[183,2773,1574],{"class":219},[183,2775,2776],{"class":223}," future ",[183,2778,681],{"class":219},[183,2780,2781],{"class":223}," as_completed(futures):\n",[183,2783,2784,2787,2789],{"class":185,"line":439},[183,2785,2786],{"class":223},"            region ",[183,2788,265],{"class":219},[183,2790,2791],{"class":223}," futures[future]\n",[183,2793,2794,2797],{"class":185,"line":457},[183,2795,2796],{"class":219},"            try",[183,2798,917],{"class":223},[183,2800,2801,2804,2806,2808,2810,2812,2814],{"class":185,"line":485},[183,2802,2803],{"class":223},"                written.append({",[183,2805,510],{"class":193},[183,2807,1722],{"class":223},[183,2809,1725],{"class":193},[183,2811,1728],{"class":223},[183,2813,784],{"class":353},[183,2815,2816],{"class":223},"(future.result())})\n",[183,2818,2819,2822,2824,2826],{"class":185,"line":490},[183,2820,2821],{"class":219},"            except",[183,2823,1812],{"class":353},[183,2825,1815],{"class":219},[183,2827,2828],{"class":223}," exc:\n",[183,2830,2831,2834,2836,2838,2840,2842,2844],{"class":185,"line":516},[183,2832,2833],{"class":223},"                failed.append({",[183,2835,510],{"class":193},[183,2837,1722],{"class":223},[183,2839,1834],{"class":193},[183,2841,1728],{"class":223},[183,2843,784],{"class":353},[183,2845,2846],{"class":223},"(exc)})\n",[183,2848,2849,2851],{"class":185,"line":530},[183,2850,944],{"class":219},[183,2852,1899],{"class":223},[10,2854,2855,2856,2858,2859,2863],{},"Assigning every path in the parent process before submitting anything is the necessary part: workers cannot see each other's ",[180,2857,1692],{}," set, so two of them could otherwise pick the same filename. Keep the pool at four to eight — the writers are memory-hungry, and a pool the size of your core count on a large dataset will hit swap before it hits a speed-up. ",[17,2860,2862],{"href":2861},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-large-excel-files-in-python\u002Fprocess-multiple-excel-files-in-parallel-with-python\u002F","Process Multiple Excel Files in Parallel with Python"," covers the trade-offs in more depth.",[168,2865,2867],{"id":2866},"common-pitfalls-and-gotchas","Common pitfalls and gotchas",[2869,2870,2871,2887],"table",{},[2872,2873,2874],"thead",{},[2875,2876,2877,2881,2884],"tr",{},[2878,2879,2880],"th",{},"Symptom",[2878,2882,2883],{},"Cause",[2878,2885,2886],{},"Fix",[2888,2889,2890,2902,2919,2932,2946,2959,2970,2983],"tbody",{},[2875,2891,2892,2896,2899],{},[2893,2894,2895],"td",{},"A report contains another region's data",[2893,2897,2898],{},"Two names slugified identically",[2893,2900,2901],{},"Check for collisions before writing",[2875,2903,2904,2910,2916],{},[2893,2905,2906,2909],{},[180,2907,2908],{},"FileNotFoundError"," on a valid name",[2893,2911,2912,2913,2915],{},"A ",[180,2914,1072],{}," in the group name made it a path",[2893,2917,2918],{},"Strip path separators in the slug",[2875,2920,2921,2924,2929],{},[2893,2922,2923],{},"The batch stops at the third region",[2893,2925,2926,2927],{},"No per-group ",[180,2928,1195],{},[2893,2930,2931],{},"Catch inside the loop, fail at the end",[2875,2933,2934,2937,2943],{},[2893,2935,2936],{},"A region's file is missing entirely",[2893,2938,2939,2940,2942],{},"The group was empty, so ",[180,2941,1930],{}," skipped it",[2893,2944,2945],{},"Reindex against the expected list",[2875,2947,2948,2951,2954],{},[2893,2949,2950],{},"Half-written file gets emailed",[2893,2952,2953],{},"Written in place",[2893,2955,2956,2957],{},"Temp file plus ",[180,2958,1905],{},[2875,2960,2961,2964,2967],{},[2893,2962,2963],{},"Last month's files sent again",[2893,2965,2966],{},"Delivery globbed the directory",[2893,2968,2969],{},"Send from the manifest",[2875,2971,2972,2975,2978],{},[2893,2973,2974],{},"Scheduler reports success despite failures",[2893,2976,2977],{},"Errors only logged",[2893,2979,2980,2982],{},[180,2981,2509],{}," non-zero at the end",[2875,2984,2985,2988,2991],{},[2893,2986,2987],{},"Machine swaps during a parallel run",[2893,2989,2990],{},"Pool too large for memory",[2893,2992,2993],{},"Four to eight workers",[168,2995,2997],{"id":2996},"performance-and-scale-notes","Performance and scale notes",[10,2999,3000],{},"The cost is per workbook, not per row, so 200 small reports take noticeably longer than one report with 200 times the rows. Build the shared parts once — a template loaded once, formats created once, the source read once — and keep only the per-group work inside the loop.",[10,3002,3003],{},"Past a few hundred outputs, the delivery becomes the real problem rather than the generation: two hundred emails with attachments will trip most SMTP rate limits. Publishing to a shared folder and sending one message with a link scales where attachments do not, and it also means a correction replaces a file instead of chasing an email.",[168,3005,3007],{"id":3006},"conclusion","Conclusion",[10,3009,3010,3011,3013],{},"The loop is easy; the batch is not. Slugify every group name and check for collisions so no region can receive another's numbers, wrap each group in its own ",[180,3012,1195],{}," so one failure costs one report, write each workbook atomically, decide deliberately what an empty group produces, and finish by writing a manifest and exiting non-zero if anything failed. That turns a per-region loop into something a scheduler can run unattended and someone can audit afterwards.",[168,3015,3017],{"id":3016},"frequently-asked-questions","Frequently asked questions",[10,3019,3020,3024],{},[3021,3022,3023],"strong",{},"Should one failing region stop the whole batch?","\nNo. Catch per group, record the failure, and carry on — then fail the job at the end with a summary. Eleven delivered reports and one named failure is a far better outcome than nothing at all.",[10,3026,3027,3030],{},[3021,3028,3029],{},"How do I stop a group name breaking the filename?","\nSlugify it — strip path separators and characters the filesystem rejects, collapse whitespace, and cap the length. Then check for collisions, because two different names can slugify to the same string.",[10,3032,3033,3036],{},[3021,3034,3035],{},"What should happen when a group has no rows?","\nDecide explicitly and write it down. Usually: produce the workbook with a visible \"no activity this period\" note, so the absence is a statement rather than a missing file nobody chases.",[10,3038,3039,3042],{},[3021,3040,3041],{},"Is it faster to generate the reports in parallel?","\nOften, yes — the work is CPU-bound in the Excel writer, so a process pool helps where threads would not. Keep the pool small and make sure each worker writes to its own file.",[168,3044,3046],{"id":3045},"related","Related",[10,3048,3049],{},"Up to the parent guide:",[3051,3052,3053],"ul",{},[3054,3055,3056,3058],"li",{},[17,3057,20],{"href":19}," — the template each of these workbooks can be built from.",[10,3060,3061],{},"Related guides:",[3051,3063,3064,3071,3078,3083],{},[3054,3065,3066,3070],{},[17,3067,3069],{"href":3068},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Ffill-excel-template-with-python-openpyxl\u002F","Fill an Excel Template with Python and openpyxl"," — the per-group build step in template form.",[3054,3072,3073,3077],{},[17,3074,3076],{"href":3075},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-excel-report-to-multiple-recipients-python\u002F","Send an Excel Report to Multiple Recipients in Python"," — delivering what the manifest lists.",[3054,3079,3080,3082],{},[17,3081,2862],{"href":2861}," — the pool sizing behind step 6.",[3054,3084,3085,3089],{},[17,3086,3088],{"href":3087},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fretry-a-failed-excel-report-job-in-python\u002F","Retry a Failed Excel Report Job in Python"," — retrying the one region that failed rather than the batch.",[3091,3092,3093],"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-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .s_b0D, html code.shiki .s_b0D{--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#72F088;--shiki-dark-font-weight:bold}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}",{"title":178,"searchDepth":233,"depth":233,"links":3095},[3096,3097,3098,3099,3100,3101,3102,3103,3104,3105,3106,3107],{"id":170,"depth":233,"text":171},{"id":206,"depth":233,"text":207},{"id":547,"depth":233,"text":548},{"id":1188,"depth":233,"text":1189},{"id":1923,"depth":233,"text":1924},{"id":2184,"depth":233,"text":2185},{"id":2631,"depth":233,"text":2632},{"id":2866,"depth":233,"text":2867},{"id":2996,"depth":233,"text":2997},{"id":3006,"depth":233,"text":3007},{"id":3016,"depth":233,"text":3017},{"id":3045,"depth":233,"text":3046},"2026-08-11","Split one dataset into many workbooks safely: a filename that cannot collide or break a filesystem, one failure that does not stop the batch, atomic writes, an empty-group decision, and a manifest of what was produced.","md",[3112,3114,3116,3120],{"q":3023,"a":3113},"No. Catch per group, record the failure, and carry on — then fail the job at the end with a summary. Eleven delivered reports and one named failure is a far better outcome than nothing at all.",{"q":3029,"a":3115},"Slugify it — strip path separators and characters the filesystem rejects, collapse whitespace, and cap the length. Then check for collisions, because two different names can slugify to the same string.",{"q":3035,"a":3117},{"Decide explicitly and write it down":3118},{" Usually":3119},"produce the workbook with a visible \"no activity this period\" note, so the absence is a statement rather than a missing file nobody chases.",{"q":3041,"a":3121},"Often, yes — the work is CPU-bound in the Excel writer, so a process pool helps where threads would not. Keep the pool small and make sure each worker writes to its own file.",{"breadcrumb":3123},[3124,3126,3129,3130],{"name":3125,"item":1072},"Home",{"name":3127,"item":3128},"Automating Reporting Workflows","\u002Fautomating-reporting-workflows\u002F",{"name":20,"item":19},{"name":5,"item":3131},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-excel-report-per-region-in-a-loop\u002F","\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-excel-report-per-region-in-a-loop",{"title":3134,"description":3135},"Generate One Excel Report per Region in Python","Loop a report over groups and write one workbook each — safe filenames, per-group error isolation, atomic saves, handling empty groups, parallel generation and a run manifest.","generate-one-excel-report-per-region-in-a-loop","automating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-one-excel-report-per-region-in-a-loop\u002Findex","how-to","OmS5wLLA_uJGEwqlTuyiT3Wr3QiCZZ-ii4TYP8SVInI",[3141,3144],{"title":3069,"path":3142,"stem":3143,"children":-1},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Ffill-excel-template-with-python-openpyxl","automating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Ffill-excel-template-with-python-openpyxl\u002Findex",{"title":3145,"path":3146,"stem":3147,"children":-1},"Populate an Excel Template Without Losing Formatting","\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fpopulate-excel-template-without-losing-formatting","automating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fpopulate-excel-template-without-losing-formatting\u002Findex",1786800027382]