[{"data":1,"prerenderedAt":2102},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fmake-excel-output-reproducible":3,"surround:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fmake-excel-output-reproducible":2067,"ancestors:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fmake-excel-output-reproducible":2076,"children:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fmake-excel-output-reproducible":2081,"related:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fmake-excel-output-reproducible":2082},{"id":4,"title":5,"body":6,"dateModified":2033,"datePublished":2033,"description":2034,"extension":2035,"faq":2036,"keyword":2045,"kind":2046,"meta":2047,"navigation":293,"path":2056,"seo":2057,"slug":2060,"stem":2061,"topics":2062,"type":2065,"__hash__":2066},"docs\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fmake-excel-output-reproducible\u002Findex.md","Make Excel Output Reproducible in Python",{"type":7,"value":8,"toc":2018},"minimark",[9,19,186,191,223,234,238,241,516,531,535,538,709,717,721,729,1174,1181,1278,1282,1285,1509,1532,1536,1539,1739,1751,1864,1868,1875,1879,1887,1891,1941,1945,1948,1952,1959,1963,1969,1975,1981,1987,1991,2014],[10,11,12,13,18],"p",{},"Run a report twice on the same inputs and you would expect the same file. Usually you do not get it: the two workbooks open identically in Excel but differ byte for byte, so a hash comparison says they changed, a backup tool stores both, and a \"has the report changed since yesterday?\" check always says yes. Some of the differences are metadata written by the library; others come from the script itself — files read in whatever order the operating system lists them, a set iterated in arbitrary order, the clock. This page, part of ",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002F","Testing and Packaging Excel Automation Scripts",", finds each source and removes it.",[20,21,29,30,29,34,29,38,29,46,29,56,29,67,29,74,29,79,29,87,29,94,29,100,29,105,29,108,29,113,29,116,29,122,29,126,29,130,29,133,29,136,29,141,29,148,29,153,29,157,29,160,29,163,29,166,29,172,29,176,29,180,29,183],"svg",{"viewBox":22,"role":23,"ariaLabelledBy":24,"xmlns":27,"style":28},"4 0 752 436","img",[25,26],"rep-sources-t","rep-sources-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  ",[31,32,33],"title",{"id":25},"What makes two runs of the same report differ",[35,36,37],"desc",{"id":26},"Four sources of difference between two runs with the same inputs. Metadata written by the library: the created and modified timestamps inside the file. The zip container: some libraries stamp each archive entry with the current time. Input order: files and rows read in the order the operating system happens to list them. Hidden state: sets, random numbers and calls to now() in the code.",[39,40],"rect",{"x":41,"y":42,"width":43,"height":44,"fill":45},"4","0","752","436","#ffffff",[39,47],{"x":48,"y":49,"width":50,"height":51,"rx":52,"fill":53,"stroke":54,"style":55},"20.0","171.5","220.0","93.0","12","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[57,58,60],"g",{"transform":59},"translate(118.0,184.5) scale(1.000)",[61,62],"path",{"d":63,"fill":64,"stroke":65,"style":66},"M20 11a8 8 0 1 0-2.3 5.7 M20 4v7h-7","none","var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[68,69,73],"text",{"x":70,"y":71,"style":72},"130.0","231.0","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Same inputs",[68,75,78],{"x":70,"y":76,"style":77},"250.5","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","two runs",[39,80],{"x":81,"y":82,"width":83,"height":84,"rx":52,"fill":85,"stroke":86,"style":55},"490.0","16.0","250.0","92.0","#fdefd8","var(--gold,#b4740a)",[57,88,90],{"transform":89},"translate(603.0,29.0) scale(1.000)",[61,91],{"d":92,"fill":64,"stroke":93,"style":66},"M6 3h9l4 4v14H6z M15 3v4h4","var(--gold-ink,#7a4e06)",[68,95,99],{"x":96,"y":97,"style":98},"615.0","75.0","font-size:13px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","File metadata",[68,101,104],{"x":96,"y":102,"style":103},"94.0","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","created and modified times",[61,106],{"d":107,"fill":64,"stroke":54,"style":55},"M244.0,218.0 C364.0,218.0 364.0,62.0 480.0,62.0",[109,110],"polygon",{"points":111,"fill":112},"484.0,62.0 475.0,67.0 475.0,57.0","#5b5cf0",[39,114],{"x":81,"y":115,"width":83,"height":84,"rx":52,"fill":85,"stroke":86,"style":55},"120.0",[57,117,119],{"transform":118},"translate(603.0,133.0) scale(1.000)",[61,120],{"d":121,"fill":64,"stroke":93,"style":66},"M12 2.5l8.5 4.7v9.6L12 21.5l-8.5-4.7V7.2z M3.5 7.2l8.5 4.7 8.5-4.7 M12 11.9v9.6",[68,123,125],{"x":96,"y":124,"style":98},"179.0","Zip entry dates",[68,127,129],{"x":96,"y":128,"style":103},"198.0","current time per entry",[61,131],{"d":132,"fill":64,"stroke":54,"style":55},"M244.0,218.0 C364.0,218.0 364.0,166.0 480.0,166.0",[109,134],{"points":135,"fill":112},"484.0,166.0 475.0,171.0 475.0,161.0",[39,137],{"x":81,"y":138,"width":83,"height":84,"rx":52,"fill":139,"stroke":140,"style":55},"224.0","#fee8f2","var(--accent,#f43f8f)",[57,142,144],{"transform":143},"translate(603.0,237.0) scale(1.000)",[61,145],{"d":146,"fill":64,"stroke":147,"style":66},"M3 6h6l2 2h10v11H3z","var(--accent-ink,#be185d)",[68,149,152],{"x":96,"y":150,"style":151},"283.0","font-size:13px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Input order",[68,154,156],{"x":96,"y":155,"style":103},"302.0","unsorted file lists",[61,158],{"d":159,"fill":64,"stroke":54,"style":55},"M244.0,218.0 C364.0,218.0 364.0,270.0 480.0,270.0",[109,161],{"points":162,"fill":112},"484.0,270.0 475.0,275.0 475.0,265.0",[39,164],{"x":81,"y":165,"width":83,"height":84,"rx":52,"fill":139,"stroke":140,"style":55},"328.0",[57,167,169],{"transform":168},"translate(603.0,341.0) scale(1.000)",[61,170],{"d":171,"fill":64,"stroke":147,"style":66},"M9 8l-4 4 4 4 M15 8l4 4-4 4",[68,173,175],{"x":96,"y":174,"style":151},"387.0","Hidden state",[68,177,179],{"x":96,"y":178,"style":103},"406.0","sets, random, now()",[61,181],{"d":182,"fill":64,"stroke":54,"style":55},"M244.0,218.0 C364.0,218.0 364.0,374.0 480.0,374.0",[109,184],{"points":185,"fill":112},"484.0,374.0 475.0,379.0 475.0,369.0",[187,188,190],"h2",{"id":189},"prerequisites","Prerequisites",[192,193,198],"pre",{"className":194,"code":195,"language":196,"meta":197,"style":197},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl xlsxwriter\n","bash","",[199,200,201],"code",{"__ignoreMap":197},[202,203,206,210,214,217,220],"span",{"class":204,"line":205},"line",1,[202,207,209],{"class":208},"sMTad","pip",[202,211,213],{"class":212},"srMev"," install",[202,215,216],{"class":212}," pandas",[202,218,219],{"class":212}," openpyxl",[202,221,222],{"class":212}," xlsxwriter\n",[10,224,225,226,229,230,233],{},"Three small input files in ",[199,227,228],{},"inputs\u002F",", one per region, are combined into a report. The examples hash the output with ",[199,231,232],{},"hashlib"," to compare runs.",[187,235,237],{"id":236},"step-1-see-the-difference","Step 1 — See the difference",[10,239,240],{},"Write the same DataFrame twice, a couple of seconds apart, and compare the files' hashes and their metadata:",[192,242,246],{"className":243,"code":244,"language":245,"meta":197,"style":197},"language-python shiki shiki-themes github-light github-dark-high-contrast","import hashlib\nimport time\nimport zipfile\nimport pandas as pd\n\ndef digest(path):\n    return hashlib.sha256(open(path, \"rb\").read()).hexdigest()[:12]\n\ndf = pd.DataFrame({\"Region\": [\"North\", \"South\"], \"Revenue\": [48200, 39100]})\ndf.to_excel(\"run1.xlsx\", index=False)\ntime.sleep(1.1)\ndf.to_excel(\"run2.xlsx\", index=False)\nprint(pd.get_option(\"io.excel.xlsx.writer\"), \"->\", digest(\"run1.xlsx\"), digest(\"run2.xlsx\"))\n\ncore = zipfile.ZipFile(\"run1.xlsx\").read(\"docProps\u002Fcore.xml\").decode()\nprint(core[core.index(\"\u003Cdcterms:created\"):][:120])\n","python",[199,247,248,258,266,274,288,295,308,335,340,386,409,420,438,469,474,496],{"__ignoreMap":197},[202,249,250,254],{"class":204,"line":205},[202,251,253],{"class":252},"s-kum","import",[202,255,257],{"class":256},"skGVy"," hashlib\n",[202,259,261,263],{"class":204,"line":260},2,[202,262,253],{"class":252},[202,264,265],{"class":256}," time\n",[202,267,269,271],{"class":204,"line":268},3,[202,270,253],{"class":252},[202,272,273],{"class":256}," zipfile\n",[202,275,277,279,282,285],{"class":204,"line":276},4,[202,278,253],{"class":252},[202,280,281],{"class":256}," pandas ",[202,283,284],{"class":252},"as",[202,286,287],{"class":256}," pd\n",[202,289,291],{"class":204,"line":290},5,[202,292,294],{"emptyLinePlaceholder":293},true,"\n",[202,296,298,301,305],{"class":204,"line":297},6,[202,299,300],{"class":252},"def",[202,302,304],{"class":303},"s_Opv"," digest",[202,306,307],{"class":256},"(path):\n",[202,309,311,314,317,321,324,327,330,332],{"class":204,"line":310},7,[202,312,313],{"class":252},"    return",[202,315,316],{"class":256}," hashlib.sha256(",[202,318,320],{"class":319},"sP0c6","open",[202,322,323],{"class":256},"(path, ",[202,325,326],{"class":212},"\"rb\"",[202,328,329],{"class":256},").read()).hexdigest()[:",[202,331,52],{"class":319},[202,333,334],{"class":256},"]\n",[202,336,338],{"class":204,"line":337},8,[202,339,294],{"emptyLinePlaceholder":293},[202,341,343,346,349,352,355,358,361,364,367,370,373,375,378,380,383],{"class":204,"line":342},9,[202,344,345],{"class":256},"df ",[202,347,348],{"class":252},"=",[202,350,351],{"class":256}," pd.DataFrame({",[202,353,354],{"class":212},"\"Region\"",[202,356,357],{"class":256},": [",[202,359,360],{"class":212},"\"North\"",[202,362,363],{"class":256},", ",[202,365,366],{"class":212},"\"South\"",[202,368,369],{"class":256},"], ",[202,371,372],{"class":212},"\"Revenue\"",[202,374,357],{"class":256},[202,376,377],{"class":319},"48200",[202,379,363],{"class":256},[202,381,382],{"class":319},"39100",[202,384,385],{"class":256},"]})\n",[202,387,389,392,395,397,401,403,406],{"class":204,"line":388},10,[202,390,391],{"class":256},"df.to_excel(",[202,393,394],{"class":212},"\"run1.xlsx\"",[202,396,363],{"class":256},[202,398,400],{"class":399},"sa561","index",[202,402,348],{"class":252},[202,404,405],{"class":319},"False",[202,407,408],{"class":256},")\n",[202,410,412,415,418],{"class":204,"line":411},11,[202,413,414],{"class":256},"time.sleep(",[202,416,417],{"class":319},"1.1",[202,419,408],{"class":256},[202,421,423,425,428,430,432,434,436],{"class":204,"line":422},12,[202,424,391],{"class":256},[202,426,427],{"class":212},"\"run2.xlsx\"",[202,429,363],{"class":256},[202,431,400],{"class":399},[202,433,348],{"class":252},[202,435,405],{"class":319},[202,437,408],{"class":256},[202,439,441,444,447,450,453,456,459,461,464,466],{"class":204,"line":440},13,[202,442,443],{"class":319},"print",[202,445,446],{"class":256},"(pd.get_option(",[202,448,449],{"class":212},"\"io.excel.xlsx.writer\"",[202,451,452],{"class":256},"), ",[202,454,455],{"class":212},"\"->\"",[202,457,458],{"class":256},", digest(",[202,460,394],{"class":212},[202,462,463],{"class":256},"), digest(",[202,465,427],{"class":212},[202,467,468],{"class":256},"))\n",[202,470,472],{"class":204,"line":471},14,[202,473,294],{"emptyLinePlaceholder":293},[202,475,477,480,482,485,487,490,493],{"class":204,"line":476},15,[202,478,479],{"class":256},"core ",[202,481,348],{"class":252},[202,483,484],{"class":256}," zipfile.ZipFile(",[202,486,394],{"class":212},[202,488,489],{"class":256},").read(",[202,491,492],{"class":212},"\"docProps\u002Fcore.xml\"",[202,494,495],{"class":256},").decode()\n",[202,497,499,501,504,507,510,513],{"class":204,"line":498},16,[202,500,443],{"class":319},[202,502,503],{"class":256},"(core[core.index(",[202,505,506],{"class":212},"\"\u003Cdcterms:created\"",[202,508,509],{"class":256},"):][:",[202,511,512],{"class":319},"120",[202,514,515],{"class":256},"])\n",[10,517,518,519,522,523,526,527,530],{},"Same data, different hashes. ",[199,520,521],{},"to_excel"," uses xlsxwriter when it is installed — the ",[199,524,525],{},"auto"," setting prefers it over openpyxl — and xlsxwriter records the current time as the workbook's creation date in ",[199,528,529],{},"docProps\u002Fcore.xml",". That one timestamp is the only difference.",[187,532,534],{"id":533},"step-2-fix-it-for-xlsxwriter","Step 2 — Fix it for xlsxwriter",[10,536,537],{},"xlsxwriter already writes every zip entry with a fixed 1980 date, so the creation timestamp is all that varies. Set it explicitly — to the report's as-of date, which is meaningful and stable:",[192,539,541],{"className":243,"code":540,"language":245,"meta":197,"style":197},"from datetime import datetime\n\nas_of = datetime(2026, 9, 30)\nfor name in [\"fixed1.xlsx\", \"fixed2.xlsx\"]:\n    with pd.ExcelWriter(name, engine=\"xlsxwriter\") as writer:\n        writer.book.set_properties({\"created\": as_of, \"title\": \"Daily sales\", \"author\": \"Reporting\"})\n        df.to_excel(writer, index=False)\n    time.sleep(1.1)\nprint(\"xlsxwriter:\", digest(\"fixed1.xlsx\"), digest(\"fixed2.xlsx\"))\n",[199,542,543,556,560,585,610,634,667,680,689],{"__ignoreMap":197},[202,544,545,548,551,553],{"class":204,"line":205},[202,546,547],{"class":252},"from",[202,549,550],{"class":256}," datetime ",[202,552,253],{"class":252},[202,554,555],{"class":256}," datetime\n",[202,557,558],{"class":204,"line":260},[202,559,294],{"emptyLinePlaceholder":293},[202,561,562,565,567,570,573,575,578,580,583],{"class":204,"line":268},[202,563,564],{"class":256},"as_of ",[202,566,348],{"class":252},[202,568,569],{"class":256}," datetime(",[202,571,572],{"class":319},"2026",[202,574,363],{"class":256},[202,576,577],{"class":319},"9",[202,579,363],{"class":256},[202,581,582],{"class":319},"30",[202,584,408],{"class":256},[202,586,587,590,593,596,599,602,604,607],{"class":204,"line":276},[202,588,589],{"class":252},"for",[202,591,592],{"class":256}," name ",[202,594,595],{"class":252},"in",[202,597,598],{"class":256}," [",[202,600,601],{"class":212},"\"fixed1.xlsx\"",[202,603,363],{"class":256},[202,605,606],{"class":212},"\"fixed2.xlsx\"",[202,608,609],{"class":256},"]:\n",[202,611,612,615,618,621,623,626,629,631],{"class":204,"line":290},[202,613,614],{"class":252},"    with",[202,616,617],{"class":256}," pd.ExcelWriter(name, ",[202,619,620],{"class":399},"engine",[202,622,348],{"class":252},[202,624,625],{"class":212},"\"xlsxwriter\"",[202,627,628],{"class":256},") ",[202,630,284],{"class":252},[202,632,633],{"class":256}," writer:\n",[202,635,636,639,642,645,648,651,654,656,659,661,664],{"class":204,"line":297},[202,637,638],{"class":256},"        writer.book.set_properties({",[202,640,641],{"class":212},"\"created\"",[202,643,644],{"class":256},": as_of, ",[202,646,647],{"class":212},"\"title\"",[202,649,650],{"class":256},": ",[202,652,653],{"class":212},"\"Daily sales\"",[202,655,363],{"class":256},[202,657,658],{"class":212},"\"author\"",[202,660,650],{"class":256},[202,662,663],{"class":212},"\"Reporting\"",[202,665,666],{"class":256},"})\n",[202,668,669,672,674,676,678],{"class":204,"line":310},[202,670,671],{"class":256},"        df.to_excel(writer, ",[202,673,400],{"class":399},[202,675,348],{"class":252},[202,677,405],{"class":319},[202,679,408],{"class":256},[202,681,682,685,687],{"class":204,"line":337},[202,683,684],{"class":256},"    time.sleep(",[202,686,417],{"class":319},[202,688,408],{"class":256},[202,690,691,693,696,699,701,703,705,707],{"class":204,"line":342},[202,692,443],{"class":319},[202,694,695],{"class":256},"(",[202,697,698],{"class":212},"\"xlsxwriter:\"",[202,700,458],{"class":256},[202,702,601],{"class":212},[202,704,463],{"class":256},[202,706,606],{"class":212},[202,708,468],{"class":256},[10,710,711,712,716],{},"Identical bytes. Using the as-of date rather than an arbitrary constant keeps the metadata honest: the file says it describes 30 September, which is true however many times it is regenerated. ",[14,713,715],{"href":714},"\u002Fgetting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Fset-workbook-properties-with-python\u002F","Set Workbook Properties with Python"," covers the other properties.",[187,718,720],{"id":719},"step-3-fix-it-for-openpyxl","Step 3 — Fix it for openpyxl",[10,722,723,724,728],{},"openpyxl is harder: it overwrites the ",[725,726,727],"em",{},"modified"," time with the current time on every save, even if you set it, and stamps each zip entry with the current time too. Setting properties is not enough, so normalise the file after saving — rewrite the archive with a fixed date on every entry and a fixed modified value:",[192,730,732],{"className":243,"code":731,"language":245,"meta":197,"style":197},"import re\nfrom openpyxl import Workbook\n\ndef normalise_xlsx(path, when=datetime(2026, 9, 30)):\n    stamp = when.strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n    with zipfile.ZipFile(path) as src:\n        entries = [(info, src.read(info.filename)) for info in src.infolist()]\n    with zipfile.ZipFile(path, \"w\", zipfile.ZIP_DEFLATED) as dst:\n        for info, data in entries:\n            if info.filename == \"docProps\u002Fcore.xml\":\n                data = re.sub(rb\"(\u003Cdcterms:(created|modified)[^>]*>)[^\u003C]*\", lambda m: m.group(1) + stamp.encode(), data)\n            fixed = zipfile.ZipInfo(info.filename, date_time=when.timetuple()[:6])\n            fixed.compress_type = zipfile.ZIP_DEFLATED\n            fixed.external_attr = info.external_attr\n            dst.writestr(fixed, data)\n\nhashes = []\nfor name in [\"op1.xlsx\", \"op2.xlsx\"]:\n    wb = Workbook()\n    wb.active.append([\"Region\", \"Revenue\"])\n    wb.active.append([\"North\", 48200])\n    wb.save(name)\n    before = digest(name)\n    normalise_xlsx(name)\n    hashes.append((before, digest(name)))\n    time.sleep(1.1)\nprint(\"openpyxl before:\", [b for b, _ in hashes], \"after:\", [a for _, a in hashes])\n",[199,733,734,741,753,757,785,807,819,839,862,875,892,972,995,1008,1018,1023,1027,1038,1059,1070,1084,1097,1103,1114,1120,1126,1135],{"__ignoreMap":197},[202,735,736,738],{"class":204,"line":205},[202,737,253],{"class":252},[202,739,740],{"class":256}," re\n",[202,742,743,745,748,750],{"class":204,"line":260},[202,744,547],{"class":252},[202,746,747],{"class":256}," openpyxl ",[202,749,253],{"class":252},[202,751,752],{"class":256}," Workbook\n",[202,754,755],{"class":204,"line":268},[202,756,294],{"emptyLinePlaceholder":293},[202,758,759,761,764,767,769,772,774,776,778,780,782],{"class":204,"line":276},[202,760,300],{"class":252},[202,762,763],{"class":303}," normalise_xlsx",[202,765,766],{"class":256},"(path, when",[202,768,348],{"class":252},[202,770,771],{"class":256},"datetime(",[202,773,572],{"class":319},[202,775,363],{"class":256},[202,777,577],{"class":319},[202,779,363],{"class":256},[202,781,582],{"class":319},[202,783,784],{"class":256},")):\n",[202,786,787,790,792,795,798,802,805],{"class":204,"line":290},[202,788,789],{"class":256},"    stamp ",[202,791,348],{"class":252},[202,793,794],{"class":256}," when.strftime(",[202,796,797],{"class":212},"\"%Y-%m-",[202,799,801],{"class":800},"sSjpA","%d",[202,803,804],{"class":212},"T%H:%M:%SZ\"",[202,806,408],{"class":256},[202,808,809,811,814,816],{"class":204,"line":297},[202,810,614],{"class":252},[202,812,813],{"class":256}," zipfile.ZipFile(path) ",[202,815,284],{"class":252},[202,817,818],{"class":256}," src:\n",[202,820,821,824,826,829,831,834,836],{"class":204,"line":310},[202,822,823],{"class":256},"        entries ",[202,825,348],{"class":252},[202,827,828],{"class":256}," [(info, src.read(info.filename)) ",[202,830,589],{"class":252},[202,832,833],{"class":256}," info ",[202,835,595],{"class":252},[202,837,838],{"class":256}," src.infolist()]\n",[202,840,841,843,846,849,852,855,857,859],{"class":204,"line":337},[202,842,614],{"class":252},[202,844,845],{"class":256}," zipfile.ZipFile(path, ",[202,847,848],{"class":212},"\"w\"",[202,850,851],{"class":256},", zipfile.",[202,853,854],{"class":319},"ZIP_DEFLATED",[202,856,628],{"class":256},[202,858,284],{"class":252},[202,860,861],{"class":256}," dst:\n",[202,863,864,867,870,872],{"class":204,"line":342},[202,865,866],{"class":252},"        for",[202,868,869],{"class":256}," info, data ",[202,871,595],{"class":252},[202,873,874],{"class":256}," entries:\n",[202,876,877,880,883,886,889],{"class":204,"line":388},[202,878,879],{"class":252},"            if",[202,881,882],{"class":256}," info.filename ",[202,884,885],{"class":252},"==",[202,887,888],{"class":212}," \"docProps\u002Fcore.xml\"",[202,890,891],{"class":256},":\n",[202,893,894,897,899,902,905,908,910,913,915,918,921,923,926,929,932,935,938,940,942,944,947,949,951,953,955,958,961,964,966,969],{"class":204,"line":411},[202,895,896],{"class":256},"                data ",[202,898,348],{"class":252},[202,900,901],{"class":256}," re.sub(",[202,903,904],{"class":252},"rb",[202,906,907],{"class":212},"\"",[202,909,695],{"class":319},[202,911,912],{"class":212},"\u003Cdcterms:",[202,914,695],{"class":319},[202,916,917],{"class":212},"created",[202,919,920],{"class":252},"|",[202,922,727],{"class":212},[202,924,925],{"class":319},")[",[202,927,928],{"class":252},"^",[202,930,931],{"class":800},">",[202,933,934],{"class":319},"]",[202,936,937],{"class":252},"*",[202,939,931],{"class":212},[202,941,925],{"class":319},[202,943,928],{"class":252},[202,945,946],{"class":800},"\u003C",[202,948,934],{"class":319},[202,950,937],{"class":252},[202,952,907],{"class":212},[202,954,363],{"class":256},[202,956,957],{"class":252},"lambda",[202,959,960],{"class":256}," m: m.group(",[202,962,963],{"class":319},"1",[202,965,628],{"class":256},[202,967,968],{"class":252},"+",[202,970,971],{"class":256}," stamp.encode(), data)\n",[202,973,974,977,979,982,985,987,990,993],{"class":204,"line":422},[202,975,976],{"class":256},"            fixed ",[202,978,348],{"class":252},[202,980,981],{"class":256}," zipfile.ZipInfo(info.filename, ",[202,983,984],{"class":399},"date_time",[202,986,348],{"class":252},[202,988,989],{"class":256},"when.timetuple()[:",[202,991,992],{"class":319},"6",[202,994,515],{"class":256},[202,996,997,1000,1002,1005],{"class":204,"line":440},[202,998,999],{"class":256},"            fixed.compress_type ",[202,1001,348],{"class":252},[202,1003,1004],{"class":256}," zipfile.",[202,1006,1007],{"class":319},"ZIP_DEFLATED\n",[202,1009,1010,1013,1015],{"class":204,"line":471},[202,1011,1012],{"class":256},"            fixed.external_attr ",[202,1014,348],{"class":252},[202,1016,1017],{"class":256}," info.external_attr\n",[202,1019,1020],{"class":204,"line":476},[202,1021,1022],{"class":256},"            dst.writestr(fixed, data)\n",[202,1024,1025],{"class":204,"line":498},[202,1026,294],{"emptyLinePlaceholder":293},[202,1028,1030,1033,1035],{"class":204,"line":1029},17,[202,1031,1032],{"class":256},"hashes ",[202,1034,348],{"class":252},[202,1036,1037],{"class":256}," []\n",[202,1039,1041,1043,1045,1047,1049,1052,1054,1057],{"class":204,"line":1040},18,[202,1042,589],{"class":252},[202,1044,592],{"class":256},[202,1046,595],{"class":252},[202,1048,598],{"class":256},[202,1050,1051],{"class":212},"\"op1.xlsx\"",[202,1053,363],{"class":256},[202,1055,1056],{"class":212},"\"op2.xlsx\"",[202,1058,609],{"class":256},[202,1060,1062,1065,1067],{"class":204,"line":1061},19,[202,1063,1064],{"class":256},"    wb ",[202,1066,348],{"class":252},[202,1068,1069],{"class":256}," Workbook()\n",[202,1071,1073,1076,1078,1080,1082],{"class":204,"line":1072},20,[202,1074,1075],{"class":256},"    wb.active.append([",[202,1077,354],{"class":212},[202,1079,363],{"class":256},[202,1081,372],{"class":212},[202,1083,515],{"class":256},[202,1085,1087,1089,1091,1093,1095],{"class":204,"line":1086},21,[202,1088,1075],{"class":256},[202,1090,360],{"class":212},[202,1092,363],{"class":256},[202,1094,377],{"class":319},[202,1096,515],{"class":256},[202,1098,1100],{"class":204,"line":1099},22,[202,1101,1102],{"class":256},"    wb.save(name)\n",[202,1104,1106,1109,1111],{"class":204,"line":1105},23,[202,1107,1108],{"class":256},"    before ",[202,1110,348],{"class":252},[202,1112,1113],{"class":256}," digest(name)\n",[202,1115,1117],{"class":204,"line":1116},24,[202,1118,1119],{"class":256},"    normalise_xlsx(name)\n",[202,1121,1123],{"class":204,"line":1122},25,[202,1124,1125],{"class":256},"    hashes.append((before, digest(name)))\n",[202,1127,1129,1131,1133],{"class":204,"line":1128},26,[202,1130,684],{"class":256},[202,1132,417],{"class":319},[202,1134,408],{"class":256},[202,1136,1138,1140,1142,1145,1148,1150,1153,1155,1158,1161,1164,1166,1169,1171],{"class":204,"line":1137},27,[202,1139,443],{"class":319},[202,1141,695],{"class":256},[202,1143,1144],{"class":212},"\"openpyxl before:\"",[202,1146,1147],{"class":256},", [b ",[202,1149,589],{"class":252},[202,1151,1152],{"class":256}," b, _ ",[202,1154,595],{"class":252},[202,1156,1157],{"class":256}," hashes], ",[202,1159,1160],{"class":212},"\"after:\"",[202,1162,1163],{"class":256},", [a ",[202,1165,589],{"class":252},[202,1167,1168],{"class":256}," _, a ",[202,1170,595],{"class":252},[202,1172,1173],{"class":256}," hashes])\n",[10,1175,1176,1177,1180],{},"Before normalising, the two saves differ; afterwards they match. The rewrite preserves entry order and content, and the file opens normally. Call it as the last step after ",[199,1178,1179],{},"wb.save()"," — including when pandas writes through the openpyxl engine.",[20,1182,29,1187,29,1190,29,1193,29,1196,29,1202,29,1207,29,1213,29,1216,29,1220,29,1225,29,1231,29,1233,29,1237,29,1239,29,1242,29,1245,29,1249,29,1251,29,1255,29,1257,29,1260,29,1263,29,1267,29,1269,29,1273,29,1275],{"viewBox":1183,"role":23,"ariaLabelledBy":1184,"xmlns":27,"style":28},"4 0 752 174",[1185,1186],"rep-libs-t","rep-libs-d",[31,1188,1189],{"id":1185},"What each library leaves varying, and the fix",[35,1191,1192],{"id":1186},"A table of writers. xlsxwriter writes fixed zip entry dates but the current created time; fix by setting created with set_properties. openpyxl writes the current time into both the modified property and every zip entry, and overrides a modified value you set; fix by normalising the archive after saving. pandas to_excel inherits the behaviour of whichever engine it uses.",[39,1194],{"x":41,"y":42,"width":43,"height":1195,"fill":45},"174",[39,1197],{"x":48,"y":82,"width":1198,"height":1199,"rx":42,"fill":45,"stroke":1200,"style":1201},"132.1","40.0","var(--line,#cdd5e6)","stroke-width:1px",[39,1203],{"x":1204,"y":82,"width":1205,"height":1199,"rx":42,"fill":53,"stroke":54,"style":1206},"152.1","294.0","stroke-width:1.2px",[68,1208,1212],{"x":1209,"y":1210,"style":1211},"299.0","41.0","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Varies between runs",[39,1214],{"x":1215,"y":82,"width":1205,"height":1199,"rx":42,"fill":53,"stroke":54,"style":1206},"446.0",[68,1217,1219],{"x":1218,"y":1210,"style":1211},"593.0","Fix",[39,1221],{"x":48,"y":1222,"width":1198,"height":1223,"rx":42,"fill":1224,"stroke":1200,"style":1201},"56.0","34.0","#e7ebef",[68,1226,1230],{"x":1227,"y":1228,"style":1229},"32.0","78.0","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:start","xlsxwriter",[39,1232],{"x":1204,"y":1222,"width":1205,"height":1223,"rx":42,"fill":45,"stroke":1200,"style":1201},[68,1234,1236],{"x":1209,"y":1235,"style":77},"77.0","created timestamp",[39,1238],{"x":1215,"y":1222,"width":1205,"height":1223,"rx":42,"fill":45,"stroke":1200,"style":1201},[68,1240,1241],{"x":1218,"y":1235,"style":77},"set_properties(created=)",[39,1243],{"x":48,"y":1244,"width":1198,"height":1223,"rx":42,"fill":1224,"stroke":1200,"style":1201},"90.0",[68,1246,1248],{"x":1227,"y":1247,"style":1229},"112.0","openpyxl",[39,1250],{"x":1204,"y":1244,"width":1205,"height":1223,"rx":42,"fill":45,"stroke":1200,"style":1201},[68,1252,1254],{"x":1209,"y":1253,"style":77},"111.0","modified time, zip entry dates",[39,1256],{"x":1215,"y":1244,"width":1205,"height":1223,"rx":42,"fill":45,"stroke":1200,"style":1201},[68,1258,1259],{"x":1218,"y":1253,"style":77},"normalise after save",[39,1261],{"x":48,"y":1262,"width":1198,"height":1223,"rx":42,"fill":1224,"stroke":1200,"style":1201},"124.0",[68,1264,1266],{"x":1227,"y":1265,"style":1229},"146.0","pandas to_excel",[39,1268],{"x":1204,"y":1262,"width":1205,"height":1223,"rx":42,"fill":45,"stroke":1200,"style":1201},[68,1270,1272],{"x":1209,"y":1271,"style":77},"145.0","whatever its engine does",[39,1274],{"x":1215,"y":1262,"width":1205,"height":1223,"rx":42,"fill":45,"stroke":1200,"style":1201},[68,1276,1277],{"x":1218,"y":1271,"style":77},"fix the engine",[187,1279,1281],{"id":1280},"step-4-remove-the-differences-your-own-code-makes","Step 4 — Remove the differences your own code makes",[10,1283,1284],{},"Fixed metadata only helps if the content is the same too. The usual culprits in a report script are the order files are read in, iteration over sets, random numbers and reading the clock. Compare an order-dependent version with a deterministic one:",[192,1286,1288],{"className":243,"code":1287,"language":245,"meta":197,"style":197},"from pathlib import Path\n\ndef combine(paths):\n    return pd.concat([pd.read_excel(p) for p in paths], ignore_index=True)\n\nlisted = list(Path(\"inputs\").glob(\"*.xlsx\"))\nprint(\"as listed:\", [p.name for p in listed])\nprint(\"sorted:   \", [p.name for p in sorted(listed)])\n\nreport = combine(sorted(Path(\"inputs\").glob(\"*.xlsx\"))).sort_values([\"Region\", \"Customer\"], ignore_index=True)\nregions = sorted(set(report[\"Region\"]))\nprint(report.to_string(index=False))\nprint(\"regions:\", regions)\n",[199,1289,1290,1302,1306,1316,1343,1347,1371,1392,1415,1419,1460,1482,1497],{"__ignoreMap":197},[202,1291,1292,1294,1297,1299],{"class":204,"line":205},[202,1293,547],{"class":252},[202,1295,1296],{"class":256}," pathlib ",[202,1298,253],{"class":252},[202,1300,1301],{"class":256}," Path\n",[202,1303,1304],{"class":204,"line":260},[202,1305,294],{"emptyLinePlaceholder":293},[202,1307,1308,1310,1313],{"class":204,"line":268},[202,1309,300],{"class":252},[202,1311,1312],{"class":303}," combine",[202,1314,1315],{"class":256},"(paths):\n",[202,1317,1318,1320,1323,1325,1328,1330,1333,1336,1338,1341],{"class":204,"line":276},[202,1319,313],{"class":252},[202,1321,1322],{"class":256}," pd.concat([pd.read_excel(p) ",[202,1324,589],{"class":252},[202,1326,1327],{"class":256}," p ",[202,1329,595],{"class":252},[202,1331,1332],{"class":256}," paths], ",[202,1334,1335],{"class":399},"ignore_index",[202,1337,348],{"class":252},[202,1339,1340],{"class":319},"True",[202,1342,408],{"class":256},[202,1344,1345],{"class":204,"line":290},[202,1346,294],{"emptyLinePlaceholder":293},[202,1348,1349,1352,1354,1357,1360,1363,1366,1369],{"class":204,"line":297},[202,1350,1351],{"class":256},"listed ",[202,1353,348],{"class":252},[202,1355,1356],{"class":319}," list",[202,1358,1359],{"class":256},"(Path(",[202,1361,1362],{"class":212},"\"inputs\"",[202,1364,1365],{"class":256},").glob(",[202,1367,1368],{"class":212},"\"*.xlsx\"",[202,1370,468],{"class":256},[202,1372,1373,1375,1377,1380,1383,1385,1387,1389],{"class":204,"line":310},[202,1374,443],{"class":319},[202,1376,695],{"class":256},[202,1378,1379],{"class":212},"\"as listed:\"",[202,1381,1382],{"class":256},", [p.name ",[202,1384,589],{"class":252},[202,1386,1327],{"class":256},[202,1388,595],{"class":252},[202,1390,1391],{"class":256}," listed])\n",[202,1393,1394,1396,1398,1401,1403,1405,1407,1409,1412],{"class":204,"line":337},[202,1395,443],{"class":319},[202,1397,695],{"class":256},[202,1399,1400],{"class":212},"\"sorted:   \"",[202,1402,1382],{"class":256},[202,1404,589],{"class":252},[202,1406,1327],{"class":256},[202,1408,595],{"class":252},[202,1410,1411],{"class":319}," sorted",[202,1413,1414],{"class":256},"(listed)])\n",[202,1416,1417],{"class":204,"line":342},[202,1418,294],{"emptyLinePlaceholder":293},[202,1420,1421,1424,1426,1429,1432,1434,1436,1438,1440,1443,1445,1447,1450,1452,1454,1456,1458],{"class":204,"line":388},[202,1422,1423],{"class":256},"report ",[202,1425,348],{"class":252},[202,1427,1428],{"class":256}," combine(",[202,1430,1431],{"class":319},"sorted",[202,1433,1359],{"class":256},[202,1435,1362],{"class":212},[202,1437,1365],{"class":256},[202,1439,1368],{"class":212},[202,1441,1442],{"class":256},"))).sort_values([",[202,1444,354],{"class":212},[202,1446,363],{"class":256},[202,1448,1449],{"class":212},"\"Customer\"",[202,1451,369],{"class":256},[202,1453,1335],{"class":399},[202,1455,348],{"class":252},[202,1457,1340],{"class":319},[202,1459,408],{"class":256},[202,1461,1462,1465,1467,1469,1471,1474,1477,1479],{"class":204,"line":411},[202,1463,1464],{"class":256},"regions ",[202,1466,348],{"class":252},[202,1468,1411],{"class":319},[202,1470,695],{"class":256},[202,1472,1473],{"class":319},"set",[202,1475,1476],{"class":256},"(report[",[202,1478,354],{"class":212},[202,1480,1481],{"class":256},"]))\n",[202,1483,1484,1486,1489,1491,1493,1495],{"class":204,"line":422},[202,1485,443],{"class":319},[202,1487,1488],{"class":256},"(report.to_string(",[202,1490,400],{"class":399},[202,1492,348],{"class":252},[202,1494,405],{"class":319},[202,1496,468],{"class":256},[202,1498,1499,1501,1503,1506],{"class":204,"line":440},[202,1500,443],{"class":319},[202,1502,695],{"class":256},[202,1504,1505],{"class":212},"\"regions:\"",[202,1507,1508],{"class":256},", regions)\n",[10,1510,1511,1514,1515,1518,1519,1522,1523,1526,1527,1531],{},[199,1512,1513],{},"glob"," returns files in the order the file system stores them, which differs between machines and can change when files are rewritten; sort the list. Sort the combined data by a key that is unique per row, so the output order depends only on the data. Sort sets before iterating — string hashing is randomised per process, so iteration order over a set of strings changes from run to run. Setting ",[199,1516,1517],{},"PYTHONHASHSEED"," would also fix the order, but sorting states the intent in the code and works however the script is started. Seed any randomness (",[199,1520,1521],{},"np.random.default_rng(42)","), and pass the report date in rather than calling ",[199,1524,1525],{},"datetime.now()",", as in ",[14,1528,1530],{"href":1529},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fbackfill-excel-reports-for-past-dates\u002F","Backfill Excel Reports for Past Dates",".",[187,1533,1535],{"id":1534},"step-5-prove-it-in-a-test","Step 5 — Prove it in a test",[10,1537,1538],{},"The check that keeps it reproducible is a test that builds the report twice and compares hashes:",[192,1540,1542],{"className":243,"code":1541,"language":245,"meta":197,"style":197},"def build_report(out, as_of=datetime(2026, 9, 30)):\n    data = combine(sorted(Path(\"inputs\").glob(\"*.xlsx\"))).sort_values([\"Region\", \"Customer\"], ignore_index=True)\n    with pd.ExcelWriter(out, engine=\"xlsxwriter\") as writer:\n        writer.book.set_properties({\"created\": as_of})\n        data.to_excel(writer, sheet_name=\"Sales\", index=False)\n    return out\n\nfirst = digest(build_report(\"report_a.xlsx\"))\ntime.sleep(1.1)\nsecond = digest(build_report(\"report_b.xlsx\"))\nassert first == second, \"report output is not reproducible\"\nprint(\"reproducible:\", first == second, first)\n",[199,1543,1544,1570,1607,1626,1635,1658,1665,1669,1684,1692,1706,1722],{"__ignoreMap":197},[202,1545,1546,1548,1551,1554,1556,1558,1560,1562,1564,1566,1568],{"class":204,"line":205},[202,1547,300],{"class":252},[202,1549,1550],{"class":303}," build_report",[202,1552,1553],{"class":256},"(out, as_of",[202,1555,348],{"class":252},[202,1557,771],{"class":256},[202,1559,572],{"class":319},[202,1561,363],{"class":256},[202,1563,577],{"class":319},[202,1565,363],{"class":256},[202,1567,582],{"class":319},[202,1569,784],{"class":256},[202,1571,1572,1575,1577,1579,1581,1583,1585,1587,1589,1591,1593,1595,1597,1599,1601,1603,1605],{"class":204,"line":260},[202,1573,1574],{"class":256},"    data ",[202,1576,348],{"class":252},[202,1578,1428],{"class":256},[202,1580,1431],{"class":319},[202,1582,1359],{"class":256},[202,1584,1362],{"class":212},[202,1586,1365],{"class":256},[202,1588,1368],{"class":212},[202,1590,1442],{"class":256},[202,1592,354],{"class":212},[202,1594,363],{"class":256},[202,1596,1449],{"class":212},[202,1598,369],{"class":256},[202,1600,1335],{"class":399},[202,1602,348],{"class":252},[202,1604,1340],{"class":319},[202,1606,408],{"class":256},[202,1608,1609,1611,1614,1616,1618,1620,1622,1624],{"class":204,"line":268},[202,1610,614],{"class":252},[202,1612,1613],{"class":256}," pd.ExcelWriter(out, ",[202,1615,620],{"class":399},[202,1617,348],{"class":252},[202,1619,625],{"class":212},[202,1621,628],{"class":256},[202,1623,284],{"class":252},[202,1625,633],{"class":256},[202,1627,1628,1630,1632],{"class":204,"line":276},[202,1629,638],{"class":256},[202,1631,641],{"class":212},[202,1633,1634],{"class":256},": as_of})\n",[202,1636,1637,1640,1643,1645,1648,1650,1652,1654,1656],{"class":204,"line":290},[202,1638,1639],{"class":256},"        data.to_excel(writer, ",[202,1641,1642],{"class":399},"sheet_name",[202,1644,348],{"class":252},[202,1646,1647],{"class":212},"\"Sales\"",[202,1649,363],{"class":256},[202,1651,400],{"class":399},[202,1653,348],{"class":252},[202,1655,405],{"class":319},[202,1657,408],{"class":256},[202,1659,1660,1662],{"class":204,"line":297},[202,1661,313],{"class":252},[202,1663,1664],{"class":256}," out\n",[202,1666,1667],{"class":204,"line":310},[202,1668,294],{"emptyLinePlaceholder":293},[202,1670,1671,1674,1676,1679,1682],{"class":204,"line":337},[202,1672,1673],{"class":256},"first ",[202,1675,348],{"class":252},[202,1677,1678],{"class":256}," digest(build_report(",[202,1680,1681],{"class":212},"\"report_a.xlsx\"",[202,1683,468],{"class":256},[202,1685,1686,1688,1690],{"class":204,"line":342},[202,1687,414],{"class":256},[202,1689,417],{"class":319},[202,1691,408],{"class":256},[202,1693,1694,1697,1699,1701,1704],{"class":204,"line":388},[202,1695,1696],{"class":256},"second ",[202,1698,348],{"class":252},[202,1700,1678],{"class":256},[202,1702,1703],{"class":212},"\"report_b.xlsx\"",[202,1705,468],{"class":256},[202,1707,1708,1711,1714,1716,1719],{"class":204,"line":411},[202,1709,1710],{"class":252},"assert",[202,1712,1713],{"class":256}," first ",[202,1715,885],{"class":252},[202,1717,1718],{"class":256}," second, ",[202,1720,1721],{"class":212},"\"report output is not reproducible\"\n",[202,1723,1724,1726,1728,1731,1734,1736],{"class":204,"line":422},[202,1725,443],{"class":319},[202,1727,695],{"class":256},[202,1729,1730],{"class":212},"\"reproducible:\"",[202,1732,1733],{"class":256},", first ",[202,1735,885],{"class":252},[202,1737,1738],{"class":256}," second, first)\n",[10,1740,1741,1742,1745,1746,1750],{},"In pytest, put the same assertion in a test function that writes to ",[199,1743,1744],{},"tmp_path",". When it fails, unzip both files and diff the XML parts — the part that differs names the cause. ",[14,1747,1749],{"href":1748},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcompare-excel-output-to-a-golden-file-in-tests\u002F","Compare Excel Output to a Golden File in Tests"," takes the next step: comparing against a stored known-good file.",[20,1752,29,1757,29,1760,29,1763,29,1766,29,1771,29,1778,29,1783,29,1788,29,1793,29,1800,29,1804,29,1809,29,1813,29,1818,29,1823,29,1830,29,1835,29,1839,29,1843,29,1848,29,1851,29,1856,29,1859],{"viewBox":1753,"role":23,"ariaLabelledBy":1754,"xmlns":27,"style":28},"4 0 752 207",[1755,1756],"rep-before-after-t","rep-before-after-d",[31,1758,1759],{"id":1755},"Two runs of the same report, before and after",[35,1761,1762],{"id":1756},"Two panels. Before: different hashes every run, a change check that always reports a change, and backups of identical reports. After: identical hashes for identical inputs, a change check that fires only when the data changes, and one stored copy per distinct report.",[39,1764],{"x":41,"y":42,"width":43,"height":1765,"fill":45},"207",[39,1767],{"x":48,"y":82,"width":1768,"height":1769,"rx":1770,"fill":139,"stroke":140,"style":55},"351.0","175.0","14",[57,1772,1774],{"transform":1773},"translate(182.5,29.0) scale(1.083)",[61,1775],{"d":1776,"fill":64,"stroke":147,"style":1777},"M12 3.5l9.5 17h-19z M12 10v5 M12 17.8v.4","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[68,1779,1782],{"x":1780,"y":1228,"style":1781},"195.5","font-size:14px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Before",[204,1784],{"x1":1785,"y1":1786,"x2":1787,"y2":1786,"stroke":140,"style":1201},"36.0","91.0","355.0",[68,1789,1792],{"x":1790,"y":1253,"style":1791},"60.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:start","new hash every run",[57,1794,1796],{"transform":1795},"translate(38.5,99.5) scale(0.625)",[61,1797],{"d":1798,"fill":64,"stroke":140,"style":1799},"M6 6l12 12 M18 6L6 18","stroke-width:2.88px;stroke-linecap:round;stroke-linejoin:round",[68,1801,1803],{"x":1790,"y":1802,"style":1791},"137.0","\"changed\" every day",[57,1805,1807],{"transform":1806},"translate(38.5,125.5) scale(0.625)",[61,1808],{"d":1798,"fill":64,"stroke":140,"style":1799},[68,1810,1812],{"x":1790,"y":1811,"style":1791},"163.0","duplicate backups",[57,1814,1816],{"transform":1815},"translate(38.5,151.5) scale(0.625)",[61,1817],{"d":1798,"fill":64,"stroke":140,"style":1799},[39,1819],{"x":1820,"y":82,"width":1768,"height":1769,"rx":1770,"fill":1821,"stroke":1822,"style":55},"389.0","#d9f4f1","var(--teal,#0f9488)",[57,1824,1826],{"transform":1825},"translate(551.5,29.0) scale(1.083)",[61,1827],{"d":1828,"fill":64,"stroke":1829,"style":1777},"M5 12.5l4.5 4.5L19 7","var(--teal-ink,#0b6157)",[68,1831,1834],{"x":1832,"y":1228,"style":1833},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","After",[204,1836],{"x1":1837,"y1":1786,"x2":1838,"y2":1786,"stroke":1822,"style":1201},"405.0","724.0",[68,1840,1842],{"x":1841,"y":1253,"style":1791},"429.0","same inputs, same hash",[57,1844,1846],{"transform":1845},"translate(407.5,99.5) scale(0.625)",[61,1847],{"d":1828,"fill":64,"stroke":1822,"style":1799},[68,1849,1850],{"x":1841,"y":1802,"style":1791},"changes mean data changed",[57,1852,1854],{"transform":1853},"translate(407.5,125.5) scale(0.625)",[61,1855],{"d":1828,"fill":64,"stroke":1822,"style":1799},[68,1857,1858],{"x":1841,"y":1811,"style":1791},"one copy per real change",[57,1860,1862],{"transform":1861},"translate(407.5,151.5) scale(0.625)",[61,1863],{"d":1828,"fill":64,"stroke":1822,"style":1799},[187,1865,1867],{"id":1866},"across-machines-compare-content-rather-than-bytes","Across machines, compare content rather than bytes",[10,1869,1870,1871,1874],{},"Byte-identical output is reliable on one machine with fixed library versions. Across machines it can still differ for reasons outside your code: a different zlib build may compress the same XML to different bytes, and a different openpyxl or xlsxwriter version may write attributes in another order. When a check has to work between a laptop and a server, hash the ",[725,1872,1873],{},"uncompressed"," parts instead — read each entry of the zip in name order and hash its contents — which ignores compression differences while still catching every real change. Pinning library versions in the project's requirements removes the second cause.",[187,1876,1878],{"id":1877},"what-reproducibility-buys","What reproducibility buys",[10,1880,1881,1882,1886],{},"Once identical inputs give identical files, a hash becomes a reliable \"did anything change?\" signal. A scheduled job can skip re-sending a report whose hash matches yesterday's — ",[14,1883,1885],{"href":1884},"\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"," applies the same idea to inputs. A rerun of a closed month can be proved to match what was sent. And upgrading pandas or openpyxl becomes testable: rebuild last month's reports and see which, if any, change.",[187,1888,1890],{"id":1889},"common-pitfalls","Common pitfalls",[1892,1893,1894,1902,1912,1921,1927,1935],"ul",{},[1895,1896,1897,1901],"li",{},[1898,1899,1900],"strong",{},"Setting openpyxl's modified property and expecting it to stick."," It is overwritten on save; normalise afterwards.",[1895,1903,1904,1907,1908,1911],{},[1898,1905,1906],{},"Forgetting zip entry dates."," Fixing ",[199,1909,1910],{},"core.xml"," alone leaves the archive different.",[1895,1913,1914,1920],{},[1898,1915,1916,1917,1919],{},"Unsorted ",[199,1918,1513],{}," results."," Sort file lists before reading.",[1895,1922,1923,1926],{},[1898,1924,1925],{},"Iterating over sets of strings."," Their order changes between processes; sort first.",[1895,1928,1929,1934],{},[1898,1930,1931,1933],{},[199,1932,1525],{}," in content."," Titles and \"generated at\" cells change every run; use the as-of date, or accept and exclude that one cell from comparisons.",[1895,1936,1937,1940],{},[1898,1938,1939],{},"Library upgrades."," A new version can change the XML it writes; pin versions for reports that must be identical.",[187,1942,1944],{"id":1943},"performance-notes","Performance notes",[10,1946,1947],{},"Normalising rewrites the zip once, which costs about as much as writing it; negligible for report-sized files. Hashing is fast even for large workbooks.",[187,1949,1951],{"id":1950},"conclusion","Conclusion",[10,1953,1954,1955,1958],{},"Byte-identical output needs fixed metadata and deterministic content. With xlsxwriter, set the created date with ",[199,1956,1957],{},"set_properties","; with openpyxl, rewrite the saved archive with fixed entry dates and a fixed modified value. In your own code, sort file lists, rows and sets, seed randomness and pass the report date in instead of reading the clock. Guard it with a test that builds the report twice and compares hashes.",[187,1960,1962],{"id":1961},"frequently-asked-questions","Frequently asked questions",[10,1964,1965,1968],{},[1898,1966,1967],{},"Why do two runs of my script produce different xlsx files?","\nAn xlsx file records when it was created and last modified, and some libraries also stamp each entry in the zip archive with the current time. Even with identical data, those timestamps make the files differ byte for byte.",[10,1970,1971,1974],{},[1898,1972,1973],{},"How do I make xlsxwriter output identical every run?","\nSet the created date explicitly by calling set_properties on the workbook with a fixed created datetime. xlsxwriter already writes fixed zip entry dates, so with a fixed created date the same content produces the same bytes.",[10,1976,1977,1980],{},[1898,1978,1979],{},"How do I make openpyxl output reproducible?","\nopenpyxl sets the modified time to the current time on every save and stamps zip entries with it. After saving, rewrite the archive with a fixed date for every entry and a fixed modified value in docProps\u002Fcore.xml.",[10,1982,1983,1986],{},[1898,1984,1985],{},"Why does it matter if the bytes are identical?","\nIdentical inputs giving identical files lets you check a rerun changed nothing with a hash, store only reports that actually changed, avoid re-sending unchanged reports and trust that a regression test compares content rather than timestamps.",[187,1988,1990],{"id":1989},"related","Related",[1892,1992,1993,1999,2004,2009],{},[1895,1994,1995,1996,1998],{},"Up one level: ",[14,1997,17],{"href":16}," — reliable report code.",[1895,2000,2001,2003],{},[14,2002,1749],{"href":1748}," — regression tests on content.",[1895,2005,2006,2008],{},[14,2007,715],{"href":714}," — the metadata involved.",[1895,2010,2011,2013],{},[14,2012,1530],{"href":1529}," — rerunning with confidence.",[2015,2016,2017],"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 .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 .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":197,"searchDepth":260,"depth":260,"links":2019},[2020,2021,2022,2023,2024,2025,2026,2027,2028,2029,2030,2031,2032],{"id":189,"depth":260,"text":190},{"id":236,"depth":260,"text":237},{"id":533,"depth":260,"text":534},{"id":719,"depth":260,"text":720},{"id":1280,"depth":260,"text":1281},{"id":1534,"depth":260,"text":1535},{"id":1866,"depth":260,"text":1867},{"id":1877,"depth":260,"text":1878},{"id":1889,"depth":260,"text":1890},{"id":1943,"depth":260,"text":1944},{"id":1950,"depth":260,"text":1951},{"id":1961,"depth":260,"text":1962},{"id":1989,"depth":260,"text":1990},"2026-09-25","Run the same report twice on the same inputs and get byte-identical workbooks. Find what changes between runs — the created and modified timestamps inside the file, zip entry dates, unsorted inputs, sets, random numbers and the clock — fix each one for xlsxwriter and openpyxl, and prove it with a hash check in a test.","md",[2037,2039,2041,2043],{"q":1967,"a":2038},"An xlsx file records when it was created and last modified, and some libraries also stamp each entry in the zip archive with the current time. Even with identical data, those timestamps make the files differ byte for byte.",{"q":1973,"a":2040},"Set the created date explicitly by calling set_properties on the workbook with a fixed created datetime. xlsxwriter already writes fixed zip entry dates, so with a fixed created date the same content produces the same bytes.",{"q":1979,"a":2042},"openpyxl sets the modified time to the current time on every save and stamps zip entries with it. After saving, rewrite the archive with a fixed date for every entry and a fixed modified value in docProps\u002Fcore.xml.",{"q":1985,"a":2044},"Identical inputs giving identical files lets you check a rerun changed nothing with a hash, store only reports that actually changed, avoid re-sending unchanged reports and trust that a regression test compares content rather than timestamps.","make excel output reproducible",null,{"breadcrumb":2048},[2049,2052,2055],{"name":2050,"item":2051},"Home","\u002F",{"name":2053,"item":2054},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":17,"item":16},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fmake-excel-output-reproducible",{"title":2058,"description":2059},"Make Python Excel Output Byte-for-Byte Reproducible","Produce identical xlsx files on every run: xlsxwriter set_properties created, openpyxl modified timestamps and zip dates, normalising a saved workbook, sorted file lists, deterministic ordering, fixed seeds, as-of dates instead of now, and hash-based tests.","make-excel-output-reproducible","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fmake-excel-output-reproducible\u002Findex",[2063,2064],"reliable-jobs","writing-excel","how-to","D3xpm6D-Z5OEiCjAxsvDlc532RN2OXUAR89lAcjLZFM",[2068,2072],{"title":2069,"path":2070,"stem":2071,"children":-1},"Keep Excel Report Settings in a Config File","\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fkeep-excel-report-settings-in-a-config-file","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fkeep-excel-report-settings-in-a-config-file\u002Findex",{"title":2073,"path":2074,"stem":2075,"children":-1},"Package a Python Excel Script as an EXE with PyInstaller","\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fpackage-a-python-excel-script-as-an-exe-with-pyinstaller","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fpackage-a-python-excel-script-as-an-exe-with-pyinstaller\u002Findex",[2077,2079],{"path":2078,"title":2053},"\u002Fautomating-reporting-workflows",{"path":2080,"title":17},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts",[],[2083,2087,2090,2093,2096,2099],{"path":2084,"title":2085,"kind":2086},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation","Error Handling and Logging in Excel Automation","guide",{"path":2088,"title":2089,"kind":2065},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run","Detect a Scheduled Report That Did Not Run",{"path":2091,"title":2092,"kind":2065},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fignore-excel-lock-files-when-processing-a-folder","Ignore Excel Lock Files When Processing a Folder",{"path":2094,"title":2095,"kind":2065},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Flog-python-excel-script-output-to-a-file","Log Python Excel Script Output to a File",{"path":2097,"title":2098,"kind":2065},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun","Make an Excel Report Job Safe to Rerun",{"path":2100,"title":2101,"kind":2065},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports","Prevent Formula Injection in Excel Exports from Python",1790972142702]