[{"data":1,"prerenderedAt":1895},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun":3,"surround:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun":1860,"ancestors:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun":1869,"children:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun":1874,"related:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun":1875},{"id":4,"title":5,"body":6,"dateModified":1827,"datePublished":1827,"description":1828,"extension":1829,"faq":1830,"keyword":1839,"kind":1840,"meta":1841,"navigation":104,"path":1850,"seo":1851,"slug":1854,"stem":1855,"topics":1856,"type":1858,"__hash__":1859},"docs\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun\u002Findex.md","Make an Excel Report Job Safe to Rerun",{"type":7,"value":8,"toc":1815},"minimark",[9,24,29,58,61,65,68,313,320,324,327,671,686,797,801,804,964,983,987,990,1176,1187,1301,1305,1308,1611,1623,1703,1707,1745,1749,1752,1756,1762,1768,1774,1780,1784,1811],[10,11,12,13,17,18,23],"p",{},"Every scheduled report job gets rerun eventually. The source data was late and the numbers need refreshing; a bug was fixed and last month needs regenerating; the job crashed after writing the file but before emailing it; someone ran it by hand not knowing the scheduler already had. A job that is ",[14,15,16],"em",{},"safe to rerun"," — idempotent, in the jargon — produces the same outcome however many times it runs for the same period: one file with the right numbers, one set of database rows, one email to each recipient. Most jobs are not like that by default. They name files after the time they ran, append to tables, and send email unconditionally, so a rerun leaves two files, doubled totals and a confused inbox. This page builds a small report job and makes each of its steps safe to repeat, then crashes it half-way and shows a rerun finishing the work without repeating any of it. It belongs to ",[19,20,22],"a",{"href":21},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002F","Error Handling and Logging in Excel Automation",".",[25,26,28],"h2",{"id":27},"prerequisites","Prerequisites",[30,31,36],"pre",{"className":32,"code":33,"language":34,"meta":35,"style":35},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl\n","bash","",[37,38,39],"code",{"__ignoreMap":35},[40,41,44,48,52,55],"span",{"class":42,"line":43},"line",1,[40,45,47],{"class":46},"sMTad","pip",[40,49,51],{"class":50},"srMev"," install",[40,53,54],{"class":50}," pandas",[40,56,57],{"class":50}," openpyxl\n",[10,59,60],{},"SQLite and the rest are in the standard library. The job reads a sales workbook, writes a monthly summary workbook, loads the summary rows into a reporting table, and \"sends\" the report — recorded rather than emailed, to keep the example self-contained.",[25,62,64],{"id":63},"step-1-take-the-period-as-a-parameter-never-now","Step 1 — Take the period as a parameter, never \"now\"",[10,66,67],{},"The first source of non-repeatable runs is the clock. A job that reports on \"last month\" by computing it from today's date produces a different report when rerun on the 2nd of next month than on the 1st. Make the period an explicit argument, and default it from the clock only at the very edge:",[30,69,73],{"className":70,"code":71,"language":72,"meta":35,"style":35},"language-python shiki shiki-themes github-light github-dark-high-contrast","import argparse\nfrom datetime import date\n\ndef last_month(today):\n    first = today.replace(day=1)\n    prev = date.fromordinal(first.toordinal() - 1)\n    return f\"{prev:%Y-%m}\"\n\nparser = argparse.ArgumentParser()\nparser.add_argument(\"--period\", help=\"YYYY-MM to report on; defaults to last month\")\nargs = parser.parse_args([\"--period\", \"2026-09\"])\nperiod = args.period or last_month(date.today())\nprint(\"reporting period:\", period, \"| default if omitted on 2026-10-02:\", last_month(date(2026, 10, 2)))\n","python",[37,74,75,85,99,106,119,144,163,200,205,216,238,259,276],{"__ignoreMap":35},[40,76,77,81],{"class":42,"line":43},[40,78,80],{"class":79},"s-kum","import",[40,82,84],{"class":83},"skGVy"," argparse\n",[40,86,88,91,94,96],{"class":42,"line":87},2,[40,89,90],{"class":79},"from",[40,92,93],{"class":83}," datetime ",[40,95,80],{"class":79},[40,97,98],{"class":83}," date\n",[40,100,102],{"class":42,"line":101},3,[40,103,105],{"emptyLinePlaceholder":104},true,"\n",[40,107,109,112,116],{"class":42,"line":108},4,[40,110,111],{"class":79},"def",[40,113,115],{"class":114},"s_Opv"," last_month",[40,117,118],{"class":83},"(today):\n",[40,120,122,125,128,131,135,137,141],{"class":42,"line":121},5,[40,123,124],{"class":83},"    first ",[40,126,127],{"class":79},"=",[40,129,130],{"class":83}," today.replace(",[40,132,134],{"class":133},"sa561","day",[40,136,127],{"class":79},[40,138,140],{"class":139},"sP0c6","1",[40,142,143],{"class":83},")\n",[40,145,147,150,152,155,158,161],{"class":42,"line":146},6,[40,148,149],{"class":83},"    prev ",[40,151,127],{"class":79},[40,153,154],{"class":83}," date.fromordinal(first.toordinal() ",[40,156,157],{"class":79},"-",[40,159,160],{"class":139}," 1",[40,162,143],{"class":83},[40,164,166,169,172,175,179,182,185,188,191,194,197],{"class":42,"line":165},7,[40,167,168],{"class":79},"    return",[40,170,171],{"class":79}," f",[40,173,174],{"class":50},"\"",[40,176,178],{"class":177},"sSjpA","{",[40,180,181],{"class":83},"prev:",[40,183,184],{"class":79},"%",[40,186,187],{"class":83},"Y",[40,189,190],{"class":79},"-%",[40,192,193],{"class":83},"m",[40,195,196],{"class":177},"}",[40,198,199],{"class":50},"\"\n",[40,201,203],{"class":42,"line":202},8,[40,204,105],{"emptyLinePlaceholder":104},[40,206,208,211,213],{"class":42,"line":207},9,[40,209,210],{"class":83},"parser ",[40,212,127],{"class":79},[40,214,215],{"class":83}," argparse.ArgumentParser()\n",[40,217,219,222,225,228,231,233,236],{"class":42,"line":218},10,[40,220,221],{"class":83},"parser.add_argument(",[40,223,224],{"class":50},"\"--period\"",[40,226,227],{"class":83},", ",[40,229,230],{"class":133},"help",[40,232,127],{"class":79},[40,234,235],{"class":50},"\"YYYY-MM to report on; defaults to last month\"",[40,237,143],{"class":83},[40,239,241,244,246,249,251,253,256],{"class":42,"line":240},11,[40,242,243],{"class":83},"args ",[40,245,127],{"class":79},[40,247,248],{"class":83}," parser.parse_args([",[40,250,224],{"class":50},[40,252,227],{"class":83},[40,254,255],{"class":50},"\"2026-09\"",[40,257,258],{"class":83},"])\n",[40,260,262,265,267,270,273],{"class":42,"line":261},12,[40,263,264],{"class":83},"period ",[40,266,127],{"class":79},[40,268,269],{"class":83}," args.period ",[40,271,272],{"class":79},"or",[40,274,275],{"class":83}," last_month(date.today())\n",[40,277,279,282,285,288,291,294,297,300,302,305,307,310],{"class":42,"line":278},13,[40,280,281],{"class":139},"print",[40,283,284],{"class":83},"(",[40,286,287],{"class":50},"\"reporting period:\"",[40,289,290],{"class":83},", period, ",[40,292,293],{"class":50},"\"| default if omitted on 2026-10-02:\"",[40,295,296],{"class":83},", last_month(date(",[40,298,299],{"class":139},"2026",[40,301,227],{"class":83},[40,303,304],{"class":139},"10",[40,306,227],{"class":83},[40,308,309],{"class":139},"2",[40,311,312],{"class":83},")))\n",[10,314,315,316,319],{},"Everything downstream takes ",[37,317,318],{},"period"," and nothing else reads the date. Rerunning September in November, or in a test, then gives exactly September — the same rows, the same file name, the same keys. Filtering the input by period rather than \"the last 30 days\" matters for the same reason.",[25,321,323],{"id":322},"step-2-deterministic-names-and-atomic-writes","Step 2 — Deterministic names and atomic writes",[10,325,326],{},"Name output files after the period, not the run time, and write them so a crash can never leave half a file under the final name:",[30,328,330],{"className":70,"code":329,"language":72,"meta":35,"style":35},"import os\nimport tempfile\nfrom pathlib import Path\nimport pandas as pd\n\nOUT = Path(\"out\")\nOUT.mkdir(exist_ok=True)\n\ndef build_summary(period):\n    sales = pd.read_excel(\"sales.xlsx\")\n    month = sales[sales[\"date\"].dt.strftime(\"%Y-%m\") == period]\n    return month.groupby(\"region\", as_index=False)[\"amount\"].sum().assign(period=period)\n\ndef write_atomically(df, target):\n    fd, tmp = tempfile.mkstemp(dir=target.parent, suffix=\".xlsx\")\n    os.close(fd)\n    try:\n        df.to_excel(tmp, index=False)\n        os.replace(tmp, target)\n    except BaseException:\n        os.unlink(tmp)\n        raise\n\nsummary = build_summary(period)\nreport = OUT \u002F f\"sales_summary_{period}.xlsx\"\nwrite_atomically(summary, report)\nprint(summary.to_string(index=False))\n",[37,331,332,339,346,358,371,375,391,408,412,422,437,465,501,505,516,545,551,560,575,581,592,598,604,609,620,648,654],{"__ignoreMap":35},[40,333,334,336],{"class":42,"line":43},[40,335,80],{"class":79},[40,337,338],{"class":83}," os\n",[40,340,341,343],{"class":42,"line":87},[40,342,80],{"class":79},[40,344,345],{"class":83}," tempfile\n",[40,347,348,350,353,355],{"class":42,"line":101},[40,349,90],{"class":79},[40,351,352],{"class":83}," pathlib ",[40,354,80],{"class":79},[40,356,357],{"class":83}," Path\n",[40,359,360,362,365,368],{"class":42,"line":108},[40,361,80],{"class":79},[40,363,364],{"class":83}," pandas ",[40,366,367],{"class":79},"as",[40,369,370],{"class":83}," pd\n",[40,372,373],{"class":42,"line":121},[40,374,105],{"emptyLinePlaceholder":104},[40,376,377,380,383,386,389],{"class":42,"line":146},[40,378,379],{"class":139},"OUT",[40,381,382],{"class":79}," =",[40,384,385],{"class":83}," Path(",[40,387,388],{"class":50},"\"out\"",[40,390,143],{"class":83},[40,392,393,395,398,401,403,406],{"class":42,"line":165},[40,394,379],{"class":139},[40,396,397],{"class":83},".mkdir(",[40,399,400],{"class":133},"exist_ok",[40,402,127],{"class":79},[40,404,405],{"class":139},"True",[40,407,143],{"class":83},[40,409,410],{"class":42,"line":202},[40,411,105],{"emptyLinePlaceholder":104},[40,413,414,416,419],{"class":42,"line":207},[40,415,111],{"class":79},[40,417,418],{"class":114}," build_summary",[40,420,421],{"class":83},"(period):\n",[40,423,424,427,429,432,435],{"class":42,"line":218},[40,425,426],{"class":83},"    sales ",[40,428,127],{"class":79},[40,430,431],{"class":83}," pd.read_excel(",[40,433,434],{"class":50},"\"sales.xlsx\"",[40,436,143],{"class":83},[40,438,439,442,444,447,450,453,456,459,462],{"class":42,"line":240},[40,440,441],{"class":83},"    month ",[40,443,127],{"class":79},[40,445,446],{"class":83}," sales[sales[",[40,448,449],{"class":50},"\"date\"",[40,451,452],{"class":83},"].dt.strftime(",[40,454,455],{"class":50},"\"%Y-%m\"",[40,457,458],{"class":83},") ",[40,460,461],{"class":79},"==",[40,463,464],{"class":83}," period]\n",[40,466,467,469,472,475,477,480,482,485,488,491,494,496,498],{"class":42,"line":261},[40,468,168],{"class":79},[40,470,471],{"class":83}," month.groupby(",[40,473,474],{"class":50},"\"region\"",[40,476,227],{"class":83},[40,478,479],{"class":133},"as_index",[40,481,127],{"class":79},[40,483,484],{"class":139},"False",[40,486,487],{"class":83},")[",[40,489,490],{"class":50},"\"amount\"",[40,492,493],{"class":83},"].sum().assign(",[40,495,318],{"class":133},[40,497,127],{"class":79},[40,499,500],{"class":83},"period)\n",[40,502,503],{"class":42,"line":278},[40,504,105],{"emptyLinePlaceholder":104},[40,506,508,510,513],{"class":42,"line":507},14,[40,509,111],{"class":79},[40,511,512],{"class":114}," write_atomically",[40,514,515],{"class":83},"(df, target):\n",[40,517,519,522,524,527,530,532,535,538,540,543],{"class":42,"line":518},15,[40,520,521],{"class":83},"    fd, tmp ",[40,523,127],{"class":79},[40,525,526],{"class":83}," tempfile.mkstemp(",[40,528,529],{"class":133},"dir",[40,531,127],{"class":79},[40,533,534],{"class":83},"target.parent, ",[40,536,537],{"class":133},"suffix",[40,539,127],{"class":79},[40,541,542],{"class":50},"\".xlsx\"",[40,544,143],{"class":83},[40,546,548],{"class":42,"line":547},16,[40,549,550],{"class":83},"    os.close(fd)\n",[40,552,554,557],{"class":42,"line":553},17,[40,555,556],{"class":79},"    try",[40,558,559],{"class":83},":\n",[40,561,563,566,569,571,573],{"class":42,"line":562},18,[40,564,565],{"class":83},"        df.to_excel(tmp, ",[40,567,568],{"class":133},"index",[40,570,127],{"class":79},[40,572,484],{"class":139},[40,574,143],{"class":83},[40,576,578],{"class":42,"line":577},19,[40,579,580],{"class":83},"        os.replace(tmp, target)\n",[40,582,584,587,590],{"class":42,"line":583},20,[40,585,586],{"class":79},"    except",[40,588,589],{"class":139}," BaseException",[40,591,559],{"class":83},[40,593,595],{"class":42,"line":594},21,[40,596,597],{"class":83},"        os.unlink(tmp)\n",[40,599,601],{"class":42,"line":600},22,[40,602,603],{"class":79},"        raise\n",[40,605,607],{"class":42,"line":606},23,[40,608,105],{"emptyLinePlaceholder":104},[40,610,612,615,617],{"class":42,"line":611},24,[40,613,614],{"class":83},"summary ",[40,616,127],{"class":79},[40,618,619],{"class":83}," build_summary(period)\n",[40,621,623,626,628,631,634,636,639,641,643,645],{"class":42,"line":622},25,[40,624,625],{"class":83},"report ",[40,627,127],{"class":79},[40,629,630],{"class":139}," OUT",[40,632,633],{"class":79}," \u002F",[40,635,171],{"class":79},[40,637,638],{"class":50},"\"sales_summary_",[40,640,178],{"class":177},[40,642,318],{"class":83},[40,644,196],{"class":177},[40,646,647],{"class":50},".xlsx\"\n",[40,649,651],{"class":42,"line":650},26,[40,652,653],{"class":83},"write_atomically(summary, report)\n",[40,655,657,659,662,664,666,668],{"class":42,"line":656},27,[40,658,281],{"class":139},[40,660,661],{"class":83},"(summary.to_string(",[40,663,568],{"class":133},[40,665,127],{"class":79},[40,667,484],{"class":139},[40,669,670],{"class":83},"))\n",[10,672,673,674,677,678,681,682,23],{},"Writing to a temporary file in the same folder and then calling ",[37,675,676],{},"os.replace"," swaps the finished file in under its final name in one step, so readers see either the previous complete file or the new complete file, never a partial one. A rerun simply replaces the period's file instead of adding ",[37,679,680],{},"sales_summary_2026-10-02T07-15.xlsx"," next to it. The atomic write is covered in detail in ",[19,683,685],{"href":684},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fwrite-excel-files-atomically-in-python\u002F","Write Excel Files Atomically in Python",[687,688,696,697,696,701,696,705,696,713,696,723,696,734,696,741,696,747,696,752,696,756,696,760,696,764,696,769,696,776,696,781,696,785,696,788,696,791,696,794],"svg",{"viewBox":689,"role":690,"ariaLabelledBy":691,"xmlns":694,"style":695},"4 0 752 233","img",[692,693],"rr-steps-t","rr-steps-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  ",[698,699,700],"title",{"id":692},"Each step, unsafe and safe to repeat",[702,703,704],"desc",{"id":693},"Reading the clock, naming files by run time, appending rows and sending unconditionally all change the outcome on a rerun. A period parameter, period-based names with atomic replace, delete-and-insert per period in one transaction and a send ledger keyed by period make every step repeatable.",[706,707],"rect",{"x":708,"y":709,"width":710,"height":711,"fill":712},"4","0","752","233","#ffffff",[706,714],{"x":715,"y":716,"width":717,"height":718,"rx":719,"fill":720,"stroke":721,"style":722},"20.0","16.0","351.0","201.0","14","#fdefd8","var(--gold,#b4740a)","stroke-width:2px",[724,725,727],"g",{"transform":726},"translate(182.5,29.0) scale(1.083)",[728,729],"path",{"d":730,"fill":731,"stroke":732,"style":733},"M6 6l12 12 M18 6L6 18","none","var(--gold-ink,#7a4e06)","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[735,736,740],"text",{"x":737,"y":738,"style":739},"195.5","78.0","font-size:14px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Unsafe on rerun",[42,742],{"x1":743,"y1":744,"x2":745,"y2":744,"stroke":721,"style":746},"36.0","91.0","355.0","stroke-width:1px",[735,748,751],{"x":737,"y":749,"style":750},"111.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","date.today() decides the period",[735,753,755],{"x":737,"y":754,"style":750},"137.0","file named by run time",[735,757,759],{"x":737,"y":758,"style":750},"163.0","append rows to the table",[735,761,763],{"x":737,"y":762,"style":750},"189.0","send every time",[706,765],{"x":766,"y":716,"width":717,"height":718,"rx":719,"fill":767,"stroke":768,"style":722},"389.0","#d9f4f1","var(--teal,#0f9488)",[724,770,772],{"transform":771},"translate(551.5,29.0) scale(1.083)",[728,773],{"d":774,"fill":731,"stroke":775,"style":733},"M5 12.5l4.5 4.5L19 7","var(--teal-ink,#0b6157)",[735,777,780],{"x":778,"y":738,"style":779},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Safe to rerun",[42,782],{"x1":783,"y1":744,"x2":784,"y2":744,"stroke":768,"style":746},"405.0","724.0",[735,786,787],{"x":778,"y":749,"style":750},"period passed in",[735,789,790],{"x":778,"y":754,"style":750},"file named by period, os.replace",[735,792,793],{"x":778,"y":758,"style":750},"delete + insert the period, one transaction",[735,795,796],{"x":778,"y":762,"style":750},"ledger with a unique key",[25,798,800],{"id":799},"step-3-replace-the-periods-rows-in-one-transaction","Step 3 — Replace the period's rows, in one transaction",[10,802,803],{},"Appending the summary to a reporting table doubles it on every rerun. Instead, delete the period's rows and insert the new ones inside a single transaction:",[30,805,807],{"className":70,"code":806,"language":72,"meta":35,"style":35},"import sqlite3\n\ndb = sqlite3.connect(\"state.db\")\ndb.execute(\"CREATE TABLE IF NOT EXISTS sales_summary (period TEXT, region TEXT, amount REAL, PRIMARY KEY (period, region))\")\ndb.execute(\"CREATE TABLE IF NOT EXISTS sends (period TEXT, recipient TEXT, sent_at TEXT, PRIMARY KEY (period, recipient))\")\n\ndef load(db, df, period):\n    with db:\n        db.execute(\"DELETE FROM sales_summary WHERE period = ?\", (period,))\n        db.executemany(\"INSERT INTO sales_summary VALUES (?, ?, ?)\", df[[\"period\", \"region\", \"amount\"]].itertuples(index=False))\n\nfor _ in range(3):\n    load(db, summary, period)\nprint(db.execute(\"SELECT period, region, amount FROM sales_summary\").fetchall())\n",[37,808,809,816,820,835,845,854,858,868,876,887,920,924,946,951],{"__ignoreMap":35},[40,810,811,813],{"class":42,"line":43},[40,812,80],{"class":79},[40,814,815],{"class":83}," sqlite3\n",[40,817,818],{"class":42,"line":87},[40,819,105],{"emptyLinePlaceholder":104},[40,821,822,825,827,830,833],{"class":42,"line":101},[40,823,824],{"class":83},"db ",[40,826,127],{"class":79},[40,828,829],{"class":83}," sqlite3.connect(",[40,831,832],{"class":50},"\"state.db\"",[40,834,143],{"class":83},[40,836,837,840,843],{"class":42,"line":108},[40,838,839],{"class":83},"db.execute(",[40,841,842],{"class":50},"\"CREATE TABLE IF NOT EXISTS sales_summary (period TEXT, region TEXT, amount REAL, PRIMARY KEY (period, region))\"",[40,844,143],{"class":83},[40,846,847,849,852],{"class":42,"line":121},[40,848,839],{"class":83},[40,850,851],{"class":50},"\"CREATE TABLE IF NOT EXISTS sends (period TEXT, recipient TEXT, sent_at TEXT, PRIMARY KEY (period, recipient))\"",[40,853,143],{"class":83},[40,855,856],{"class":42,"line":146},[40,857,105],{"emptyLinePlaceholder":104},[40,859,860,862,865],{"class":42,"line":165},[40,861,111],{"class":79},[40,863,864],{"class":114}," load",[40,866,867],{"class":83},"(db, df, period):\n",[40,869,870,873],{"class":42,"line":202},[40,871,872],{"class":79},"    with",[40,874,875],{"class":83}," db:\n",[40,877,878,881,884],{"class":42,"line":207},[40,879,880],{"class":83},"        db.execute(",[40,882,883],{"class":50},"\"DELETE FROM sales_summary WHERE period = ?\"",[40,885,886],{"class":83},", (period,))\n",[40,888,889,892,895,898,901,903,905,907,909,912,914,916,918],{"class":42,"line":218},[40,890,891],{"class":83},"        db.executemany(",[40,893,894],{"class":50},"\"INSERT INTO sales_summary VALUES (?, ?, ?)\"",[40,896,897],{"class":83},", df[[",[40,899,900],{"class":50},"\"period\"",[40,902,227],{"class":83},[40,904,474],{"class":50},[40,906,227],{"class":83},[40,908,490],{"class":50},[40,910,911],{"class":83},"]].itertuples(",[40,913,568],{"class":133},[40,915,127],{"class":79},[40,917,484],{"class":139},[40,919,670],{"class":83},[40,921,922],{"class":42,"line":240},[40,923,105],{"emptyLinePlaceholder":104},[40,925,926,929,932,935,938,940,943],{"class":42,"line":261},[40,927,928],{"class":79},"for",[40,930,931],{"class":83}," _ ",[40,933,934],{"class":79},"in",[40,936,937],{"class":139}," range",[40,939,284],{"class":83},[40,941,942],{"class":139},"3",[40,944,945],{"class":83},"):\n",[40,947,948],{"class":42,"line":278},[40,949,950],{"class":83},"    load(db, summary, period)\n",[40,952,953,955,958,961],{"class":42,"line":507},[40,954,281],{"class":139},[40,956,957],{"class":83},"(db.execute(",[40,959,960],{"class":50},"\"SELECT period, region, amount FROM sales_summary\"",[40,962,963],{"class":83},").fetchall())\n",[10,965,966,967,970,971,974,975,978,979,982],{},"Loaded three times, the table still holds one row per region for September. The ",[37,968,969],{},"with db:"," block commits both statements together or rolls both back, so a failure part-way through never leaves the period empty or half-loaded. Deleting by period, rather than replacing the whole table, keeps other months untouched. The primary key on ",[37,972,973],{},"(period, region)"," is a safety net: if a bug ever tried to insert a duplicate, the load would fail loudly instead of silently doubling a figure. Databases with ",[37,976,977],{},"MERGE"," or ",[37,980,981],{},"INSERT … ON CONFLICT DO UPDATE"," can upsert instead, but delete-and-insert has one advantage: a region that disappeared from the source also disappears from the table.",[25,984,986],{"id":985},"step-4-side-effects-go-through-a-ledger","Step 4 — Side effects go through a ledger",[10,988,989],{},"Emails, uploads and API calls cannot be \"replaced\"; once sent, they are sent. Record each one under a key that identifies it — here period and recipient — and skip anything already recorded:",[30,991,993],{"className":70,"code":992,"language":72,"meta":35,"style":35},"from datetime import datetime\n\ndef send_once(db, period, recipient, path):\n    with db:\n        cur = db.execute(\"INSERT OR IGNORE INTO sends VALUES (?, ?, ?)\", (period, recipient, datetime.now().isoformat(timespec=\"seconds\")))\n    if cur.rowcount == 0:\n        return f\"skip {recipient} (already sent for {period})\"\n    # deliver(recipient, path)  — the real email or upload goes here\n    return f\"sent {path.name} to {recipient}\"\n\nfor recipient in [\"finance@example.com\", \"sales@example.com\"]:\n    print(send_once(db, period, recipient, report))\nprint(send_once(db, period, \"finance@example.com\", report))\n",[37,994,995,1006,1010,1020,1026,1052,1067,1096,1102,1129,1133,1156,1164],{"__ignoreMap":35},[40,996,997,999,1001,1003],{"class":42,"line":43},[40,998,90],{"class":79},[40,1000,93],{"class":83},[40,1002,80],{"class":79},[40,1004,1005],{"class":83}," datetime\n",[40,1007,1008],{"class":42,"line":87},[40,1009,105],{"emptyLinePlaceholder":104},[40,1011,1012,1014,1017],{"class":42,"line":101},[40,1013,111],{"class":79},[40,1015,1016],{"class":114}," send_once",[40,1018,1019],{"class":83},"(db, period, recipient, path):\n",[40,1021,1022,1024],{"class":42,"line":108},[40,1023,872],{"class":79},[40,1025,875],{"class":83},[40,1027,1028,1031,1033,1036,1039,1042,1045,1047,1050],{"class":42,"line":121},[40,1029,1030],{"class":83},"        cur ",[40,1032,127],{"class":79},[40,1034,1035],{"class":83}," db.execute(",[40,1037,1038],{"class":50},"\"INSERT OR IGNORE INTO sends VALUES (?, ?, ?)\"",[40,1040,1041],{"class":83},", (period, recipient, datetime.now().isoformat(",[40,1043,1044],{"class":133},"timespec",[40,1046,127],{"class":79},[40,1048,1049],{"class":50},"\"seconds\"",[40,1051,312],{"class":83},[40,1053,1054,1057,1060,1062,1065],{"class":42,"line":146},[40,1055,1056],{"class":79},"    if",[40,1058,1059],{"class":83}," cur.rowcount ",[40,1061,461],{"class":79},[40,1063,1064],{"class":139}," 0",[40,1066,559],{"class":83},[40,1068,1069,1072,1074,1077,1079,1082,1084,1087,1089,1091,1093],{"class":42,"line":165},[40,1070,1071],{"class":79},"        return",[40,1073,171],{"class":79},[40,1075,1076],{"class":50},"\"skip ",[40,1078,178],{"class":177},[40,1080,1081],{"class":83},"recipient",[40,1083,196],{"class":177},[40,1085,1086],{"class":50}," (already sent for ",[40,1088,178],{"class":177},[40,1090,318],{"class":83},[40,1092,196],{"class":177},[40,1094,1095],{"class":50},")\"\n",[40,1097,1098],{"class":42,"line":202},[40,1099,1101],{"class":1100},"s-wDw","    # deliver(recipient, path)  — the real email or upload goes here\n",[40,1103,1104,1106,1108,1111,1113,1116,1118,1121,1123,1125,1127],{"class":42,"line":207},[40,1105,168],{"class":79},[40,1107,171],{"class":79},[40,1109,1110],{"class":50},"\"sent ",[40,1112,178],{"class":177},[40,1114,1115],{"class":83},"path.name",[40,1117,196],{"class":177},[40,1119,1120],{"class":50}," to ",[40,1122,178],{"class":177},[40,1124,1081],{"class":83},[40,1126,196],{"class":177},[40,1128,199],{"class":50},[40,1130,1131],{"class":42,"line":218},[40,1132,105],{"emptyLinePlaceholder":104},[40,1134,1135,1137,1140,1142,1145,1148,1150,1153],{"class":42,"line":240},[40,1136,928],{"class":79},[40,1138,1139],{"class":83}," recipient ",[40,1141,934],{"class":79},[40,1143,1144],{"class":83}," [",[40,1146,1147],{"class":50},"\"finance@example.com\"",[40,1149,227],{"class":83},[40,1151,1152],{"class":50},"\"sales@example.com\"",[40,1154,1155],{"class":83},"]:\n",[40,1157,1158,1161],{"class":42,"line":261},[40,1159,1160],{"class":139},"    print",[40,1162,1163],{"class":83},"(send_once(db, period, recipient, report))\n",[40,1165,1166,1168,1171,1173],{"class":42,"line":278},[40,1167,281],{"class":139},[40,1169,1170],{"class":83},"(send_once(db, period, ",[40,1172,1147],{"class":50},[40,1174,1175],{"class":83},", report))\n",[10,1177,1178,1179,1182,1183,23],{},"The ledger row is written ",[14,1180,1181],{},"before"," the email is sent. If sending then fails, the row must be removed (or marked failed) so the rerun tries again — the trade-off is that a crash in the instant between \"recorded\" and \"sent\" can lose that one message, whereas recording afterwards risks sending it twice. For reports, an occasional duplicate is usually more acceptable than a lost email, in which case record after sending; for anything with money attached — payment files, invoices — record first. A separate delivery queue makes this decision explicit; see ",[19,1184,1186],{"href":1185},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fqueue-and-retry-failed-report-emails\u002F","Queue and Retry Failed Report Emails",[687,1188,696,1193,696,1196,696,1199,696,1202,696,1210,696,1218,696,1223,696,1227,696,1231,696,1234,696,1239,696,1247,696,1252,696,1257,696,1263,696,1268,696,1273,696,1275,696,1278,696,1282,696,1285,696,1288,696,1294,696,1298],{"viewBox":1189,"role":690,"ariaLabelledBy":1190,"xmlns":694,"style":695},"4 0 752 272",[1191,1192],"rr-ledger-t","rr-ledger-d",[698,1194,1195],{"id":1191},"Record a send before or after it happens",[702,1197,1198],{"id":1192},"Recording before sending means a crash between the two can lose one message but never duplicates it, which suits payments and invoices. Recording after sending means a crash can cause one duplicate but never loses a message, which suits routine reports.",[706,1200],{"x":708,"y":709,"width":710,"height":1201,"fill":712},"272",[706,1203],{"x":1204,"y":716,"width":1205,"height":1206,"rx":1207,"fill":1208,"stroke":1209,"style":722},"240.0","280.0","75.5","12","#f0f4ff","var(--brand,#5b5cf0)",[724,1211,1213],{"transform":1212},"translate(368.0,29.0) scale(1.000)",[728,1214],{"d":1215,"fill":731,"stroke":1216,"style":1217},"M3 11l18-8-8 18-2-8z","var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[735,1219,1222],{"x":1220,"y":1206,"style":1221},"380.0","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Which is worse for this output?",[42,1224],{"x1":1220,"y1":1225,"x2":1220,"y2":1226,"stroke":1209,"style":722},"91.5","111.5",[42,1228],{"x1":1229,"y1":1226,"x2":1230,"y2":1226,"stroke":1209,"style":722},"196.0","564.0",[42,1232],{"x1":1229,"y1":1226,"x2":1229,"y2":1233,"stroke":1209,"style":722},"157.5",[1235,1236],"polygon",{"points":1237,"fill":1238},"196.0,161.5 191.0,152.5 201.0,152.5","#5b5cf0",[706,1240],{"x":1241,"y":1242,"width":1243,"height":1244,"rx":304,"fill":712,"stroke":1245,"style":1246},"154.6","121.5","82.9","21.0","var(--line,#cdd5e6)","stroke-width:1.2px",[735,1248,1251],{"x":1229,"y":1249,"style":1250},"136.0","font-size:11.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","a duplicate",[706,1253],{"x":715,"y":1254,"width":1255,"height":1256,"rx":1207,"fill":720,"stroke":721,"style":722},"163.5","352.0","93.0",[724,1258,1260],{"transform":1259},"translate(184.0,176.5) scale(1.000)",[728,1261],{"d":1262,"fill":731,"stroke":732,"style":1217},"M6 11h12v9H6z M8.5 11V8a3.5 3.5 0 0 1 7 0v3",[735,1264,1267],{"x":1229,"y":1265,"style":1266},"223.0","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","record first",[735,1269,1272],{"x":1229,"y":1270,"style":1271},"242.5","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","payments, invoices",[42,1274],{"x1":1230,"y1":1226,"x2":1230,"y2":1233,"stroke":1209,"style":722},[1235,1276],{"points":1277,"fill":1238},"564.0,161.5 559.0,152.5 569.0,152.5",[706,1279],{"x":1280,"y":1242,"width":1281,"height":1244,"rx":304,"fill":712,"stroke":1245,"style":1246},"510.9","106.2",[735,1283,1284],{"x":1230,"y":1249,"style":1250},"a lost message",[706,1286],{"x":1287,"y":1254,"width":1255,"height":1256,"rx":1207,"fill":767,"stroke":768,"style":722},"388.0",[724,1289,1291],{"transform":1290},"translate(552.0,176.5) scale(1.000)",[728,1292],{"d":1293,"fill":731,"stroke":775,"style":1217},"M3 6h18v12H3z M3 7l9 6 9-6",[735,1295,1297],{"x":1230,"y":1265,"style":1296},"font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","record after",[735,1299,1300],{"x":1230,"y":1270,"style":1271},"routine reports",[25,1302,1304],{"id":1303},"step-5-crash-half-way-then-rerun","Step 5 — Crash half-way, then rerun",[10,1306,1307],{},"Put the steps together, make the job crash after loading but before sending, and run it again:",[30,1309,1311],{"className":70,"code":1310,"language":72,"meta":35,"style":35},"RECIPIENTS = [\"finance@example.com\", \"sales@example.com\", \"board@example.com\"]\n\ndef run(period, crash_after_load=False):\n    summary = build_summary(period)\n    write_atomically(summary, OUT \u002F f\"sales_summary_{period}.xlsx\")\n    load(db, summary, period)\n    if crash_after_load:\n        raise RuntimeError(\"simulated crash before sending\")\n    return [send_once(db, period, r, OUT \u002F f\"sales_summary_{period}.xlsx\") for r in RECIPIENTS]\n\ntry:\n    run(\"2026-10\", crash_after_load=True)\nexcept RuntimeError as e:\n    print(\"first run:\", e)\nfor line in run(\"2026-10\"):\n    print(\"rerun:\", line)\nfor line in run(\"2026-09\"):\n    print(\"september again:\", line)\nprint(\"files:\", sorted(p.name for p in OUT.iterdir()))\nprint(\"rows:\", db.execute(\"SELECT period, COUNT(*) FROM sales_summary GROUP BY period\").fetchall())\ndb.close()\n",[37,1312,1313,1336,1340,1356,1365,1389,1393,1400,1415,1452,1456,1463,1482,1495,1507,1523,1535,1549,1560,1589,1606],{"__ignoreMap":35},[40,1314,1315,1318,1320,1322,1324,1326,1328,1330,1333],{"class":42,"line":43},[40,1316,1317],{"class":139},"RECIPIENTS",[40,1319,382],{"class":79},[40,1321,1144],{"class":83},[40,1323,1147],{"class":50},[40,1325,227],{"class":83},[40,1327,1152],{"class":50},[40,1329,227],{"class":83},[40,1331,1332],{"class":50},"\"board@example.com\"",[40,1334,1335],{"class":83},"]\n",[40,1337,1338],{"class":42,"line":87},[40,1339,105],{"emptyLinePlaceholder":104},[40,1341,1342,1344,1347,1350,1352,1354],{"class":42,"line":101},[40,1343,111],{"class":79},[40,1345,1346],{"class":114}," run",[40,1348,1349],{"class":83},"(period, crash_after_load",[40,1351,127],{"class":79},[40,1353,484],{"class":139},[40,1355,945],{"class":83},[40,1357,1358,1361,1363],{"class":42,"line":108},[40,1359,1360],{"class":83},"    summary ",[40,1362,127],{"class":79},[40,1364,619],{"class":83},[40,1366,1367,1370,1372,1374,1376,1378,1380,1382,1384,1387],{"class":42,"line":121},[40,1368,1369],{"class":83},"    write_atomically(summary, ",[40,1371,379],{"class":139},[40,1373,633],{"class":79},[40,1375,171],{"class":79},[40,1377,638],{"class":50},[40,1379,178],{"class":177},[40,1381,318],{"class":83},[40,1383,196],{"class":177},[40,1385,1386],{"class":50},".xlsx\"",[40,1388,143],{"class":83},[40,1390,1391],{"class":42,"line":146},[40,1392,950],{"class":83},[40,1394,1395,1397],{"class":42,"line":165},[40,1396,1056],{"class":79},[40,1398,1399],{"class":83}," crash_after_load:\n",[40,1401,1402,1405,1408,1410,1413],{"class":42,"line":202},[40,1403,1404],{"class":79},"        raise",[40,1406,1407],{"class":139}," RuntimeError",[40,1409,284],{"class":83},[40,1411,1412],{"class":50},"\"simulated crash before sending\"",[40,1414,143],{"class":83},[40,1416,1417,1419,1422,1424,1426,1428,1430,1432,1434,1436,1438,1440,1442,1445,1447,1450],{"class":42,"line":207},[40,1418,168],{"class":79},[40,1420,1421],{"class":83}," [send_once(db, period, r, ",[40,1423,379],{"class":139},[40,1425,633],{"class":79},[40,1427,171],{"class":79},[40,1429,638],{"class":50},[40,1431,178],{"class":177},[40,1433,318],{"class":83},[40,1435,196],{"class":177},[40,1437,1386],{"class":50},[40,1439,458],{"class":83},[40,1441,928],{"class":79},[40,1443,1444],{"class":83}," r ",[40,1446,934],{"class":79},[40,1448,1449],{"class":139}," RECIPIENTS",[40,1451,1335],{"class":83},[40,1453,1454],{"class":42,"line":218},[40,1455,105],{"emptyLinePlaceholder":104},[40,1457,1458,1461],{"class":42,"line":240},[40,1459,1460],{"class":79},"try",[40,1462,559],{"class":83},[40,1464,1465,1468,1471,1473,1476,1478,1480],{"class":42,"line":261},[40,1466,1467],{"class":83},"    run(",[40,1469,1470],{"class":50},"\"2026-10\"",[40,1472,227],{"class":83},[40,1474,1475],{"class":133},"crash_after_load",[40,1477,127],{"class":79},[40,1479,405],{"class":139},[40,1481,143],{"class":83},[40,1483,1484,1487,1489,1492],{"class":42,"line":278},[40,1485,1486],{"class":79},"except",[40,1488,1407],{"class":139},[40,1490,1491],{"class":79}," as",[40,1493,1494],{"class":83}," e:\n",[40,1496,1497,1499,1501,1504],{"class":42,"line":507},[40,1498,1160],{"class":139},[40,1500,284],{"class":83},[40,1502,1503],{"class":50},"\"first run:\"",[40,1505,1506],{"class":83},", e)\n",[40,1508,1509,1511,1514,1516,1519,1521],{"class":42,"line":518},[40,1510,928],{"class":79},[40,1512,1513],{"class":83}," line ",[40,1515,934],{"class":79},[40,1517,1518],{"class":83}," run(",[40,1520,1470],{"class":50},[40,1522,945],{"class":83},[40,1524,1525,1527,1529,1532],{"class":42,"line":547},[40,1526,1160],{"class":139},[40,1528,284],{"class":83},[40,1530,1531],{"class":50},"\"rerun:\"",[40,1533,1534],{"class":83},", line)\n",[40,1536,1537,1539,1541,1543,1545,1547],{"class":42,"line":553},[40,1538,928],{"class":79},[40,1540,1513],{"class":83},[40,1542,934],{"class":79},[40,1544,1518],{"class":83},[40,1546,255],{"class":50},[40,1548,945],{"class":83},[40,1550,1551,1553,1555,1558],{"class":42,"line":562},[40,1552,1160],{"class":139},[40,1554,284],{"class":83},[40,1556,1557],{"class":50},"\"september again:\"",[40,1559,1534],{"class":83},[40,1561,1562,1564,1566,1569,1571,1574,1577,1579,1582,1584,1586],{"class":42,"line":577},[40,1563,281],{"class":139},[40,1565,284],{"class":83},[40,1567,1568],{"class":50},"\"files:\"",[40,1570,227],{"class":83},[40,1572,1573],{"class":139},"sorted",[40,1575,1576],{"class":83},"(p.name ",[40,1578,928],{"class":79},[40,1580,1581],{"class":83}," p ",[40,1583,934],{"class":79},[40,1585,630],{"class":139},[40,1587,1588],{"class":83},".iterdir()))\n",[40,1590,1591,1593,1595,1598,1601,1604],{"class":42,"line":583},[40,1592,281],{"class":139},[40,1594,284],{"class":83},[40,1596,1597],{"class":50},"\"rows:\"",[40,1599,1600],{"class":83},", db.execute(",[40,1602,1603],{"class":50},"\"SELECT period, COUNT(*) FROM sales_summary GROUP BY period\"",[40,1605,963],{"class":83},[40,1607,1608],{"class":42,"line":594},[40,1609,1610],{"class":83},"db.close()\n",[10,1612,1613,1614,1618,1619,23],{},"The first October run wrote the file and loaded the rows, then crashed. The rerun rewrites the same file, replaces the same rows and sends to all three recipients — nothing is duplicated, because nothing before the send had any lasting effect that could be doubled. Running September once more sends only to the board address, the one recipient that had not received it, and skips the two that had. The output folder holds exactly one file per period and the table one set of rows per period. That is the property to aim for: whatever happened before, running the job again for a period finishes the work and does nothing twice. Two further protections complete the picture — stopping two copies running at the same moment, in ",[19,1615,1617],{"href":1616},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-overlapping-runs-of-an-excel-report-job\u002F","Prevent Overlapping Runs of an Excel Report Job",", and skipping work entirely when the input has not changed, in ",[19,1620,1622],{"href":1621},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fskip-a-report-when-the-input-file-has-not-changed\u002F","Skip a Report When the Input File Has Not Changed",[687,1624,696,1629,696,1632,696,1635,696,1638,696,1645,696,1649,696,1655,696,1660,696,1664,696,1668,696,1672,696,1677,696,1681,696,1685,696,1687,696,1689,696,1691,696,1696,696,1699,696,1701],{"viewBox":1625,"role":690,"ariaLabelledBy":1626,"xmlns":694,"style":695},"4 0 752 245",[1627,1628],"rr-crash-t","rr-crash-d",[698,1630,1631],{"id":1627},"A crash and a rerun for October",[702,1633,1634],{"id":1628},"The first run writes the October file and loads its rows, then crashes before sending. The rerun replaces the file and rows with identical ones and sends to all three recipients. A later run for September sends only to the recipient who had not yet received it.",[706,1636],{"x":708,"y":709,"width":710,"height":1637,"fill":712},"245",[42,1639],{"x1":1640,"y1":1641,"x2":1642,"y2":1641,"stroke":1643,"style":1644},"96.0","122.5","664.0","var(--muted,#5b6780)","stroke-width:2.5px",[706,1646],{"x":715,"y":716,"width":1647,"height":1648,"rx":1207,"fill":720,"stroke":721,"style":722},"176.0","74.5",[735,1650,1654],{"x":1651,"y":1652,"style":1653},"108.0","49.0","font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Run 1",[735,1656,1659],{"x":1651,"y":1657,"style":1658},"67.5","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","file + rows, then crash",[42,1661],{"x1":1651,"y1":1662,"x2":1651,"y2":1641,"stroke":1643,"style":1663},"90.5","stroke-width:1.5px",[1665,1666],"circle",{"cx":1651,"cy":1641,"r":1667,"fill":721,"stroke":712,"style":722},"7.0",[706,1669],{"x":1670,"y":1671,"width":1647,"height":1648,"rx":1207,"fill":767,"stroke":768,"style":722},"292.0","154.5",[735,1673,1676],{"x":1220,"y":1674,"style":1675},"179.0","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Rerun",[735,1678,1680],{"x":1220,"y":1679,"style":1658},"197.5","same file, same rows, 3",[735,1682,1684],{"x":1220,"y":1683,"style":1658},"214.5","sends",[42,1686],{"x1":1220,"y1":1641,"x2":1220,"y2":1671,"stroke":1643,"style":1663},[1665,1688],{"cx":1220,"cy":1641,"r":1667,"fill":768,"stroke":712,"style":722},[706,1690],{"x":1230,"y":716,"width":1647,"height":1648,"rx":1207,"fill":1208,"stroke":1209,"style":722},[735,1692,1695],{"x":1693,"y":1652,"style":1694},"652.0","font-size:12.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","September again",[735,1697,1698],{"x":1693,"y":1657,"style":1658},"1 new send, 2 skipped",[42,1700],{"x1":1693,"y1":1662,"x2":1693,"y2":1641,"stroke":1643,"style":1663},[1665,1702],{"cx":1693,"cy":1641,"r":1667,"fill":1209,"stroke":712,"style":722},[25,1704,1706],{"id":1705},"common-pitfalls","Common pitfalls",[1708,1709,1710,1721,1727,1733,1739],"ul",{},[1711,1712,1713,1720],"li",{},[1714,1715,1716,1719],"strong",{},[37,1717,1718],{},"date.today()"," deep in the code."," Pass the period in; default it only at the entry point.",[1711,1722,1723,1726],{},[1714,1724,1725],{},"Timestamped file names."," Name by period and replace atomically.",[1711,1728,1729,1732],{},[1714,1730,1731],{},"Appending rows."," Delete and insert the period in one transaction.",[1711,1734,1735,1738],{},[1714,1736,1737],{},"Unconditional emails."," Record sends under a unique key and check before sending.",[1711,1740,1741,1744],{},[1714,1742,1743],{},"Assuming the last run finished."," Design every step to be repeated after a crash.",[25,1746,1748],{"id":1747},"conclusion","Conclusion",[10,1750,1751],{},"Make a report job safe to rerun by giving every step a stable identity: the period is a parameter, files are named by period and swapped in atomically, database rows for the period are deleted and reinserted in one transaction, and emails and uploads go through a ledger keyed by period and recipient. Then a rerun — after a crash, a fix or a mistake — finishes whatever was left and repeats nothing.",[25,1753,1755],{"id":1754},"frequently-asked-questions","Frequently asked questions",[10,1757,1758,1761],{},[1714,1759,1760],{},"What does it mean for a report job to be idempotent?","\nRunning it several times for the same period has the same outcome as running it once: one file, one set of rows and one email per recipient.",[10,1763,1764,1767],{},[1714,1765,1766],{},"How do I avoid duplicate rows when rerunning a load?","\nDelete the period's existing rows and insert the new ones inside a single transaction, or upsert on a key that includes the period.",[10,1769,1770,1773],{},[1714,1771,1772],{},"How do I stop a rerun from emailing people twice?","\nRecord each send in a table with a unique key such as period and recipient, and skip recipients already recorded.",[10,1775,1776,1779],{},[1714,1777,1778],{},"Why should the reporting period be a parameter?","\nBecause a job that works out the period from today's date reports on a different period when rerun later; passing it in makes reruns reproducible.",[25,1781,1783],{"id":1782},"related","Related",[1708,1785,1786,1792,1799,1806],{},[1711,1787,1788,1789,1791],{},"Up one level: ",[19,1790,22],{"href":21}," — reliable Excel jobs.",[1711,1793,1794,1798],{},[19,1795,1797],{"href":1796},"\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 steps that can be retried.",[1711,1800,1801,1805],{},[19,1802,1804],{"href":1803},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fquarantine-bad-excel-files-in-a-batch-job\u002F","Quarantine Bad Excel Files in a Batch Job"," — handling bad input files.",[1711,1807,1808,1810],{},[19,1809,685],{"href":684}," — the atomic write in detail.",[1812,1813,1814],"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 .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}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":35,"searchDepth":87,"depth":87,"links":1816},[1817,1818,1819,1820,1821,1822,1823,1824,1825,1826],{"id":27,"depth":87,"text":28},{"id":63,"depth":87,"text":64},{"id":322,"depth":87,"text":323},{"id":799,"depth":87,"text":800},{"id":985,"depth":87,"text":986},{"id":1303,"depth":87,"text":1304},{"id":1705,"depth":87,"text":1706},{"id":1747,"depth":87,"text":1748},{"id":1754,"depth":87,"text":1755},{"id":1782,"depth":87,"text":1783},"2026-10-02","Design a scheduled Excel report job so that running it again — after a crash, a fix or by accident — gives the same result and no duplicate side effects: take the period as a parameter instead of reading the clock, name outputs by period, write files atomically, replace a period's database rows in one transaction, record sends in a ledger, and recover cleanly from a crash half-way through.","md",[1831,1833,1835,1837],{"q":1760,"a":1832},"Running it several times for the same period has the same outcome as running it once: one file, one set of rows and one email per recipient.",{"q":1766,"a":1834},"Delete the period's existing rows and insert the new ones inside a single transaction, or upsert on a key that includes the period.",{"q":1772,"a":1836},"Record each send in a table with a unique key such as period and recipient, and skip recipients already recorded.",{"q":1778,"a":1838},"Because a job that works out the period from today's date reports on a different period when rerun later; passing it in makes reruns reproducible.","make an excel report job safe to rerun",null,{"breadcrumb":1842},[1843,1846,1849],{"name":1844,"item":1845},"Home","\u002F",{"name":1847,"item":1848},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":22,"item":21},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun",{"title":1852,"description":1853},"Make an Excel Report Job Safe to Rerun in Python","Idempotent Excel report jobs: period parameters instead of today, deterministic file names, atomic writes, delete-and-insert per period in one transaction, a send ledger with unique keys, and recovering from a mid-run crash.","make-an-excel-report-job-safe-to-rerun","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun\u002Findex",[1857],"reliable-jobs","how-to","3pLazirkmwSXCTWc2z10Z92O2oPtoAKHR96Pu4lLmAE",[1861,1865],{"title":1862,"path":1863,"stem":1864,"children":-1},"Log Python Excel Script Output to a File","\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Flog-python-excel-script-output-to-a-file","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Flog-python-excel-script-output-to-a-file\u002Findex",{"title":1866,"path":1867,"stem":1868,"children":-1},"Prevent Formula Injection in Excel Exports from Python","\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports\u002Findex",[1870,1872],{"path":1871,"title":22},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation",{"path":1873,"title":1847},"\u002Fautomating-reporting-workflows",[],[1876,1880,1883,1886,1889,1892],{"path":1877,"title":1878,"kind":1879},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts","Testing and Packaging Excel Automation Scripts","guide",{"path":1881,"title":1882,"kind":1858},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fblock-large-or-sensitive-excel-files-with-a-pre-commit-hook","Block Large or Sensitive Excel Files with a Pre-commit Hook",{"path":1884,"title":1885,"kind":1858},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fbuild-a-command-line-tool-for-excel-reports-with-argparse","Build a Command-Line Tool for Excel Reports with argparse",{"path":1887,"title":1888,"kind":1858},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcompare-excel-output-to-a-golden-file-in-tests","Compare Excel Output to a Golden File in Tests",{"path":1890,"title":1891,"kind":1858},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcreate-excel-test-fixtures-in-memory","Create Excel Test Fixtures in Memory for pytest",{"path":1893,"title":1894,"kind":1858},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fdiff-excel-files-in-git-with-a-textconv-driver","Diff Excel Files in Git with a textconv Driver",1790972132012]