[{"data":1,"prerenderedAt":1285},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-excel-files-in-git-with-git-lfs":3,"surround:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-excel-files-in-git-with-git-lfs":1250,"ancestors:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-excel-files-in-git-with-git-lfs":1259,"children:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-excel-files-in-git-with-git-lfs":1264,"related:\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-excel-files-in-git-with-git-lfs":1265},{"id":4,"title":5,"body":6,"dateModified":1217,"datePublished":1217,"description":1218,"extension":1219,"faq":1220,"keyword":1229,"kind":1230,"meta":1231,"navigation":1240,"path":1241,"seo":1242,"slug":1244,"stem":1245,"topics":1246,"type":1248,"__hash__":1249},"docs\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-excel-files-in-git-with-git-lfs\u002Findex.md","Store Excel Files in Git with Git LFS",{"type":7,"value":8,"toc":1204},"minimark",[9,24,29,58,65,69,193,227,231,372,383,541,620,624,776,803,807,879,901,1022,1026,1033,1044,1048,1056,1060,1104,1108,1128,1132,1138,1144,1159,1169,1173,1200],[10,11,12,13,17,18,23],"p",{},"An Excel automation project usually has a few workbooks that genuinely belong in the repository: the formatted template the report fills in, a reference table of cost centres, the golden output file a test compares against. Git handles them, but not well. An ",[14,15,16],"code",{},".xlsx"," file is a zip archive, so every saved change produces a completely different binary blob; git cannot store just the difference, and every version of every workbook stays in the history that each clone downloads. A template saved fifty times is fifty copies. Git LFS (Large File Storage) changes where those bytes live: the repository stores a tiny text pointer for each version, and the workbook itself goes to an LFS server — which GitHub, GitLab, Bitbucket and Azure DevOps all provide — and is downloaded only for the versions that are checked out. This page sets up LFS for Excel files, shows what is actually committed, keeps diffs readable, and uses LFS file locking for templates. It belongs to ",[19,20,22],"a",{"href":21},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002F","Testing and Packaging Excel Automation Scripts",".",[25,26,28],"h2",{"id":27},"prerequisites","Prerequisites",[30,31,36],"pre",{"className":32,"code":33,"language":34,"meta":35,"style":35},"language-bash shiki shiki-themes github-light github-dark-high-contrast","git lfs version        # install from git-lfs.com or your package manager if missing\n","bash","",[14,37,38],{"__ignoreMap":35},[39,40,43,47,51,54],"span",{"class":41,"line":42},"line",1,[39,44,46],{"class":45},"sMTad","git",[39,48,50],{"class":49},"srMev"," lfs",[39,52,53],{"class":49}," version",[39,55,57],{"class":56},"s-wDw","        # install from git-lfs.com or your package manager if missing\n",[10,59,60,61,64],{},"Git LFS is a git extension; install it once per machine and run ",[14,62,63],{},"git lfs install",". The hosting service needs LFS enabled for the repository, which is the default on the major platforms, though storage and bandwidth quotas apply.",[25,66,68],{"id":67},"step-1-track-excel-files","Step 1 — Track Excel files",[30,70,72],{"className":32,"code":71,"language":34,"meta":35,"style":35},"set -e\nrm -rf reports-repo && mkdir reports-repo && cd reports-repo\ngit init -q && git config user.email dev@example.com && git config user.name Dev\ngit lfs install --local > \u002Fdev\u002Fnull\ngit lfs track \"*.xlsx\" \"*.xlsm\"\ncat .gitattributes\n",[14,73,74,83,112,148,168,184],{"__ignoreMap":35},[39,75,76,80],{"class":41,"line":42},[39,77,79],{"class":78},"sP0c6","set",[39,81,82],{"class":78}," -e\n",[39,84,86,89,92,95,99,102,104,106,109],{"class":41,"line":85},2,[39,87,88],{"class":45},"rm",[39,90,91],{"class":78}," -rf",[39,93,94],{"class":49}," reports-repo",[39,96,98],{"class":97},"skGVy"," && ",[39,100,101],{"class":45},"mkdir",[39,103,94],{"class":49},[39,105,98],{"class":97},[39,107,108],{"class":78},"cd",[39,110,111],{"class":49}," reports-repo\n",[39,113,115,117,120,123,125,127,130,133,136,138,140,142,145],{"class":41,"line":114},3,[39,116,46],{"class":45},[39,118,119],{"class":49}," init",[39,121,122],{"class":78}," -q",[39,124,98],{"class":97},[39,126,46],{"class":45},[39,128,129],{"class":49}," config",[39,131,132],{"class":49}," user.email",[39,134,135],{"class":49}," dev@example.com",[39,137,98],{"class":97},[39,139,46],{"class":45},[39,141,129],{"class":49},[39,143,144],{"class":49}," user.name",[39,146,147],{"class":49}," Dev\n",[39,149,151,153,155,158,161,165],{"class":41,"line":150},4,[39,152,46],{"class":45},[39,154,50],{"class":49},[39,156,157],{"class":49}," install",[39,159,160],{"class":78}," --local",[39,162,164],{"class":163},"s-kum"," >",[39,166,167],{"class":49}," \u002Fdev\u002Fnull\n",[39,169,171,173,175,178,181],{"class":41,"line":170},5,[39,172,46],{"class":45},[39,174,50],{"class":49},[39,176,177],{"class":49}," track",[39,179,180],{"class":49}," \"*.xlsx\"",[39,182,183],{"class":49}," \"*.xlsm\"\n",[39,185,187,190],{"class":41,"line":186},6,[39,188,189],{"class":45},"cat",[39,191,192],{"class":49}," .gitattributes\n",[10,194,195,198,199,202,203,206,207,210,211,214,215,218,219,221,222,226],{},[14,196,197],{},"git lfs track"," writes the patterns to ",[14,200,201],{},".gitattributes",", which is committed with the project so every clone treats the files the same way. The attributes say three things: ",[14,204,205],{},"filter=lfs"," replaces the file's content with a pointer when it is committed and restores it on checkout; ",[14,208,209],{},"diff=lfs"," and ",[14,212,213],{},"merge=lfs"," tell git not to try text diffs and merges on it; and ",[14,216,217],{},"-text"," stops line-ending conversion from corrupting the binary. Commit ",[14,220,201],{}," ",[223,224,225],"strong",{},"before"," adding any workbooks — files committed before the pattern exists go into normal git storage, and only a history rewrite moves them out again.",[25,228,230],{"id":229},"step-2-commit-a-workbook-and-see-what-git-stores","Step 2 — Commit a workbook and see what git stores",[30,232,234],{"className":32,"code":233,"language":34,"meta":35,"style":35},"set -e\ncd reports-repo\npython - \u003C\u003C'EOF'\nfrom openpyxl import Workbook\nfrom openpyxl.styles import Font\nwb = Workbook(); ws = wb.active; ws.title = \"Report\"\nws[\"A1\"] = \"Monthly sales report\"; ws[\"A1\"].font = Font(size=16, bold=True)\nws.append([]); ws.append([\"Region\", \"Revenue\", \"Target\"])\nwb.save(\"template.xlsx\")\nEOF\ngit add .gitattributes template.xlsx && git commit -q -m \"Add report template\"\necho \"--- what the commit contains for template.xlsx:\"\ngit show HEAD:template.xlsx\necho \"--- files in LFS:\"\ngit lfs ls-files --size\n",[14,235,236,242,248,263,268,273,278,284,290,296,302,331,340,351,359],{"__ignoreMap":35},[39,237,238,240],{"class":41,"line":42},[39,239,79],{"class":78},[39,241,82],{"class":78},[39,243,244,246],{"class":41,"line":85},[39,245,108],{"class":78},[39,247,111],{"class":49},[39,249,250,253,256,259],{"class":41,"line":114},[39,251,252],{"class":45},"python",[39,254,255],{"class":49}," -",[39,257,258],{"class":163}," \u003C\u003C",[39,260,262],{"class":261},"sXKb-","'EOF'\n",[39,264,265],{"class":41,"line":150},[39,266,267],{"class":49},"from openpyxl import Workbook\n",[39,269,270],{"class":41,"line":170},[39,271,272],{"class":49},"from openpyxl.styles import Font\n",[39,274,275],{"class":41,"line":186},[39,276,277],{"class":49},"wb = Workbook(); ws = wb.active; ws.title = \"Report\"\n",[39,279,281],{"class":41,"line":280},7,[39,282,283],{"class":49},"ws[\"A1\"] = \"Monthly sales report\"; ws[\"A1\"].font = Font(size=16, bold=True)\n",[39,285,287],{"class":41,"line":286},8,[39,288,289],{"class":49},"ws.append([]); ws.append([\"Region\", \"Revenue\", \"Target\"])\n",[39,291,293],{"class":41,"line":292},9,[39,294,295],{"class":49},"wb.save(\"template.xlsx\")\n",[39,297,299],{"class":41,"line":298},10,[39,300,301],{"class":261},"EOF\n",[39,303,305,307,310,313,316,318,320,323,325,328],{"class":41,"line":304},11,[39,306,46],{"class":45},[39,308,309],{"class":49}," add",[39,311,312],{"class":49}," .gitattributes",[39,314,315],{"class":49}," template.xlsx",[39,317,98],{"class":97},[39,319,46],{"class":45},[39,321,322],{"class":49}," commit",[39,324,122],{"class":78},[39,326,327],{"class":78}," -m",[39,329,330],{"class":49}," \"Add report template\"\n",[39,332,334,337],{"class":41,"line":333},12,[39,335,336],{"class":78},"echo",[39,338,339],{"class":49}," \"--- what the commit contains for template.xlsx:\"\n",[39,341,343,345,348],{"class":41,"line":342},13,[39,344,46],{"class":45},[39,346,347],{"class":49}," show",[39,349,350],{"class":49}," HEAD:template.xlsx\n",[39,352,354,356],{"class":41,"line":353},14,[39,355,336],{"class":78},[39,357,358],{"class":49}," \"--- files in LFS:\"\n",[39,360,362,364,366,369],{"class":41,"line":361},15,[39,363,46],{"class":45},[39,365,50],{"class":49},[39,367,368],{"class":49}," ls-files",[39,370,371],{"class":78}," --size\n",[10,373,374,375,378,379,382],{},"The workbook in the working folder is an ordinary Excel file that opens normally. The commit, however, contains only the three-line pointer: the LFS spec version, the SHA-256 hash of the real file and its size. The real bytes are kept in ",[14,376,377],{},".git\u002Flfs\u002Fobjects"," locally and uploaded to the LFS server on ",[14,380,381],{},"git push",". Anyone who clones the repository gets the pointers for the whole history but downloads only the workbooks for the commit they check out, so fifty versions of a template cost fifty small pointers, not fifty workbooks.",[384,385,393,394,393,398,393,402,393,410,393,419,393,423,393,430,393,434,393,438,393,442,393,446,393,450,393,455,393,460,393,464,393,466,393,469,393,471,393,474,393,477,393,481,393,483,393,487,393,489,393,492,393,494,393,497,393,500,393,504,393,506,393,509,393,511,393,514,393,516,393,519,393,522,393,526,393,528,393,531,393,533,393,536,393,538],"svg",{"viewBox":386,"role":387,"ariaLabelledBy":388,"xmlns":391,"style":392},"87 0 586 168","img",[389,390],"lfs-pointer-t","lfs-pointer-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:586px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[395,396,397],"title",{"id":389},"The pointer file git commits",[399,400,401],"desc",{"id":390},"The committed content of template.xlsx is three lines of text — the LFS specification URL, the SHA-256 object id of the real workbook and its size in bytes, here about 4.9 kilobytes.",[403,404],"rect",{"x":405,"y":406,"width":407,"height":408,"fill":409},"87","0","586","168","#ffffff",[403,411],{"x":412,"y":413,"width":414,"height":415,"rx":406,"fill":416,"stroke":417,"style":418},"103.1","16.0","32.0","24.0","#e7ebef","var(--line,#cdd5e6)","stroke-width:1px",[403,420],{"x":421,"y":413,"width":422,"height":415,"rx":406,"fill":416,"stroke":417,"style":418},"135.1","70.2",[424,425,429],"text",{"x":426,"y":427,"style":428},"170.2","33.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[403,431],{"x":432,"y":413,"width":433,"height":415,"rx":406,"fill":416,"stroke":417,"style":418},"205.3","234.8",[424,435,437],{"x":436,"y":427,"style":428},"322.7","B",[403,439],{"x":440,"y":413,"width":441,"height":415,"rx":406,"fill":416,"stroke":417,"style":418},"440.1","216.8",[424,443,445],{"x":444,"y":427,"style":428},"548.5","C",[403,447],{"x":412,"y":448,"width":414,"height":449,"rx":406,"fill":416,"stroke":417,"style":418},"40.0","28.0",[424,451,454],{"x":452,"y":453,"style":428},"119.1","59.0","1",[403,456],{"x":421,"y":448,"width":422,"height":449,"rx":406,"fill":457,"stroke":458,"style":459},"#f0f4ff","var(--brand,#5b5cf0)","stroke-width:1.5px",[424,461,463],{"x":426,"y":453,"style":462},"font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Line",[403,465],{"x":432,"y":448,"width":433,"height":449,"rx":406,"fill":457,"stroke":458,"style":459},[424,467,468],{"x":436,"y":453,"style":462},"Example",[403,470],{"x":440,"y":448,"width":441,"height":449,"rx":406,"fill":457,"stroke":458,"style":459},[424,472,473],{"x":444,"y":453,"style":462},"Meaning",[403,475],{"x":412,"y":476,"width":414,"height":449,"rx":406,"fill":416,"stroke":417,"style":418},"68.0",[424,478,480],{"x":452,"y":479,"style":428},"87.0","2",[403,482],{"x":421,"y":476,"width":422,"height":449,"rx":406,"fill":409,"stroke":417,"style":418},[424,484,486],{"x":426,"y":479,"style":485},"font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","version",[403,488],{"x":432,"y":476,"width":433,"height":449,"rx":406,"fill":409,"stroke":417,"style":418},[424,490,491],{"x":436,"y":479,"style":485},"https:\u002F\u002Fgit-lfs.github.com\u002Fspec\u002Fv1",[403,493],{"x":440,"y":476,"width":441,"height":449,"rx":406,"fill":409,"stroke":417,"style":418},[424,495,496],{"x":444,"y":479,"style":485},"pointer format",[403,498],{"x":412,"y":499,"width":414,"height":449,"rx":406,"fill":416,"stroke":417,"style":418},"96.0",[424,501,503],{"x":452,"y":502,"style":428},"115.0","3",[403,505],{"x":421,"y":499,"width":422,"height":449,"rx":406,"fill":409,"stroke":417,"style":418},[424,507,508],{"x":426,"y":502,"style":485},"oid",[403,510],{"x":432,"y":499,"width":433,"height":449,"rx":406,"fill":409,"stroke":417,"style":418},[424,512,513],{"x":436,"y":502,"style":485},"sha256:95c0636d…",[403,515],{"x":440,"y":499,"width":441,"height":449,"rx":406,"fill":409,"stroke":417,"style":418},[424,517,518],{"x":444,"y":502,"style":485},"hash of the real workbook",[403,520],{"x":412,"y":521,"width":414,"height":449,"rx":406,"fill":416,"stroke":417,"style":418},"124.0",[424,523,525],{"x":452,"y":524,"style":428},"143.0","4",[403,527],{"x":421,"y":521,"width":422,"height":449,"rx":406,"fill":409,"stroke":417,"style":418},[424,529,530],{"x":426,"y":524,"style":485},"size",[403,532],{"x":432,"y":521,"width":433,"height":449,"rx":406,"fill":409,"stroke":417,"style":418},[424,534,535],{"x":436,"y":524,"style":485},"4895",[403,537],{"x":440,"y":521,"width":441,"height":449,"rx":406,"fill":409,"stroke":417,"style":418},[424,539,540],{"x":444,"y":524,"style":485},"bytes to download on checkout",[384,542,393,548,393,551,393,554,393,558,393,567,393,573,393,578,393,582,393,586,393,590,393,594,393,599,393,604,393,608,393,611,393,614,393,617],{"viewBox":543,"role":387,"ariaLabelledBy":544,"xmlns":391,"style":547},"4 0 752 199",[545,546],"lfs-vs-git-t","lfs-vs-git-d","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[395,549,550],{"id":545},"Where the workbook bytes live",[399,552,553],{"id":546},"In plain git every version of the workbook is a full blob in the repository history that every clone downloads. With Git LFS the history holds a small pointer per version, the workbooks live on the LFS server, and a clone downloads only the versions it checks out.",[403,555],{"x":525,"y":406,"width":556,"height":557,"fill":409},"752","199",[403,559],{"x":560,"y":413,"width":561,"height":562,"rx":563,"fill":564,"stroke":565,"style":566},"20.0","351.0","167.0","14","#fce9e9","var(--danger,#dc2626)","stroke-width:2px",[424,568,572],{"x":569,"y":570,"style":571},"195.5","44.0","font-size:14px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Plain git",[41,574],{"x1":575,"y1":576,"x2":577,"y2":576,"stroke":565,"style":418},"36.0","57.0","355.0",[424,579,581],{"x":569,"y":580,"style":485},"77.0","full blob for every saved version",[424,583,585],{"x":569,"y":584,"style":485},"103.0","every clone downloads all of them",[424,587,589],{"x":569,"y":588,"style":485},"129.0","repository grows with each save",[424,591,593],{"x":569,"y":592,"style":485},"155.0","no useful delta compression",[403,595],{"x":596,"y":413,"width":561,"height":562,"rx":563,"fill":597,"stroke":598,"style":566},"389.0","#d9f4f1","var(--teal,#0f9488)",[424,600,603],{"x":601,"y":570,"style":602},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Git LFS",[41,605],{"x1":606,"y1":576,"x2":607,"y2":576,"stroke":598,"style":418},"405.0","724.0",[424,609,610],{"x":601,"y":580,"style":485},"three-line pointer per version",[424,612,613],{"x":601,"y":584,"style":485},"workbooks on the LFS server",[424,615,616],{"x":601,"y":588,"style":485},"clone fetches only what it checks out",[424,618,619],{"x":601,"y":592,"style":485},"history stays small",[25,621,623],{"id":622},"step-3-keep-diffs-readable","Step 3 — Keep diffs readable",[30,625,627],{"className":32,"code":626,"language":34,"meta":35,"style":35},"set -e\ncd reports-repo\ncat > xlsx2txt.py \u003C\u003C'EOF'\nimport sys\nfrom openpyxl import load_workbook\nwb = load_workbook(sys.argv[1], read_only=True, data_only=False)\nfor ws in wb.worksheets:\n    for row in ws.iter_rows():\n        for c in row:\n            if c.value is not None:\n                print(f\"{ws.title}!{c.coordinate}\\t{c.value}\")\nEOF\nprintf '*.xlsx filter=lfs diff=xlsx merge=lfs -text\\n*.xlsm filter=lfs diff=xlsx merge=lfs -text\\n' > .gitattributes\ngit config diff.xlsx.textconv \"python $PWD\u002Fxlsx2txt.py\"\npython - \u003C\u003C'EOF'\nfrom openpyxl import load_workbook\nwb = load_workbook(\"template.xlsx\"); wb[\"Report\"][\"D3\"] = \"Variance\"; wb.save(\"template.xlsx\")\nEOF\ngit diff template.xlsx | tail -n +5\n",[14,628,629,635,641,654,659,664,669,674,679,684,689,694,698,710,728,738,743,749,754],{"__ignoreMap":35},[39,630,631,633],{"class":41,"line":42},[39,632,79],{"class":78},[39,634,82],{"class":78},[39,636,637,639],{"class":41,"line":85},[39,638,108],{"class":78},[39,640,111],{"class":49},[39,642,643,645,647,650,652],{"class":41,"line":114},[39,644,189],{"class":45},[39,646,164],{"class":163},[39,648,649],{"class":49}," xlsx2txt.py",[39,651,258],{"class":163},[39,653,262],{"class":261},[39,655,656],{"class":41,"line":150},[39,657,658],{"class":49},"import sys\n",[39,660,661],{"class":41,"line":170},[39,662,663],{"class":49},"from openpyxl import load_workbook\n",[39,665,666],{"class":41,"line":186},[39,667,668],{"class":49},"wb = load_workbook(sys.argv[1], read_only=True, data_only=False)\n",[39,670,671],{"class":41,"line":280},[39,672,673],{"class":49},"for ws in wb.worksheets:\n",[39,675,676],{"class":41,"line":286},[39,677,678],{"class":49},"    for row in ws.iter_rows():\n",[39,680,681],{"class":41,"line":292},[39,682,683],{"class":49},"        for c in row:\n",[39,685,686],{"class":41,"line":298},[39,687,688],{"class":49},"            if c.value is not None:\n",[39,690,691],{"class":41,"line":304},[39,692,693],{"class":49},"                print(f\"{ws.title}!{c.coordinate}\\t{c.value}\")\n",[39,695,696],{"class":41,"line":333},[39,697,301],{"class":261},[39,699,700,703,706,708],{"class":41,"line":342},[39,701,702],{"class":78},"printf",[39,704,705],{"class":49}," '*.xlsx filter=lfs diff=xlsx merge=lfs -text\\n*.xlsm filter=lfs diff=xlsx merge=lfs -text\\n'",[39,707,164],{"class":163},[39,709,192],{"class":49},[39,711,712,714,716,719,722,725],{"class":41,"line":353},[39,713,46],{"class":45},[39,715,129],{"class":49},[39,717,718],{"class":49}," diff.xlsx.textconv",[39,720,721],{"class":49}," \"python ",[39,723,724],{"class":97},"$PWD",[39,726,727],{"class":49},"\u002Fxlsx2txt.py\"\n",[39,729,730,732,734,736],{"class":41,"line":361},[39,731,252],{"class":45},[39,733,255],{"class":49},[39,735,258],{"class":163},[39,737,262],{"class":261},[39,739,741],{"class":41,"line":740},16,[39,742,663],{"class":49},[39,744,746],{"class":41,"line":745},17,[39,747,748],{"class":49},"wb = load_workbook(\"template.xlsx\"); wb[\"Report\"][\"D3\"] = \"Variance\"; wb.save(\"template.xlsx\")\n",[39,750,752],{"class":41,"line":751},18,[39,753,301],{"class":261},[39,755,757,759,762,764,767,770,773],{"class":41,"line":756},19,[39,758,46],{"class":45},[39,760,761],{"class":49}," diff",[39,763,315],{"class":49},[39,765,766],{"class":163}," |",[39,768,769],{"class":45}," tail",[39,771,772],{"class":78}," -n",[39,774,775],{"class":49}," +5\n",[10,777,778,779,781,782,785,786,789,790,794,795,798,799,23],{},"With ",[14,780,209],{},", ",[14,783,784],{},"git diff"," on a workbook says only that the pointer changed — a new hash and a new size — which is no help in a code review. Replacing it with ",[14,787,788],{},"diff=xlsx"," and a ",[791,792,793],"em",{},"textconv"," driver keeps LFS storage while showing a readable diff: git restores both versions of the workbook, converts each to text with the small script, and diffs the text. Here adding a ",[791,796,797],{},"Variance"," heading to the template shows up as one added line naming the cell. The textconv setting lives in each developer's git config, not in the repository; a more complete converter that also shows formats and formulas is described in ",[19,800,802],{"href":801},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fdiff-excel-files-in-git-with-a-textconv-driver\u002F","Diff Excel Files in Git with a textconv Driver",[25,804,806],{"id":805},"step-4-lock-templates-so-edits-do-not-collide","Step 4 — Lock templates so edits do not collide",[30,808,810],{"className":32,"code":809,"language":34,"meta":35,"style":35},"git lfs track \"*.xlsx\" --lockable          # adds 'lockable' to the attribute line\ngit lfs lock templates\u002Fmonthly_report.xlsx  # requires a push remote with LFS locking\n# ... edit and commit ...\ngit push\ngit lfs unlock templates\u002Fmonthly_report.xlsx\ngit lfs locks                               # who holds which lock\n",[14,811,812,828,843,848,855,867],{"__ignoreMap":35},[39,813,814,816,818,820,822,825],{"class":41,"line":42},[39,815,46],{"class":45},[39,817,50],{"class":49},[39,819,177],{"class":49},[39,821,180],{"class":49},[39,823,824],{"class":78}," --lockable",[39,826,827],{"class":56},"          # adds 'lockable' to the attribute line\n",[39,829,830,832,834,837,840],{"class":41,"line":85},[39,831,46],{"class":45},[39,833,50],{"class":49},[39,835,836],{"class":49}," lock",[39,838,839],{"class":49}," templates\u002Fmonthly_report.xlsx",[39,841,842],{"class":56},"  # requires a push remote with LFS locking\n",[39,844,845],{"class":41,"line":114},[39,846,847],{"class":56},"# ... edit and commit ...\n",[39,849,850,852],{"class":41,"line":150},[39,851,46],{"class":45},[39,853,854],{"class":49}," push\n",[39,856,857,859,861,864],{"class":41,"line":170},[39,858,46],{"class":45},[39,860,50],{"class":49},[39,862,863],{"class":49}," unlock",[39,865,866],{"class":49}," templates\u002Fmonthly_report.xlsx\n",[39,868,869,871,873,876],{"class":41,"line":186},[39,870,46],{"class":45},[39,872,50],{"class":49},[39,874,875],{"class":49}," locks",[39,877,878],{"class":56},"                               # who holds which lock\n",[10,880,881,882,885,886,889,890,893,894,897,898,900],{},"Two people editing the same text file can merge their changes; two people editing the same workbook cannot — git has no way to combine two binary zips, and one person's work is lost. LFS locking prevents that. Marking a pattern ",[14,883,884],{},"lockable"," makes matching files ",[223,887,888],{},"read-only"," in every working copy until the user takes the lock with ",[14,891,892],{},"git lfs lock",", which is recorded on the server; others see who holds it and cannot push changes to the file until it is released. The read-only behaviour surprises people the first time — a script that tries to save over a lockable template fails with ",[791,895,896],{},"Permission denied"," until the file is locked or made writable — so use ",[14,899,884],{}," for files people edit by hand, such as templates, and not for files a test or job writes.",[384,902,393,907,393,910,393,913,393,916,393,921,393,927,393,932,393,937,393,942,393,945,393,950,393,954,393,958,393,962,393,965,393,970,393,975,393,978,393,981,393,985,393,988,393,991,393,995,393,998,393,1001,393,1005,393,1008,393,1011,393,1016,393,1019],{"viewBox":903,"role":387,"ariaLabelledBy":904,"xmlns":391,"style":547},"4 0 752 108",[905,906],"lfs-lock-t","lfs-lock-d",[395,908,909],{"id":905},"Editing a lockable template",[399,911,912],{"id":906},"A lockable workbook is read-only after checkout. The editor takes the lock, which the server records, edits and commits the file, pushes, and then releases the lock so the next person can take it.",[403,914],{"x":525,"y":406,"width":556,"height":915,"fill":409},"108",[403,917],{"x":560,"y":413,"width":918,"height":919,"rx":920,"fill":416,"stroke":417,"style":566},"115.2","76.5","12",[424,922,926],{"x":923,"y":924,"style":925},"77.6","50.2","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Checkout",[424,928,931],{"x":923,"y":929,"style":930},"69.8","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","file is read-only",[41,933],{"x1":934,"y1":935,"x2":936,"y2":935,"stroke":458,"style":566},"140.2","54.2","162.2",[938,939],"polygon",{"points":940,"fill":941},"166.2,54.2 157.2,59.2 157.2,49.2","#5b5cf0",[403,943],{"x":944,"y":413,"width":918,"height":919,"rx":920,"fill":457,"stroke":458,"style":566},"171.2",[424,946,892],{"x":947,"y":948,"style":949},"228.8","41.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[424,951,953],{"x":947,"y":952,"style":930},"61.0","server records",[424,955,957],{"x":947,"y":956,"style":930},"78.5","owner",[41,959],{"x1":960,"y1":935,"x2":961,"y2":935,"stroke":458,"style":566},"291.4","313.4",[938,963],{"points":964,"fill":941},"317.4,54.2 308.4,59.2 308.4,49.2",[403,966],{"x":967,"y":413,"width":918,"height":919,"rx":920,"fill":968,"stroke":969,"style":566},"322.4","#fdefd8","var(--gold,#b4740a)",[424,971,974],{"x":972,"y":948,"style":973},"380.0","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Edit and",[424,976,977],{"x":972,"y":453,"style":973},"commit",[424,979,980],{"x":972,"y":956,"style":930},"in Excel",[41,982],{"x1":983,"y1":935,"x2":984,"y2":935,"stroke":458,"style":566},"442.6","464.6",[938,986],{"points":987,"fill":941},"468.6,54.2 459.6,59.2 459.6,49.2",[403,989],{"x":990,"y":413,"width":918,"height":919,"rx":920,"fill":968,"stroke":969,"style":566},"473.6",[424,992,994],{"x":993,"y":948,"style":973},"531.2","Push",[424,996,997],{"x":993,"y":952,"style":930},"others blocked",[424,999,1000],{"x":993,"y":956,"style":930},"meanwhile",[41,1002],{"x1":1003,"y1":935,"x2":1004,"y2":935,"stroke":458,"style":566},"593.8","615.8",[938,1006],{"points":1007,"fill":941},"619.8,54.2 610.8,59.2 610.8,49.2",[403,1009],{"x":1010,"y":413,"width":918,"height":919,"rx":920,"fill":597,"stroke":598,"style":566},"624.8",[424,1012,1015],{"x":1013,"y":948,"style":1014},"682.4","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","git lfs unlock",[424,1017,1018],{"x":1013,"y":952,"style":930},"next person can",[424,1020,1021],{"x":1013,"y":956,"style":930},"edit",[25,1023,1025],{"id":1024},"moving-existing-workbooks-into-lfs-and-ci","Moving existing workbooks into LFS, and CI",[10,1027,1028,1029,1032],{},"If workbooks are already in the repository's history, tracking them now only affects future commits. ",[14,1030,1031],{},"git lfs migrate import --include=\"*.xlsx,*.xlsm\" --everything"," rewrites every branch so that past versions become pointers too, which shrinks the repository — but it changes every commit hash, so it needs a force-push and a fresh clone by everyone, and is best done at a quiet moment with the team's agreement. A gentler option is to leave history alone and accept that the old blobs remain; the repository stops growing from that point.",[10,1034,1035,1036,1039,1040,1043],{},"In CI, make sure the checkout step fetches LFS files — on GitHub Actions, ",[14,1037,1038],{},"actions\u002Fcheckout"," with ",[14,1041,1042],{},"lfs: true"," — or the tests will open three-line pointer files instead of workbooks and fail with confusing \"not a zip file\" errors. Cache the LFS objects between runs if the workbooks are large, because LFS bandwidth counts against the hosting quota.",[25,1045,1047],{"id":1046},"what-belongs-in-lfs","What belongs in LFS",[10,1049,1050,1051,1055],{},"LFS is for workbooks that are part of the project: templates, reference data, golden files for tests, sample inputs used in documentation. It is not a place for report outputs, data extracts or anything containing real personal data — those should not be in the repository at all, LFS or not, and a pre-commit hook can enforce that, as in ",[19,1052,1054],{"href":1053},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fblock-large-or-sensitive-excel-files-with-a-pre-commit-hook\u002F","Block Large or Sensitive Excel Files with a Pre-commit Hook",". Small test fixtures can often be generated in code instead, which keeps them reviewable as text.",[25,1057,1059],{"id":1058},"common-pitfalls","Common pitfalls",[1061,1062,1063,1072,1078,1089,1098],"ul",{},[1064,1065,1066,1071],"li",{},[223,1067,1068,1069,23],{},"Adding workbooks before ",[14,1070,201],{}," They go into plain git; track first.",[1064,1073,1074,1077],{},[223,1075,1076],{},"Pointer files in CI."," Enable LFS in the checkout step.",[1064,1079,1080,1083,1084,1086,1087,23],{},[223,1081,1082],{},"Unreadable diffs."," Use ",[14,1085,788],{}," with a textconv driver instead of ",[14,1088,209],{},[1064,1090,1091,1094,1095,1097],{},[223,1092,1093],{},"Lockable files written by scripts."," They are read-only until locked; use ",[14,1096,884],{}," only for hand-edited files.",[1064,1099,1100,1103],{},[223,1101,1102],{},"Outputs and extracts in LFS."," Keep generated and real data out of the repository entirely.",[25,1105,1107],{"id":1106},"conclusion","Conclusion",[10,1109,1110,1111,1114,1115,1117,1118,1120,1121,1123,1124,1127],{},"To keep Excel files in git without bloating it, run ",[14,1112,1113],{},"git lfs track \"*.xlsx\" \"*.xlsm\""," and commit ",[14,1116,201],{}," before any workbooks. Commits then hold small pointers and the workbooks live on the LFS server. Swap ",[14,1119,209],{}," for a textconv driver to keep diffs readable, mark hand-edited templates ",[14,1122,884],{}," so edits cannot collide, enable LFS in CI checkouts, and use ",[14,1125,1126],{},"git lfs migrate"," only when rewriting history is acceptable.",[25,1129,1131],{"id":1130},"frequently-asked-questions","Frequently asked questions",[10,1133,1134,1137],{},[223,1135,1136],{},"Should Excel files be stored in Git LFS?","\nWorkbooks that belong to the project, such as templates and test golden files, should; LFS keeps one small pointer per version in history instead of a full copy.",[10,1139,1140,1143],{},[223,1141,1142],{},"What does git store for a file tracked by LFS?","\nA three-line pointer with the LFS spec version, the file's SHA-256 hash and its size; the content is stored on the LFS server.",[10,1145,1146,1149,1150,1152,1153,1155,1156,1158],{},[223,1147,1148],{},"Why is my Excel file read-only after checkout?","\nIts pattern is marked ",[14,1151,884],{}," in ",[14,1154,201],{},"; take the lock with ",[14,1157,892],{}," before editing.",[10,1160,1161,1164,1165,1168],{},[223,1162,1163],{},"How do I move Excel files already in history into LFS?","\nRun ",[14,1166,1167],{},"git lfs migrate import --include=\"*.xlsx\" --everything",", which rewrites history and requires a force-push and fresh clones.",[25,1170,1172],{"id":1171},"related","Related",[1061,1174,1175,1181,1186,1193],{},[1064,1176,1177,1178,1180],{},"Up one level: ",[19,1179,22],{"href":21}," — reliable, testable projects.",[1064,1182,1183,1185],{},[19,1184,802],{"href":801}," — readable workbook diffs.",[1064,1187,1188,1192],{},[19,1189,1191],{"href":1190},"\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"," — golden files worth storing.",[1064,1194,1195,1199],{},[19,1196,1198],{"href":1197},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fmake-excel-output-reproducible\u002F","Make Excel Output Reproducible"," — fewer pointless new versions.",[1201,1202,1203],"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 .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .sXKb-, html code.shiki .sXKb-{--shiki-default:#032F62;--shiki-dark:#F0F3F6}",{"title":35,"searchDepth":85,"depth":85,"links":1205},[1206,1207,1208,1209,1210,1211,1212,1213,1214,1215,1216],{"id":27,"depth":85,"text":28},{"id":67,"depth":85,"text":68},{"id":229,"depth":85,"text":230},{"id":622,"depth":85,"text":623},{"id":805,"depth":85,"text":806},{"id":1024,"depth":85,"text":1025},{"id":1046,"depth":85,"text":1047},{"id":1058,"depth":85,"text":1059},{"id":1106,"depth":85,"text":1107},{"id":1130,"depth":85,"text":1131},{"id":1171,"depth":85,"text":1172},"2026-10-02","Keep report templates, reference workbooks and golden test files in a git repository without bloating it: track .xlsx and .xlsm with Git LFS so git stores a small pointer and the workbook lives on the LFS server, see what is actually committed, keep readable diffs with a textconv driver, lock binary files so two people cannot edit the same template, and move existing workbooks into LFS.","md",[1221,1223,1225,1227],{"q":1136,"a":1222},"Workbooks that belong to the project, such as templates and test golden files, should; LFS keeps one small pointer per version in history instead of a full copy.",{"q":1142,"a":1224},"A three-line pointer with the LFS spec version, the file's SHA-256 hash and its size; the content is stored on the LFS server.",{"q":1148,"a":1226},"Its pattern is marked lockable in .gitattributes; take the lock with git lfs lock before editing.",{"q":1163,"a":1228},"Run git lfs migrate import --include=\"*.xlsx\" --everything, which rewrites history and requires a force-push and fresh clones.","git lfs excel files",null,{"breadcrumb":1232},[1233,1236,1239],{"name":1234,"item":1235},"Home","\u002F",{"name":1237,"item":1238},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":22,"item":21},true,"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-excel-files-in-git-with-git-lfs",{"title":5,"description":1243},"Git LFS for Excel files: track .xlsx and .xlsm, what the pointer file looks like, readable diffs with textconv, lockable templates and read-only files, migrating history, and CI checkout.","store-excel-files-in-git-with-git-lfs","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-excel-files-in-git-with-git-lfs\u002Findex",[1247],"reliable-jobs","how-to","Mfw-LsE_EN_vYs6I-7X6Zs8k8Meo3H3TZeaZ0XKj_6E",[1251,1255],{"title":1252,"path":1253,"stem":1254,"children":-1},"Store Credentials for Excel Report Scripts Safely","\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-credentials-for-excel-report-scripts-safely","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fstore-credentials-for-excel-report-scripts-safely\u002Findex",{"title":1256,"path":1257,"stem":1258,"children":-1},"Test Excel Cleaning Code with Hypothesis","\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Ftest-excel-cleaning-code-with-hypothesis","automating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Ftest-excel-cleaning-code-with-hypothesis\u002Findex",[1260,1262],{"path":1261,"title":1237},"\u002Fautomating-reporting-workflows",{"path":1263,"title":22},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts",[],[1266,1270,1273,1276,1279,1282],{"path":1267,"title":1268,"kind":1269},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation","Error Handling and Logging in Excel Automation","guide",{"path":1271,"title":1272,"kind":1248},"\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":1274,"title":1275,"kind":1248},"\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":1277,"title":1278,"kind":1248},"\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":1280,"title":1281,"kind":1248},"\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":1283,"title":1284,"kind":1248},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports","Prevent Formula Injection in Excel Exports from Python",1790972142859]