[{"data":1,"prerenderedAt":1804},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fdiff-excel-files-in-git-with-a-textconv-driver":3,"surround:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fdiff-excel-files-in-git-with-a-textconv-driver":1769,"ancestors:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fdiff-excel-files-in-git-with-a-textconv-driver":1778,"children:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fdiff-excel-files-in-git-with-a-textconv-driver":1783,"related:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fdiff-excel-files-in-git-with-a-textconv-driver":1784},{"id":4,"title":5,"body":6,"dateModified":1735,"datePublished":1735,"description":1736,"extension":1737,"faq":1738,"keyword":1747,"kind":1748,"meta":1749,"navigation":321,"path":1758,"seo":1759,"slug":1762,"stem":1763,"topics":1764,"type":1767,"__hash__":1768},"docs\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fdiff-excel-files-in-git-with-a-textconv-driver\u002Findex.md","Diff Excel Files in Git with a textconv Driver",{"type":7,"value":8,"toc":1722},"minimark",[9,40,45,77,91,276,280,287,640,655,670,674,684,836,886,907,911,914,1057,1095,1125,1219,1223,1230,1526,1541,1545,1556,1560,1569,1573,1626,1630,1657,1661,1667,1673,1679,1685,1689,1718],[10,11,12,13,17,18,21,22,26,27,29,30,33,34,39],"p",{},"Report templates, golden output files for tests and small reference workbooks often live in the same repository as the code that uses them. Their history is then useless in review: change one cell in a template and ",[14,15,16],"code",{},"git diff"," says only ",[14,19,20],{},"Binary files a\u002Ftemplate.xlsx and b\u002Ftemplate.xlsx differ",". Git has a mechanism for exactly this — a ",[23,24,25],"em",{},"textconv"," diff driver converts a binary file to text before diffing it. With a thirty-line Python script that prints each cell as a line of text, ",[14,28,16],{},", ",[14,31,32],{},"git log -p"," and pull request reviews show which cells changed on which sheet, including formulas. This page is part of ",[35,36,38],"a",{"href":37},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002F","Testing and Packaging Excel Automation Scripts",".",[41,42,44],"h2",{"id":43},"prerequisites","Prerequisites",[46,47,52],"pre",{"className":48,"code":49,"language":50,"meta":51,"style":51},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install openpyxl\n# git 2.x\n","bash","",[14,53,54,70],{"__ignoreMap":51},[55,56,59,63,67],"span",{"class":57,"line":58},"line",1,[55,60,62],{"class":61},"sMTad","pip",[55,64,66],{"class":65},"srMev"," install",[55,68,69],{"class":65}," openpyxl\n",[55,71,73],{"class":57,"line":72},2,[55,74,76],{"class":75},"s-wDw","# git 2.x\n",[10,78,79,82,83,86,87,90],{},[14,80,81],{},"pricing_template.xlsx"," is a small template with an ",[14,84,85],{},"Inputs"," sheet holding a VAT rate and an exchange rate, and a ",[14,88,89],{},"Calc"," sheet whose gross column is a formula that reads the VAT rate. It will be committed, changed in two ways — an input value and a formula — and diffed.",[92,93,101,102,101,106,101,110,101,118,101,128,101,139,101,145,101,150,101,154,101,160,101,165,101,169,101,176,101,182,101,186,101,190,101,193,101,196,101,202,101,206,101,209,101,212,101,216,101,220,101,223,101,228,101,235,101,240,101,243,101,246,101,250,101,253,101,258,101,265,101,270,101,273],"svg",{"viewBox":94,"role":95,"ariaLabelledBy":96,"xmlns":99,"style":100},"4 0 752 160","img",[97,98],"gd-flow-t","gd-flow-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  ",[103,104,105],"title",{"id":97},"How git diffs a workbook with textconv",[107,108,109],"desc",{"id":98},"For each side of the diff git runs the textconv command on the workbook, the Python script prints one line per cell with sheet, address, value and formula, and git diffs the two text versions line by line.",[111,112],"rect",{"x":113,"y":114,"width":115,"height":116,"fill":117},"4","0","752","160","#ffffff",[111,119],{"x":120,"y":121,"width":122,"height":123,"rx":124,"fill":125,"stroke":126,"style":127},"20.0","16.0","115.2","128.0","12","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[129,130,132],"g",{"transform":131},"translate(65.6,37.8) scale(1.000)",[133,134],"path",{"d":135,"fill":136,"stroke":137,"style":138},"M4 5h16v14H4z M7 9l3 3-3 3 M12 15h5","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[140,141,16],"text",{"x":142,"y":143,"style":144},"77.6","84.2","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle",[140,146,149],{"x":142,"y":147,"style":148},"103.8","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","template.xlsx",[140,151,153],{"x":142,"y":152,"style":148},"121.2","changed",[57,155],{"x1":156,"y1":157,"x2":158,"y2":157,"stroke":159,"style":127},"140.2","80.0","162.2","var(--brand,#5b5cf0)",[161,162],"polygon",{"points":163,"fill":164},"166.2,80.0 157.2,85.0 157.2,75.0","#5b5cf0",[111,166],{"x":167,"y":121,"width":122,"height":123,"rx":124,"fill":168,"stroke":159,"style":127},"171.2","#f0f4ff",[129,170,172],{"transform":171},"translate(216.8,46.5) scale(1.000)",[133,173],{"d":174,"fill":136,"stroke":175,"style":138},"M6 3h9l4 4v14H6z M15 3v4h4","var(--brand-strong,#4338ca)",[140,177,181],{"x":178,"y":179,"style":180},"228.8","93.0","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",".gitattributes",[140,183,185],{"x":178,"y":184,"style":148},"112.5","*.xlsx diff=excel",[57,187],{"x1":188,"y1":157,"x2":189,"y2":157,"stroke":159,"style":127},"291.4","313.4",[161,191],{"points":192,"fill":164},"317.4,80.0 308.4,85.0 308.4,75.0",[111,194],{"x":195,"y":121,"width":122,"height":123,"rx":124,"fill":168,"stroke":159,"style":127},"322.4",[129,197,199],{"transform":198},"translate(368.0,29.0) scale(1.000)",[133,200],{"d":201,"fill":136,"stroke":175,"style":138},"M12 3c-4 0-4 1.6-4 3v2h4.2v1H6c-2 0-3 1.4-3 4s1 4 3 4h2v-2.6c0-1.6 1.3-2.9 2.9-2.9h4.2c1.3 0 2.4-1.1 2.4-2.4V6c0-1.6-1.8-3-5.5-3z M10 5.5h.01 M12 21c4 0 4-1.6 4-3v-2h-4.2v-1H18c2 0 3-1.4 3-4s-1-4-3-4h-2v2.6c0 1.6-1.3 2.9-2.9 2.9H8.9c-1.3 0-2.4 1.1-2.4 2.4V18c0 1.6 1.8 3 5.5 3z M14 18.5h.01",[140,203,25],{"x":204,"y":205,"style":180},"380.0","75.5",[140,207,208],{"x":204,"y":179,"style":180},"script",[140,210,211],{"x":204,"y":184,"style":148},"python",[140,213,215],{"x":204,"y":214,"style":148},"130.0","xlsx2text.py",[57,217],{"x1":218,"y1":157,"x2":219,"y2":157,"stroke":159,"style":127},"442.6","464.6",[161,221],{"points":222,"fill":164},"468.6,80.0 459.6,85.0 459.6,75.0",[111,224],{"x":225,"y":121,"width":122,"height":123,"rx":124,"fill":226,"stroke":227,"style":127},"473.6","#d9f4f1","var(--teal,#0f9488)",[129,229,231],{"transform":230},"translate(519.2,37.8) scale(1.000)",[133,232],{"d":233,"fill":136,"stroke":234,"style":138},"M4 4h16v16H4z M4 9h16 M4 14h16 M10 4v16","var(--teal-ink,#0b6157)",[140,236,239],{"x":237,"y":143,"style":238},"531.2","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Text per cell",[140,241,242],{"x":237,"y":147,"style":148},"Sheet!A1 =",[140,244,245],{"x":237,"y":152,"style":148},"value",[57,247],{"x1":248,"y1":157,"x2":249,"y2":157,"stroke":159,"style":127},"593.8","615.8",[161,251],{"points":252,"fill":164},"619.8,80.0 610.8,85.0 610.8,75.0",[111,254],{"x":255,"y":121,"width":122,"height":123,"rx":124,"fill":256,"stroke":257,"style":127},"624.8","#fdefd8","var(--gold,#b4740a)",[129,259,261],{"transform":260},"translate(670.4,37.8) scale(1.000)",[133,262],{"d":263,"fill":136,"stroke":264,"style":138},"M11 4a7 7 0 1 0 .01 0z M16 16l5 5","var(--gold-ink,#7a4e06)",[140,266,269],{"x":267,"y":143,"style":268},"682.4","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Line diff",[140,271,272],{"x":267,"y":147,"style":148},"changed cells",[140,274,275],{"x":267,"y":152,"style":148},"only",[41,277,279],{"id":278},"step-1-the-converter","Step 1 — The converter",[10,281,282,283,286],{},"The converter prints one line per non-empty cell: sheet and address, then the formula if there is one, otherwise the value. Loading without ",[14,284,285],{},"data_only"," keeps formulas as text, which is what a reviewer needs to see:",[46,288,291],{"className":289,"code":290,"language":211,"meta":51,"style":51},"language-python shiki shiki-themes github-light github-dark-high-contrast","import sys\nfrom openpyxl import load_workbook\n\ndef dump(path, out=sys.stdout):\n    wb = load_workbook(path, data_only=False, read_only=False)\n    for ws in wb.worksheets:\n        out.write(f\"=== {ws.title} ({ws.sheet_state}) ===\\n\")\n        for row in ws.iter_rows():\n            for cell in row:\n                if cell.value is None:\n                    continue\n                value = cell.value\n                if isinstance(value, float):\n                    value = repr(value)\n                out.write(f\"{ws.title}!{cell.coordinate}\\t{value}\\n\")\n    for name, dn in wb.defined_names.items():\n        out.write(f\"name {name} = {dn.attr_text}\\n\")\n\nif __name__ == \"__main__\":\n    dump(sys.argv[1])\n",[14,292,293,303,316,323,342,374,389,432,446,460,478,484,495,512,526,562,575,606,611,628],{"__ignoreMap":51},[55,294,295,299],{"class":57,"line":58},[55,296,298],{"class":297},"s-kum","import",[55,300,302],{"class":301},"skGVy"," sys\n",[55,304,305,308,311,313],{"class":57,"line":72},[55,306,307],{"class":297},"from",[55,309,310],{"class":301}," openpyxl ",[55,312,298],{"class":297},[55,314,315],{"class":301}," load_workbook\n",[55,317,319],{"class":57,"line":318},3,[55,320,322],{"emptyLinePlaceholder":321},true,"\n",[55,324,326,329,333,336,339],{"class":57,"line":325},4,[55,327,328],{"class":297},"def",[55,330,332],{"class":331},"s_Opv"," dump",[55,334,335],{"class":301},"(path, out",[55,337,338],{"class":297},"=",[55,340,341],{"class":301},"sys.stdout):\n",[55,343,345,348,350,353,356,358,362,364,367,369,371],{"class":57,"line":344},5,[55,346,347],{"class":301},"    wb ",[55,349,338],{"class":297},[55,351,352],{"class":301}," load_workbook(path, ",[55,354,285],{"class":355},"sa561",[55,357,338],{"class":297},[55,359,361],{"class":360},"sP0c6","False",[55,363,29],{"class":301},[55,365,366],{"class":355},"read_only",[55,368,338],{"class":297},[55,370,361],{"class":360},[55,372,373],{"class":301},")\n",[55,375,377,380,383,386],{"class":57,"line":376},6,[55,378,379],{"class":297},"    for",[55,381,382],{"class":301}," ws ",[55,384,385],{"class":297},"in",[55,387,388],{"class":301}," wb.worksheets:\n",[55,390,392,395,398,401,405,408,411,414,416,419,421,424,427,430],{"class":57,"line":391},7,[55,393,394],{"class":301},"        out.write(",[55,396,397],{"class":297},"f",[55,399,400],{"class":65},"\"=== ",[55,402,404],{"class":403},"sSjpA","{",[55,406,407],{"class":301},"ws.title",[55,409,410],{"class":403},"}",[55,412,413],{"class":65}," (",[55,415,404],{"class":403},[55,417,418],{"class":301},"ws.sheet_state",[55,420,410],{"class":403},[55,422,423],{"class":65},") ===",[55,425,426],{"class":403},"\\n",[55,428,429],{"class":65},"\"",[55,431,373],{"class":301},[55,433,435,438,441,443],{"class":57,"line":434},8,[55,436,437],{"class":297},"        for",[55,439,440],{"class":301}," row ",[55,442,385],{"class":297},[55,444,445],{"class":301}," ws.iter_rows():\n",[55,447,449,452,455,457],{"class":57,"line":448},9,[55,450,451],{"class":297},"            for",[55,453,454],{"class":301}," cell ",[55,456,385],{"class":297},[55,458,459],{"class":301}," row:\n",[55,461,463,466,469,472,475],{"class":57,"line":462},10,[55,464,465],{"class":297},"                if",[55,467,468],{"class":301}," cell.value ",[55,470,471],{"class":297},"is",[55,473,474],{"class":360}," None",[55,476,477],{"class":301},":\n",[55,479,481],{"class":57,"line":480},11,[55,482,483],{"class":297},"                    continue\n",[55,485,487,490,492],{"class":57,"line":486},12,[55,488,489],{"class":301},"                value ",[55,491,338],{"class":297},[55,493,494],{"class":301}," cell.value\n",[55,496,498,500,503,506,509],{"class":57,"line":497},13,[55,499,465],{"class":297},[55,501,502],{"class":360}," isinstance",[55,504,505],{"class":301},"(value, ",[55,507,508],{"class":360},"float",[55,510,511],{"class":301},"):\n",[55,513,515,518,520,523],{"class":57,"line":514},14,[55,516,517],{"class":301},"                    value ",[55,519,338],{"class":297},[55,521,522],{"class":360}," repr",[55,524,525],{"class":301},"(value)\n",[55,527,529,532,534,536,538,540,542,545,547,550,553,555,558,560],{"class":57,"line":528},15,[55,530,531],{"class":301},"                out.write(",[55,533,397],{"class":297},[55,535,429],{"class":65},[55,537,404],{"class":403},[55,539,407],{"class":301},[55,541,410],{"class":403},[55,543,544],{"class":65},"!",[55,546,404],{"class":403},[55,548,549],{"class":301},"cell.coordinate",[55,551,552],{"class":403},"}\\t{",[55,554,245],{"class":301},[55,556,557],{"class":403},"}\\n",[55,559,429],{"class":65},[55,561,373],{"class":301},[55,563,565,567,570,572],{"class":57,"line":564},16,[55,566,379],{"class":297},[55,568,569],{"class":301}," name, dn ",[55,571,385],{"class":297},[55,573,574],{"class":301}," wb.defined_names.items():\n",[55,576,578,580,582,585,587,590,592,595,597,600,602,604],{"class":57,"line":577},17,[55,579,394],{"class":301},[55,581,397],{"class":297},[55,583,584],{"class":65},"\"name ",[55,586,404],{"class":403},[55,588,589],{"class":301},"name",[55,591,410],{"class":403},[55,593,594],{"class":65}," = ",[55,596,404],{"class":403},[55,598,599],{"class":301},"dn.attr_text",[55,601,557],{"class":403},[55,603,429],{"class":65},[55,605,373],{"class":301},[55,607,609],{"class":57,"line":608},18,[55,610,322],{"emptyLinePlaceholder":321},[55,612,614,617,620,623,626],{"class":57,"line":613},19,[55,615,616],{"class":297},"if",[55,618,619],{"class":360}," __name__",[55,621,622],{"class":297}," ==",[55,624,625],{"class":65}," \"__main__\"",[55,627,477],{"class":301},[55,629,631,634,637],{"class":57,"line":630},20,[55,632,633],{"class":301},"    dump(sys.argv[",[55,635,636],{"class":360},"1",[55,638,639],{"class":301},"])\n",[46,641,643],{"className":48,"code":642,"language":50,"meta":51,"style":51},"python xlsx2text.py pricing_template.xlsx\n",[14,644,645],{"__ignoreMap":51},[55,646,647,649,652],{"class":57,"line":58},[55,648,211],{"class":61},[55,650,651],{"class":65}," xlsx2text.py",[55,653,654],{"class":65}," pricing_template.xlsx\n",[10,656,657,658,661,662,665,666,669],{},"Every line carries its own sheet and address, so a change shows up as exactly one changed line — and a line diff can never pair a cell on one sheet with a cell on another. Floats are printed with ",[14,659,660],{},"repr"," so ",[14,663,664],{},"0.2"," and ",[14,667,668],{},"0.20000000000000001"," are distinguishable. The sheet header includes the sheet's state, so hiding a sheet shows up in the diff too; defined names are listed at the end because a changed named range changes what formulas compute.",[41,671,673],{"id":672},"step-2-tell-git-to-use-it","Step 2 — Tell git to use it",[10,675,676,677,679,680,683],{},"Two pieces of configuration: ",[14,678,181],{},", which is committed, says which files use the ",[14,681,682],{},"excel"," diff driver; the driver's command is set in git configuration, which each clone sets for itself because git does not run commands from a repository's committed files:",[46,685,687],{"className":48,"code":686,"language":50,"meta":51,"style":51},"git init -q repo && cp pricing_template.xlsx xlsx2text.py repo\u002F && cd repo\nprintf '*.xlsx diff=excel\\n*.xlsm diff=excel\\n' > .gitattributes\ngit config diff.excel.textconv \"python xlsx2text.py\"\ngit config diff.excel.cachetextconv true\ngit -c user.name=demo -c user.email=demo@example.com add . && git -c user.name=demo -c user.email=demo@example.com commit -qm \"add pricing template\"\ngit log --oneline | wc -l | xargs echo \"commits:\"\n",[14,688,689,725,739,752,764,806],{"__ignoreMap":51},[55,690,691,694,697,700,703,706,709,712,714,717,719,722],{"class":57,"line":58},[55,692,693],{"class":61},"git",[55,695,696],{"class":65}," init",[55,698,699],{"class":360}," -q",[55,701,702],{"class":65}," repo",[55,704,705],{"class":301}," && ",[55,707,708],{"class":61},"cp",[55,710,711],{"class":65}," pricing_template.xlsx",[55,713,651],{"class":65},[55,715,716],{"class":65}," repo\u002F",[55,718,705],{"class":301},[55,720,721],{"class":360},"cd",[55,723,724],{"class":65}," repo\n",[55,726,727,730,733,736],{"class":57,"line":72},[55,728,729],{"class":360},"printf",[55,731,732],{"class":65}," '*.xlsx diff=excel\\n*.xlsm diff=excel\\n'",[55,734,735],{"class":297}," >",[55,737,738],{"class":65}," .gitattributes\n",[55,740,741,743,746,749],{"class":57,"line":318},[55,742,693],{"class":61},[55,744,745],{"class":65}," config",[55,747,748],{"class":65}," diff.excel.textconv",[55,750,751],{"class":65}," \"python xlsx2text.py\"\n",[55,753,754,756,758,761],{"class":57,"line":325},[55,755,693],{"class":61},[55,757,745],{"class":65},[55,759,760],{"class":65}," diff.excel.cachetextconv",[55,762,763],{"class":360}," true\n",[55,765,766,768,771,774,776,779,782,785,787,789,791,793,795,797,800,803],{"class":57,"line":344},[55,767,693],{"class":61},[55,769,770],{"class":360}," -c",[55,772,773],{"class":65}," user.name=demo",[55,775,770],{"class":360},[55,777,778],{"class":65}," user.email=demo@example.com",[55,780,781],{"class":65}," add",[55,783,784],{"class":65}," .",[55,786,705],{"class":301},[55,788,693],{"class":61},[55,790,770],{"class":360},[55,792,773],{"class":65},[55,794,770],{"class":360},[55,796,778],{"class":65},[55,798,799],{"class":65}," commit",[55,801,802],{"class":360}," -qm",[55,804,805],{"class":65}," \"add pricing template\"\n",[55,807,808,810,813,816,819,822,825,827,830,833],{"class":57,"line":376},[55,809,693],{"class":61},[55,811,812],{"class":65}," log",[55,814,815],{"class":360}," --oneline",[55,817,818],{"class":297}," |",[55,820,821],{"class":61}," wc",[55,823,824],{"class":360}," -l",[55,826,818],{"class":297},[55,828,829],{"class":61}," xargs",[55,831,832],{"class":65}," echo",[55,834,835],{"class":65}," \"commits:\"\n",[92,837,101,843,101,846,101,849,101,854,101,858,101,862,101,865,101,869,101,872,101,878,101,882],{"viewBox":838,"role":95,"ariaLabelledBy":839,"xmlns":99,"style":842},"54 0 652 228",[840,841],"gd-config-t","gd-config-d","width:100%;max-width:652px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[103,844,845],{"id":840},"What is shared and what each clone sets",[107,847,848],{"id":841},"Two layers. The .gitattributes file with the diff=excel entry is committed and shared by everyone who clones the repository. The textconv command and cachetextconv setting live in each clone's git configuration, because git never runs commands defined by a repository's committed files.",[111,850],{"x":851,"y":114,"width":852,"height":853,"fill":117},"54","652","228",[111,855],{"x":856,"y":121,"width":857,"height":179,"rx":124,"fill":168,"stroke":159,"style":127},"70.0","620.0",[129,859,860],{"transform":198},[133,861],{"d":174,"fill":136,"stroke":175,"style":138},[140,863,864],{"x":204,"y":205,"style":180},".gitattributes — committed",[140,866,868],{"x":204,"y":867,"style":148},"95.0","*.xlsx diff=excel · *.xlsm diff=excel",[111,870],{"x":856,"y":871,"width":857,"height":179,"rx":124,"fill":226,"stroke":227,"style":127},"119.0",[129,873,875],{"transform":874},"translate(368.0,132.0) scale(1.000)",[133,876],{"d":877,"fill":136,"stroke":234,"style":138},"M12 9a3 3 0 1 0 .01 0z M12 2.5v3 M12 18.5v3 M2.5 12h3 M18.5 12h3 M5.3 5.3l2.1 2.1 M16.6 16.6l2.1 2.1 M5.3 18.7l2.1-2.1 M16.6 7.4l2.1-2.1",[140,879,881],{"x":204,"y":880,"style":238},"178.5",".git\u002Fconfig — per clone",[140,883,885],{"x":204,"y":884,"style":148},"198.0","diff.excel.textconv = python xlsx2text.py · cachetextconv = true",[10,887,888,891,892,894,895,898,899,902,903,906],{},[14,889,890],{},"cachetextconv"," stores each converted version in git's notes so that ",[14,893,32],{}," over a long history does not reconvert the same workbook every time. The textconv command runs in the repository root, so a relative path to the script works; for a converter used across repositories, install it as a command and put that on ",[14,896,897],{},"PATH"," instead. Adding the ",[14,900,901],{},"git config"," lines to the project's setup instructions — or to a ",[14,904,905],{},"make setup"," script — keeps every developer's diffs readable.",[41,908,910],{"id":909},"step-3-change-the-workbook-and-diff-it","Step 3 — Change the workbook and diff it",[10,912,913],{},"Edit the template the way a colleague would: a new VAT rate and a rounded gross formula. Then look at the diff:",[46,915,917],{"className":289,"code":916,"language":211,"meta":51,"style":51},"from openpyxl import load_workbook\n\nwb = load_workbook(\"repo\u002Fpricing_template.xlsx\")\nwb[\"Inputs\"][\"B2\"] = 0.21\nfor r in (2, 3):\n    wb[\"Calc\"][f\"C{r}\"] = f\"=ROUND(B{r}*(1+Inputs!B2),0)\"\nwb.save(\"repo\u002Fpricing_template.xlsx\")\nprint(\"edited\")\n",[14,918,919,929,933,948,970,992,1035,1044],{"__ignoreMap":51},[55,920,921,923,925,927],{"class":57,"line":58},[55,922,307],{"class":297},[55,924,310],{"class":301},[55,926,298],{"class":297},[55,928,315],{"class":301},[55,930,931],{"class":57,"line":72},[55,932,322],{"emptyLinePlaceholder":321},[55,934,935,938,940,943,946],{"class":57,"line":318},[55,936,937],{"class":301},"wb ",[55,939,338],{"class":297},[55,941,942],{"class":301}," load_workbook(",[55,944,945],{"class":65},"\"repo\u002Fpricing_template.xlsx\"",[55,947,373],{"class":301},[55,949,950,953,956,959,962,965,967],{"class":57,"line":325},[55,951,952],{"class":301},"wb[",[55,954,955],{"class":65},"\"Inputs\"",[55,957,958],{"class":301},"][",[55,960,961],{"class":65},"\"B2\"",[55,963,964],{"class":301},"] ",[55,966,338],{"class":297},[55,968,969],{"class":360}," 0.21\n",[55,971,972,975,978,980,982,985,987,990],{"class":57,"line":344},[55,973,974],{"class":297},"for",[55,976,977],{"class":301}," r ",[55,979,385],{"class":297},[55,981,413],{"class":301},[55,983,984],{"class":360},"2",[55,986,29],{"class":301},[55,988,989],{"class":360},"3",[55,991,511],{"class":301},[55,993,994,997,1000,1002,1004,1007,1009,1012,1014,1016,1018,1020,1023,1026,1028,1030,1032],{"class":57,"line":376},[55,995,996],{"class":301},"    wb[",[55,998,999],{"class":65},"\"Calc\"",[55,1001,958],{"class":301},[55,1003,397],{"class":297},[55,1005,1006],{"class":65},"\"C",[55,1008,404],{"class":403},[55,1010,1011],{"class":301},"r",[55,1013,410],{"class":403},[55,1015,429],{"class":65},[55,1017,964],{"class":301},[55,1019,338],{"class":297},[55,1021,1022],{"class":297}," f",[55,1024,1025],{"class":65},"\"=ROUND(B",[55,1027,404],{"class":403},[55,1029,1011],{"class":301},[55,1031,410],{"class":403},[55,1033,1034],{"class":65},"*(1+Inputs!B2),0)\"\n",[55,1036,1037,1040,1042],{"class":57,"line":391},[55,1038,1039],{"class":301},"wb.save(",[55,1041,945],{"class":65},[55,1043,373],{"class":301},[55,1045,1046,1049,1052,1055],{"class":57,"line":434},[55,1047,1048],{"class":360},"print",[55,1050,1051],{"class":301},"(",[55,1053,1054],{"class":65},"\"edited\"",[55,1056,373],{"class":301},[46,1058,1060],{"className":48,"code":1059,"language":50,"meta":51,"style":51},"cd repo && git diff --stat && git diff | grep -E '^[-+][^-+]'\n",[14,1061,1062],{"__ignoreMap":51},[55,1063,1064,1066,1068,1070,1072,1075,1078,1080,1082,1084,1086,1089,1092],{"class":57,"line":58},[55,1065,721],{"class":360},[55,1067,702],{"class":65},[55,1069,705],{"class":301},[55,1071,693],{"class":61},[55,1073,1074],{"class":65}," diff",[55,1076,1077],{"class":360}," --stat",[55,1079,705],{"class":301},[55,1081,693],{"class":61},[55,1083,1074],{"class":65},[55,1085,818],{"class":297},[55,1087,1088],{"class":61}," grep",[55,1090,1091],{"class":360}," -E",[55,1093,1094],{"class":65}," '^[-+][^-+]'\n",[10,1096,1097,1098,1101,1102,1105,1106,1108,1109,1112,1113,1116,1117,1120,1121,1124],{},"Note the first two lines: ",[14,1099,1100],{},"git diff --stat"," still reports the file as ",[14,1103,1104],{},"Bin"," with no insertions or deletions, because statistics are counted on the raw bytes, not on the converted text. The diff itself shows exactly the three cells that changed — the VAT rate from ",[14,1107,664],{}," to ",[14,1110,1111],{},"0.21",", and the two gross formulas gaining ",[14,1114,1115],{},"ROUND(…,0)"," — as removed and added lines, each with its sheet and address. That is what a reviewer needs to approve a template change: not \"the file changed\", but \"the VAT rate and the rounding changed, nothing else did\". The same view appears in ",[14,1118,1119],{},"git log -p pricing_template.xlsx"," for the file's whole history, and ",[14,1122,1123],{},"git diff --word-diff"," narrows it to the changed part of each line.",[92,1126,101,1132,101,1135,101,1138,101,1143,101,1150,101,1156,101,1163,101,1168,101,1171,101,1176,101,1179,101,1183,101,1186,101,1190,101,1195,101,1198,101,1203,101,1206,101,1214],{"viewBox":1127,"role":95,"ariaLabelledBy":1128,"xmlns":99,"style":1131},"70 0 619 168",[1129,1130],"gd-diff-t","gd-diff-d","width:100%;max-width:619px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[103,1133,1134],{"id":1129},"Binary diff and textconv diff",[107,1136,1137],{"id":1130},"On the left the diff git shows without a driver, a single line saying the binary files differ. On the right the textconv diff with the VAT rate changed from 0.2 to 0.21 and the gross formulas changed to include ROUND.",[111,1139],{"x":1140,"y":114,"width":1141,"height":1142,"fill":117},"70","619","168",[111,1144],{"x":1145,"y":1146,"width":1147,"height":1148,"rx":114,"fill":117,"stroke":126,"style":1149},"86.5","40.0","196.7","28.0","stroke-width:1px",[140,1151,1155],{"x":1152,"y":1153,"style":1154},"184.9","59.0","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","Binary files a\u002F… and b\u002F… differ",[111,1157],{"x":1158,"y":1146,"width":1159,"height":1148,"rx":114,"fill":1160,"stroke":1161,"style":1162},"420.9","252.6","#fce9e9","var(--danger,#dc2626)","stroke-width:1.5px",[140,1164,1167],{"x":1165,"y":1153,"style":1166},"547.2","font-size:11px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","- Inputs!B2  0.2",[111,1169],{"x":1158,"y":1170,"width":1159,"height":1148,"rx":114,"fill":226,"stroke":227,"style":1162},"68.0",[140,1172,1175],{"x":1165,"y":1173,"style":1174},"87.0","font-size:11px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","+ Inputs!B2  0.21",[111,1177],{"x":1158,"y":1178,"width":1159,"height":1148,"rx":114,"fill":1160,"stroke":1161,"style":1162},"96.0",[140,1180,1182],{"x":1165,"y":1181,"style":1166},"115.0","- Calc!C2  =B2*(1+Inputs!B2)",[111,1184],{"x":1158,"y":1185,"width":1159,"height":1148,"rx":114,"fill":226,"stroke":227,"style":1162},"124.0",[140,1187,1189],{"x":1165,"y":1188,"style":1174},"143.0","+ Calc!C2  =ROUND(B2*(1+Inputs!B2),0)",[140,1191,1194],{"x":1152,"y":1192,"style":1193},"29.0","font-size:12.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","before",[140,1196,1197],{"x":1165,"y":1192,"style":1193},"after",[57,1199],{"x1":1200,"y1":1201,"x2":1202,"y2":1201,"stroke":159,"style":127},"293.2","54.0","406.9",[161,1204],{"points":1205,"fill":164},"410.9,54.0 401.9,59.0 401.9,49.0",[111,1207],{"x":1208,"y":1209,"width":1210,"height":1211,"rx":1212,"fill":117,"stroke":126,"style":1213},"313.0","21.0","78.2","22.0","11","stroke-width:1.2px",[140,1215,25],{"x":1216,"y":1217,"style":1218},"352.1","36.0","font-size:11.5px;font-weight:400;fill:var(--brand-strong,#4338ca);text-anchor:middle;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace",[41,1220,1222],{"id":1221},"step-4-use-it-in-reviews-and-tests","Step 4 — Use it in reviews and tests",[10,1224,1225,1226,1229],{},"Hosted services such as GitHub and GitLab render diffs on their servers and do not run local textconv drivers, so the pull request page still says \"binary\". Two ways around that. Reviewers can run ",[14,1227,1228],{},"git diff main...branch -- '*.xlsx'"," locally, where the driver applies. Or CI can post the text diff as a comment or an artefact, using the converter directly:",[46,1231,1233],{"className":289,"code":1232,"language":211,"meta":51,"style":51},"import difflib\nimport io\nimport subprocess\nimport sys\nsys.path.insert(0, \"repo\")\nfrom xlsx2text import dump\n\ndef workbook_text(path=None, git_ref=None):\n    buf = io.StringIO()\n    if git_ref:\n        data = subprocess.run([\"git\", \"-C\", \"repo\", \"show\", f\"{git_ref}:pricing_template.xlsx\"],\n                              check=True, capture_output=True).stdout\n        dump(io.BytesIO(data), buf)\n    else:\n        dump(path, buf)\n    return buf.getvalue().splitlines()\n\ndiff = difflib.unified_diff(workbook_text(git_ref=\"HEAD\"), workbook_text(\"repo\u002Fpricing_template.xlsx\"),\n                            \"HEAD\", \"working copy\", lineterm=\"\", n=0)\nprint(\"\\n\".join(line for line in diff if not line.startswith(\"@@\")))\n",[14,1234,1235,1242,1249,1256,1262,1276,1288,1292,1316,1326,1334,1380,1402,1407,1414,1419,1427,1431,1456,1487],{"__ignoreMap":51},[55,1236,1237,1239],{"class":57,"line":58},[55,1238,298],{"class":297},[55,1240,1241],{"class":301}," difflib\n",[55,1243,1244,1246],{"class":57,"line":72},[55,1245,298],{"class":297},[55,1247,1248],{"class":301}," io\n",[55,1250,1251,1253],{"class":57,"line":318},[55,1252,298],{"class":297},[55,1254,1255],{"class":301}," subprocess\n",[55,1257,1258,1260],{"class":57,"line":325},[55,1259,298],{"class":297},[55,1261,302],{"class":301},[55,1263,1264,1267,1269,1271,1274],{"class":57,"line":344},[55,1265,1266],{"class":301},"sys.path.insert(",[55,1268,114],{"class":360},[55,1270,29],{"class":301},[55,1272,1273],{"class":65},"\"repo\"",[55,1275,373],{"class":301},[55,1277,1278,1280,1283,1285],{"class":57,"line":376},[55,1279,307],{"class":297},[55,1281,1282],{"class":301}," xlsx2text ",[55,1284,298],{"class":297},[55,1286,1287],{"class":301}," dump\n",[55,1289,1290],{"class":57,"line":391},[55,1291,322],{"emptyLinePlaceholder":321},[55,1293,1294,1296,1299,1302,1304,1307,1310,1312,1314],{"class":57,"line":434},[55,1295,328],{"class":297},[55,1297,1298],{"class":331}," workbook_text",[55,1300,1301],{"class":301},"(path",[55,1303,338],{"class":297},[55,1305,1306],{"class":360},"None",[55,1308,1309],{"class":301},", git_ref",[55,1311,338],{"class":297},[55,1313,1306],{"class":360},[55,1315,511],{"class":301},[55,1317,1318,1321,1323],{"class":57,"line":448},[55,1319,1320],{"class":301},"    buf ",[55,1322,338],{"class":297},[55,1324,1325],{"class":301}," io.StringIO()\n",[55,1327,1328,1331],{"class":57,"line":462},[55,1329,1330],{"class":297},"    if",[55,1332,1333],{"class":301}," git_ref:\n",[55,1335,1336,1339,1341,1344,1347,1349,1352,1354,1356,1358,1361,1363,1365,1367,1369,1372,1374,1377],{"class":57,"line":480},[55,1337,1338],{"class":301},"        data ",[55,1340,338],{"class":297},[55,1342,1343],{"class":301}," subprocess.run([",[55,1345,1346],{"class":65},"\"git\"",[55,1348,29],{"class":301},[55,1350,1351],{"class":65},"\"-C\"",[55,1353,29],{"class":301},[55,1355,1273],{"class":65},[55,1357,29],{"class":301},[55,1359,1360],{"class":65},"\"show\"",[55,1362,29],{"class":301},[55,1364,397],{"class":297},[55,1366,429],{"class":65},[55,1368,404],{"class":403},[55,1370,1371],{"class":301},"git_ref",[55,1373,410],{"class":403},[55,1375,1376],{"class":65},":pricing_template.xlsx\"",[55,1378,1379],{"class":301},"],\n",[55,1381,1382,1385,1387,1390,1392,1395,1397,1399],{"class":57,"line":486},[55,1383,1384],{"class":355},"                              check",[55,1386,338],{"class":297},[55,1388,1389],{"class":360},"True",[55,1391,29],{"class":301},[55,1393,1394],{"class":355},"capture_output",[55,1396,338],{"class":297},[55,1398,1389],{"class":360},[55,1400,1401],{"class":301},").stdout\n",[55,1403,1404],{"class":57,"line":497},[55,1405,1406],{"class":301},"        dump(io.BytesIO(data), buf)\n",[55,1408,1409,1412],{"class":57,"line":514},[55,1410,1411],{"class":297},"    else",[55,1413,477],{"class":301},[55,1415,1416],{"class":57,"line":528},[55,1417,1418],{"class":301},"        dump(path, buf)\n",[55,1420,1421,1424],{"class":57,"line":564},[55,1422,1423],{"class":297},"    return",[55,1425,1426],{"class":301}," buf.getvalue().splitlines()\n",[55,1428,1429],{"class":57,"line":577},[55,1430,322],{"emptyLinePlaceholder":321},[55,1432,1433,1436,1438,1441,1443,1445,1448,1451,1453],{"class":57,"line":608},[55,1434,1435],{"class":301},"diff ",[55,1437,338],{"class":297},[55,1439,1440],{"class":301}," difflib.unified_diff(workbook_text(",[55,1442,1371],{"class":355},[55,1444,338],{"class":297},[55,1446,1447],{"class":65},"\"HEAD\"",[55,1449,1450],{"class":301},"), workbook_text(",[55,1452,945],{"class":65},[55,1454,1455],{"class":301},"),\n",[55,1457,1458,1461,1463,1466,1468,1471,1473,1476,1478,1481,1483,1485],{"class":57,"line":613},[55,1459,1460],{"class":65},"                            \"HEAD\"",[55,1462,29],{"class":301},[55,1464,1465],{"class":65},"\"working copy\"",[55,1467,29],{"class":301},[55,1469,1470],{"class":355},"lineterm",[55,1472,338],{"class":297},[55,1474,1475],{"class":65},"\"\"",[55,1477,29],{"class":301},[55,1479,1480],{"class":355},"n",[55,1482,338],{"class":297},[55,1484,114],{"class":360},[55,1486,373],{"class":301},[55,1488,1489,1491,1493,1495,1497,1499,1502,1504,1507,1509,1512,1514,1517,1520,1523],{"class":57,"line":630},[55,1490,1048],{"class":360},[55,1492,1051],{"class":301},[55,1494,429],{"class":65},[55,1496,426],{"class":403},[55,1498,429],{"class":65},[55,1500,1501],{"class":301},".join(line ",[55,1503,974],{"class":297},[55,1505,1506],{"class":301}," line ",[55,1508,385],{"class":297},[55,1510,1511],{"class":301}," diff ",[55,1513,616],{"class":297},[55,1515,1516],{"class":297}," not",[55,1518,1519],{"class":301}," line.startswith(",[55,1521,1522],{"class":65},"\"@@\"",[55,1524,1525],{"class":301},")))\n",[10,1527,1528,1531,1532,1535,1536,1540],{},[14,1529,1530],{},"git show HEAD:file"," retrieves the committed version's bytes, and ",[14,1533,1534],{},"difflib"," produces the same unified diff git would. The same comparison works in tests: ",[35,1537,1539],{"href":1538},"\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"," shows it failing a test with a readable list of changed cells instead of \"files differ\".",[41,1542,1544],{"id":1543},"what-the-text-view-does-not-show","What the text view does not show",[10,1546,1547,1548,1551,1552,1555],{},"The converter prints cell contents, formulas, sheet states and names — the things that change results. It does not show formatting, column widths, conditional formats, charts, data validation or comments, so a change that only restyles a template shows as an empty diff. Extend ",[14,1549,1550],{},"dump"," for the properties your templates depend on — ",[14,1553,1554],{},"cell.number_format"," is the most useful addition, because a changed number format changes what readers see — but resist dumping everything, or every save by Excel will produce noise. Workbooks saved by Excel also reorder internal XML freely, which is why diffing the unzipped XML directly is far noisier than this cell-level view.",[41,1557,1559],{"id":1558},"performance-notes","Performance notes",[10,1561,1562,1563,1565,1566,1568],{},"The converter loads the whole workbook with openpyxl, which takes a fraction of a second for templates and small golden files and longer for workbooks with hundreds of thousands of cells. Git runs it twice per diff — once for each side — and many times for ",[14,1564,32],{},", which is why ",[14,1567,890],{}," matters. Keep large data workbooks out of the repository altogether; this technique is for templates, reference tables and test fixtures, where a readable history is worth the conversion time.",[41,1570,1572],{"id":1571},"common-pitfalls","Common pitfalls",[1574,1575,1576,1590,1599,1605,1611],"ul",{},[1577,1578,1579,1583,1584,1586,1587,1589],"li",{},[1580,1581,1582],"strong",{},"Expecting the driver to come with a clone."," ",[14,1585,181],{}," is shared; the ",[14,1588,901],{}," for the command is per clone.",[1577,1591,1592,1598],{},[1580,1593,1594,1595,39],{},"Loading with ",[14,1596,1597],{},"data_only=True"," You would diff cached results and miss formula changes.",[1577,1600,1601,1604],{},[1580,1602,1603],{},"Diffs that pair unrelated cells."," Print sheet and address on every line so each cell is its own line.",[1577,1606,1607,1610],{},[1580,1608,1609],{},"Hosted diff views."," GitHub and GitLab ignore local drivers; review locally or publish the text diff from CI.",[1577,1612,1613,1616,1617,1620,1621,1625],{},[1580,1614,1615],{},"Macro-enabled files."," openpyxl reads ",[14,1618,1619],{},".xlsm"," cell content, but VBA changes are invisible; see ",[35,1622,1624],{"href":1623},"\u002Fgetting-started-with-python-excel-automation\u002Fautomating-excel-with-com-and-pywin32\u002Fextract-vba-macro-code-from-an-excel-file\u002F","Extract VBA Macro Code from an Excel File"," for diffing macros.",[41,1627,1629],{"id":1628},"conclusion","Conclusion",[10,1631,1632,1633,1636,1637,1639,1640,1642,1643,1646,1647,1649,1650,665,1652,1654,1655,39],{},"Write a small converter that prints every non-empty cell as ",[14,1634,1635],{},"Sheet!Address"," and its formula or value, plus sheet states and defined names, and register it as a git diff driver: ",[14,1638,185],{}," in ",[14,1641,181],{},", and ",[14,1644,1645],{},"diff.excel.textconv"," with ",[14,1648,890],{}," in each clone's config. ",[14,1651,16],{},[14,1653,32],{}," then show exactly which cells changed; for hosted reviews and tests, run the same converter with ",[14,1656,1534],{},[41,1658,1660],{"id":1659},"frequently-asked-questions","Frequently asked questions",[10,1662,1663,1666],{},[1580,1664,1665],{},"How do I see what changed in an Excel file in git?","\nConfigure a textconv diff driver: add \"*.xlsx diff=excel\" to .gitattributes and set git config diff.excel.textconv to a script that prints the workbook's cells as text. git diff then compares the text versions.",[10,1668,1669,1672],{},[1580,1670,1671],{},"Why does git say \"Binary files differ\" for .xlsx?","\nAn .xlsx file is a zip archive, so git treats it as binary and cannot show line changes. A textconv driver converts it to text first.",[10,1674,1675,1678],{},[1580,1676,1677],{},"Does GitHub use my textconv driver in pull requests?","\nNo. Hosted services render diffs on their servers without running local commands. Review Excel changes locally with git diff, or have CI post the text diff produced by the same converter.",[10,1680,1681,1684],{},[1580,1682,1683],{},"Can the diff show formatting changes?","\nOnly if the converter prints them. Adding number formats is useful; dumping all styles creates noise, because Excel changes many internal details on every save.",[41,1686,1688],{"id":1687},"related","Related",[1574,1690,1691,1697,1704,1711],{},[1577,1692,1693,1694,1696],{},"Up one level: ",[35,1695,38],{"href":37}," — making report code dependable.",[1577,1698,1699,1703],{},[35,1700,1702],{"href":1701},"\u002Fadvanced-data-transformation-and-cleaning\u002Fvalidating-excel-data-with-python\u002Fcompare-two-excel-files-for-differences-with-python\u002F","Compare Two Excel Files for Differences with Python"," — comparing data rather than cells.",[1577,1705,1706,1710],{},[35,1707,1709],{"href":1708},"\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Faudit-every-formula-in-a-workbook-with-python\u002F","Audit Every Formula in a Workbook with Python"," — a fuller formula inventory.",[1577,1712,1713,1717],{},[35,1714,1716],{"href":1715},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fset-up-an-excel-automation-project-with-uv\u002F","Set Up an Excel Automation Project with uv"," — where the setup script lives.",[1719,1720,1721],"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 pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}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}",{"title":51,"searchDepth":72,"depth":72,"links":1723},[1724,1725,1726,1727,1728,1729,1730,1731,1732,1733,1734],{"id":43,"depth":72,"text":44},{"id":278,"depth":72,"text":279},{"id":672,"depth":72,"text":673},{"id":909,"depth":72,"text":910},{"id":1221,"depth":72,"text":1222},{"id":1543,"depth":72,"text":1544},{"id":1558,"depth":72,"text":1559},{"id":1571,"depth":72,"text":1572},{"id":1628,"depth":72,"text":1629},{"id":1659,"depth":72,"text":1660},{"id":1687,"depth":72,"text":1688},"2026-09-29","Make git diff show what changed inside a workbook instead of \"Binary files differ\": a small Python converter that prints every cell as sheet, address, value and formula, a .gitattributes entry and a diff driver that use it, readable diffs of templates and golden files in pull requests, and the limits of the approach.","md",[1739,1741,1743,1745],{"q":1665,"a":1740},"Configure a textconv diff driver: add \"*.xlsx diff=excel\" to .gitattributes and set git config diff.excel.textconv to a script that prints the workbook's cells as text. git diff then compares the text versions.",{"q":1671,"a":1742},"An .xlsx file is a zip archive, so git treats it as binary and cannot show line changes. A textconv driver converts it to text first.",{"q":1677,"a":1744},"No. Hosted services render diffs on their servers without running local commands. Review Excel changes locally with git diff, or have CI post the text diff produced by the same converter.",{"q":1683,"a":1746},"Only if the converter prints them. Adding number formats is useful; dumping all styles creates noise, because Excel changes many internal details on every save.","diff excel files in git with a textconv driver",null,{"breadcrumb":1750},[1751,1754,1757],{"name":1752,"item":1753},"Home","\u002F",{"name":1755,"item":1756},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":38,"item":37},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fdiff-excel-files-in-git-with-a-textconv-driver",{"title":1760,"description":1761},"Readable git diff for Excel .xlsx Files with Python","Set up a git textconv diff driver for .xlsx files: a Python script that dumps sheets, cell addresses, values and formulas with openpyxl, .gitattributes diff=excel, git config diff.excel.textconv, cachetextconv, and reviewing template changes in pull requests.","diff-excel-files-in-git-with-a-textconv-driver","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fdiff-excel-files-in-git-with-a-textconv-driver\u002Findex",[1765,1766],"reliable-jobs","joins-and-lookups","how-to","NuLIQIUWI2Fo1f3yN9Bl57fKyEG4Nf_5z9KksKL0mFs",[1770,1774],{"title":1771,"path":1772,"stem":1773,"children":-1},"Create Excel Test Fixtures in Memory for pytest","\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcreate-excel-test-fixtures-in-memory","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcreate-excel-test-fixtures-in-memory\u002Findex",{"title":1775,"path":1776,"stem":1777,"children":-1},"Generate Realistic Test Data for Excel Reports with Faker","\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fgenerate-realistic-test-data-for-excel-reports-with-faker","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fgenerate-realistic-test-data-for-excel-reports-with-faker\u002Findex",[1779,1781],{"path":1780,"title":1755},"\u002Fautomating-reporting-workflows",{"path":1782,"title":38},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts",[],[1785,1789,1792,1795,1798,1801],{"path":1786,"title":1787,"kind":1788},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation","Error Handling and Logging in Excel Automation","guide",{"path":1790,"title":1791,"kind":1767},"\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":1793,"title":1794,"kind":1767},"\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":1796,"title":1797,"kind":1767},"\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":1799,"title":1800,"kind":1767},"\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":1802,"title":1803,"kind":1767},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports","Prevent Formula Injection in Excel Exports from Python",1790972140704]