[{"data":1,"prerenderedAt":1916},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports":3,"surround:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports":1881,"ancestors:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports":1890,"children:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports":1895,"related:\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports":1896},{"id":4,"title":5,"body":6,"dateModified":1847,"datePublished":1847,"description":1848,"extension":1849,"faq":1850,"keyword":1859,"kind":1860,"meta":1861,"navigation":121,"path":1870,"seo":1871,"slug":1874,"stem":1875,"topics":1876,"type":1879,"__hash__":1880},"docs\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports\u002Findex.md","Prevent Formula Injection in Excel Exports from Python",{"type":7,"value":8,"toc":1835},"minimark",[9,29,34,65,82,86,93,401,429,608,612,618,633,778,788,802,969,980,984,1001,1179,1185,1287,1291,1309,1312,1390,1394,1397,1667,1678,1682,1737,1741,1767,1771,1777,1783,1789,1795,1799,1831],[10,11,12,13,17,18,22,23,28],"p",{},"An export takes rows from a database or a web form and writes them to a spreadsheet. If a value typed by a user starts with ",[14,15,16],"code",{},"=",", the Python writer may store it as a ",[19,20,21],"em",{},"formula",", and when a colleague opens the file, the spreadsheet evaluates it. A formula can display a convincing link to a phishing page, pull data from other cells into a visible message, and in some configurations try to reach external resources. This is known as formula injection or CSV injection, and it is the export's responsibility to stop it: the person opening the file cannot tell a malicious cell from a real one. This page shows which writers are affected and how to write data safely. It is part of ",[24,25,27],"a",{"href":26},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002F","Error Handling and Logging in Excel Automation",".",[30,31,33],"h2",{"id":32},"prerequisites","Prerequisites",[35,36,41],"pre",{"className":37,"code":38,"language":39,"meta":40,"style":40},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl xlsxwriter\n","bash","",[14,42,43],{"__ignoreMap":40},[44,45,48,52,56,59,62],"span",{"class":46,"line":47},"line",1,[44,49,51],{"class":50},"sMTad","pip",[44,53,55],{"class":54},"srMev"," install",[44,57,58],{"class":54}," pandas",[44,60,61],{"class":54}," openpyxl",[44,63,64],{"class":54}," xlsxwriter\n",[10,66,67,70,71,74,75,78,79,28],{},[14,68,69],{},"feedback.pkl"," stands in for data from a customer feedback form. Two values were entered maliciously or mischievously: a \"name\" that is a ",[14,72,73],{},"HYPERLINK"," formula and a comment that is arithmetic. Two more are innocent but start with characters spreadsheets treat specially: a phone number starting with ",[14,76,77],{},"+"," and a comment starting with ",[14,80,81],{},"@",[30,83,85],{"id":84},"step-1-see-what-the-writers-do-by-default","Step 1 — See what the writers do by default",[10,87,88,89,92],{},"Write the DataFrame with each pandas engine, and with openpyxl directly, then ask openpyxl which cells were stored as formulas (",[14,90,91],{},"data_type == \"f\"","):",[35,94,98],{"className":95,"code":96,"language":97,"meta":40,"style":40},"language-python shiki shiki-themes github-light github-dark-high-contrast","from openpyxl import Workbook, load_workbook\n\nfeedback = pd.read_pickle(\"feedback.pkl\")\n\ndef formula_cells(path):\n    ws = load_workbook(path).active\n    return [c.coordinate for row in ws.iter_rows() for c in row if c.data_type == \"f\"]\n\nfor engine in (\"openpyxl\", \"xlsxwriter\"):\n    feedback.to_excel(f\"export_{engine}.xlsx\", index=False, engine=engine)\n    print(f\"pandas + {engine:10}\", formula_cells(f\"export_{engine}.xlsx\"))\n\nwb = Workbook()\nfor row in [feedback.columns.tolist()] + feedback.values.tolist():\n    wb.active.append(row)\nwb.save(\"export_append.xlsx\")\nprint(\"openpyxl append     \", formula_cells(\"export_append.xlsx\"))\n","python",[14,99,100,116,123,140,145,158,169,214,219,244,290,334,339,350,367,373,384],{"__ignoreMap":40},[44,101,102,106,110,113],{"class":46,"line":47},[44,103,105],{"class":104},"s-kum","from",[44,107,109],{"class":108},"skGVy"," openpyxl ",[44,111,112],{"class":104},"import",[44,114,115],{"class":108}," Workbook, load_workbook\n",[44,117,119],{"class":46,"line":118},2,[44,120,122],{"emptyLinePlaceholder":121},true,"\n",[44,124,126,129,131,134,137],{"class":46,"line":125},3,[44,127,128],{"class":108},"feedback ",[44,130,16],{"class":104},[44,132,133],{"class":108}," pd.read_pickle(",[44,135,136],{"class":54},"\"feedback.pkl\"",[44,138,139],{"class":108},")\n",[44,141,143],{"class":46,"line":142},4,[44,144,122],{"emptyLinePlaceholder":121},[44,146,148,151,155],{"class":46,"line":147},5,[44,149,150],{"class":104},"def",[44,152,154],{"class":153},"s_Opv"," formula_cells",[44,156,157],{"class":108},"(path):\n",[44,159,161,164,166],{"class":46,"line":160},6,[44,162,163],{"class":108},"    ws ",[44,165,16],{"class":104},[44,167,168],{"class":108}," load_workbook(path).active\n",[44,170,172,175,178,181,184,187,190,192,195,197,199,202,205,208,211],{"class":46,"line":171},7,[44,173,174],{"class":104},"    return",[44,176,177],{"class":108}," [c.coordinate ",[44,179,180],{"class":104},"for",[44,182,183],{"class":108}," row ",[44,185,186],{"class":104},"in",[44,188,189],{"class":108}," ws.iter_rows() ",[44,191,180],{"class":104},[44,193,194],{"class":108}," c ",[44,196,186],{"class":104},[44,198,183],{"class":108},[44,200,201],{"class":104},"if",[44,203,204],{"class":108}," c.data_type ",[44,206,207],{"class":104},"==",[44,209,210],{"class":54}," \"f\"",[44,212,213],{"class":108},"]\n",[44,215,217],{"class":46,"line":216},8,[44,218,122],{"emptyLinePlaceholder":121},[44,220,222,224,227,229,232,235,238,241],{"class":46,"line":221},9,[44,223,180],{"class":104},[44,225,226],{"class":108}," engine ",[44,228,186],{"class":104},[44,230,231],{"class":108}," (",[44,233,234],{"class":54},"\"openpyxl\"",[44,236,237],{"class":108},", ",[44,239,240],{"class":54},"\"xlsxwriter\"",[44,242,243],{"class":108},"):\n",[44,245,247,250,253,256,260,263,266,269,271,275,277,281,283,285,287],{"class":46,"line":246},10,[44,248,249],{"class":108},"    feedback.to_excel(",[44,251,252],{"class":104},"f",[44,254,255],{"class":54},"\"export_",[44,257,259],{"class":258},"sSjpA","{",[44,261,262],{"class":108},"engine",[44,264,265],{"class":258},"}",[44,267,268],{"class":54},".xlsx\"",[44,270,237],{"class":108},[44,272,274],{"class":273},"sa561","index",[44,276,16],{"class":104},[44,278,280],{"class":279},"sP0c6","False",[44,282,237],{"class":108},[44,284,262],{"class":273},[44,286,16],{"class":104},[44,288,289],{"class":108},"engine)\n",[44,291,293,296,299,301,304,306,308,311,313,316,319,321,323,325,327,329,331],{"class":46,"line":292},11,[44,294,295],{"class":279},"    print",[44,297,298],{"class":108},"(",[44,300,252],{"class":104},[44,302,303],{"class":54},"\"pandas + ",[44,305,259],{"class":258},[44,307,262],{"class":108},[44,309,310],{"class":104},":10",[44,312,265],{"class":258},[44,314,315],{"class":54},"\"",[44,317,318],{"class":108},", formula_cells(",[44,320,252],{"class":104},[44,322,255],{"class":54},[44,324,259],{"class":258},[44,326,262],{"class":108},[44,328,265],{"class":258},[44,330,268],{"class":54},[44,332,333],{"class":108},"))\n",[44,335,337],{"class":46,"line":336},12,[44,338,122],{"emptyLinePlaceholder":121},[44,340,342,345,347],{"class":46,"line":341},13,[44,343,344],{"class":108},"wb ",[44,346,16],{"class":104},[44,348,349],{"class":108}," Workbook()\n",[44,351,353,355,357,359,362,364],{"class":46,"line":352},14,[44,354,180],{"class":104},[44,356,183],{"class":108},[44,358,186],{"class":104},[44,360,361],{"class":108}," [feedback.columns.tolist()] ",[44,363,77],{"class":104},[44,365,366],{"class":108}," feedback.values.tolist():\n",[44,368,370],{"class":46,"line":369},15,[44,371,372],{"class":108},"    wb.active.append(row)\n",[44,374,376,379,382],{"class":46,"line":375},16,[44,377,378],{"class":108},"wb.save(",[44,380,381],{"class":54},"\"export_append.xlsx\"",[44,383,139],{"class":108},[44,385,387,390,392,395,397,399],{"class":46,"line":386},17,[44,388,389],{"class":279},"print",[44,391,298],{"class":108},[44,393,394],{"class":54},"\"openpyxl append     \"",[44,396,318],{"class":108},[44,398,381],{"class":54},[44,400,333],{"class":108},[10,402,403,404,406,407,409,410,413,414,237,416,419,420,422,423,425,426,428],{},"Every route stores the two values that start with ",[14,405,16],{}," as formulas, in the same two cells. The ",[14,408,73],{}," cell would show \"Reset your password\" as a clickable link in the Customer column. In ",[14,411,412],{},".xlsx"," files, values starting with ",[14,415,77],{},[14,417,418],{},"-"," or ",[14,421,81],{}," were stored as text by all three writers; the formula rule for ",[14,424,412],{}," output is \"starts with ",[14,427,16],{},"\".",[430,431,439,440,439,444,439,448,439,456,439,466,439,477,439,484,439,489,439,493,439,499,439,504,439,507,439,513,439,518,439,522,439,526,439,529,439,534,439,541,439,546,439,549,439,552,439,556,439,559,439,562,439,568,439,572,439,575,439,578,439,582,439,585,439,590,439,597,439,602,439,605],"svg",{"viewBox":432,"role":433,"ariaLabelledBy":434,"xmlns":437,"style":438},"4 0 752 142","img",[435,436],"fi-flow-t","fi-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  ",[441,442,443],"title",{"id":435},"How a form entry becomes a live formula",[445,446,447],"desc",{"id":436},"A user types text starting with an equals sign into a web form. It is stored in the database as text. The export writes it with a library that turns strings starting with equals into formulas. When the file is opened, the spreadsheet evaluates it, for example showing a phishing link.",[449,450],"rect",{"x":451,"y":452,"width":453,"height":454,"fill":455},"4","0","752","142","#ffffff",[449,457],{"x":458,"y":459,"width":460,"height":461,"rx":462,"fill":463,"stroke":464,"style":465},"20.0","16.0","115.2","110.5","12","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[467,468,470],"g",{"transform":469},"translate(65.6,29.0) scale(1.000)",[471,472],"path",{"d":473,"fill":474,"stroke":475,"style":476},"M12 3a9 9 0 1 0 .01 0z M3 12h18 M12 3c3.2 3 3.2 15 0 18 M12 3c-3.2 3-3.2 15 0 18","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[478,479,483],"text",{"x":480,"y":481,"style":482},"77.6","75.5","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Web form",[478,485,488],{"x":480,"y":486,"style":487},"95.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","user types",[478,490,492],{"x":480,"y":491,"style":487},"112.5","=HYPERLINK(...)",[46,494],{"x1":495,"y1":496,"x2":497,"y2":496,"stroke":498,"style":465},"140.2","71.2","162.2","var(--brand,#5b5cf0)",[500,501],"polygon",{"points":502,"fill":503},"166.2,71.2 157.2,76.2 157.2,66.2","#5b5cf0",[449,505],{"x":506,"y":459,"width":460,"height":461,"rx":462,"fill":463,"stroke":464,"style":465},"171.2",[467,508,510],{"transform":509},"translate(216.8,37.8) scale(1.000)",[471,511],{"d":512,"fill":474,"stroke":475,"style":476},"M5 6c0-1.7 3.1-3 7-3s7 1.3 7 3-3.1 3-7 3-7-1.3-7-3z M5 6v12c0 1.7 3.1 3 7 3s7-1.3 7-3V6 M5 12c0 1.7 3.1 3 7 3s7-1.3 7-3",[478,514,517],{"x":515,"y":516,"style":482},"228.8","84.2","Database",[478,519,521],{"x":515,"y":520,"style":487},"103.8","stored as text",[46,523],{"x1":524,"y1":496,"x2":525,"y2":496,"stroke":498,"style":465},"291.4","313.4",[500,527],{"points":528,"fill":503},"317.4,71.2 308.4,76.2 308.4,66.2",[449,530],{"x":531,"y":459,"width":460,"height":461,"rx":462,"fill":532,"stroke":533,"style":465},"322.4","#fce9e9","var(--danger,#dc2626)",[467,535,537],{"transform":536},"translate(368.0,29.0) scale(1.000)",[471,538],{"d":539,"fill":474,"stroke":540,"style":476},"M9 8l-4 4 4 4 M15 8l4 4-4 4","var(--accent-ink,#be185d)",[478,542,545],{"x":543,"y":481,"style":544},"380.0","font-size:13.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Export",[478,547,548],{"x":543,"y":486,"style":487},"writer makes it",[478,550,551],{"x":543,"y":491,"style":487},"a formula",[46,553],{"x1":554,"y1":496,"x2":555,"y2":496,"stroke":498,"style":465},"442.6","464.6",[500,557],{"points":558,"fill":503},"468.6,71.2 459.6,76.2 459.6,66.2",[449,560],{"x":561,"y":459,"width":460,"height":461,"rx":462,"fill":532,"stroke":533,"style":465},"473.6",[467,563,565],{"transform":564},"translate(519.2,29.0) scale(1.000)",[471,566],{"d":567,"fill":474,"stroke":540,"style":476},"M6 3h9l4 4v14H6z M15 3v4h4 M9.5 11.5l4.5 6 M14 11.5l-4.5 6",[478,569,571],{"x":570,"y":481,"style":544},"531.2","Spreadsheet",[478,573,574],{"x":570,"y":486,"style":487},"evaluates on",[478,576,577],{"x":570,"y":491,"style":487},"open",[46,579],{"x1":580,"y1":496,"x2":581,"y2":496,"stroke":498,"style":465},"593.8","615.8",[500,583],{"points":584,"fill":503},"619.8,71.2 610.8,76.2 610.8,66.2",[449,586],{"x":587,"y":459,"width":460,"height":461,"rx":462,"fill":588,"stroke":589,"style":465},"624.8","#fdefd8","var(--gold,#b4740a)",[467,591,593],{"transform":592},"translate(670.4,29.0) scale(1.000)",[471,594],{"d":595,"fill":474,"stroke":596,"style":476},"M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M4 21c0-4 3.6-7 8-7s8 3 8 7","var(--gold-ink,#7a4e06)",[478,598,601],{"x":599,"y":481,"style":600},"682.4","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Reader",[478,603,604],{"x":599,"y":486,"style":487},"sees a",[478,606,607],{"x":599,"y":491,"style":487},"real-looking link",[30,609,611],{"id":610},"step-2-write-data-as-text-in-xlsx-files","Step 2 — Write data as text in .xlsx files",[10,613,614,615,617],{},"The fix for ",[14,616,412],{}," output is to write data values as strings, so no user value is ever stored as a formula, while still writing the report's own formulas explicitly.",[10,619,620,624,625,628,629,632],{},[621,622,623],"strong",{},"pandas with xlsxwriter",": turn off ",[14,626,627],{},"strings_to_formulas",". Data strings are written with ",[14,630,631],{},"write_string","; numbers and dates are unaffected:",[35,634,636],{"className":95,"code":635,"language":97,"meta":40,"style":40},"with pd.ExcelWriter(\"safe_xlsxwriter.xlsx\", engine=\"xlsxwriter\",\n                    engine_kwargs={\"options\": {\"strings_to_formulas\": False}}) as writer:\n    feedback.to_excel(writer, index=False, sheet_name=\"Feedback\")\n    ws = writer.sheets[\"Feedback\"]\n    ws.write(\"B8\", \"Average score\")\n    ws.write_formula(\"C8\", \"=AVERAGE(C2:C6)\")           # the report's own formula, written on purpose\nprint(\"formula cells:\", formula_cells(\"safe_xlsxwriter.xlsx\"))\n",[14,637,638,660,692,715,728,743,763],{"__ignoreMap":40},[44,639,640,643,646,649,651,653,655,657],{"class":46,"line":47},[44,641,642],{"class":104},"with",[44,644,645],{"class":108}," pd.ExcelWriter(",[44,647,648],{"class":54},"\"safe_xlsxwriter.xlsx\"",[44,650,237],{"class":108},[44,652,262],{"class":273},[44,654,16],{"class":104},[44,656,240],{"class":54},[44,658,659],{"class":108},",\n",[44,661,662,665,667,669,672,675,678,681,683,686,689],{"class":46,"line":118},[44,663,664],{"class":273},"                    engine_kwargs",[44,666,16],{"class":104},[44,668,259],{"class":108},[44,670,671],{"class":54},"\"options\"",[44,673,674],{"class":108},": {",[44,676,677],{"class":54},"\"strings_to_formulas\"",[44,679,680],{"class":108},": ",[44,682,280],{"class":279},[44,684,685],{"class":108},"}}) ",[44,687,688],{"class":104},"as",[44,690,691],{"class":108}," writer:\n",[44,693,694,697,699,701,703,705,708,710,713],{"class":46,"line":125},[44,695,696],{"class":108},"    feedback.to_excel(writer, ",[44,698,274],{"class":273},[44,700,16],{"class":104},[44,702,280],{"class":279},[44,704,237],{"class":108},[44,706,707],{"class":273},"sheet_name",[44,709,16],{"class":104},[44,711,712],{"class":54},"\"Feedback\"",[44,714,139],{"class":108},[44,716,717,719,721,724,726],{"class":46,"line":142},[44,718,163],{"class":108},[44,720,16],{"class":104},[44,722,723],{"class":108}," writer.sheets[",[44,725,712],{"class":54},[44,727,213],{"class":108},[44,729,730,733,736,738,741],{"class":46,"line":147},[44,731,732],{"class":108},"    ws.write(",[44,734,735],{"class":54},"\"B8\"",[44,737,237],{"class":108},[44,739,740],{"class":54},"\"Average score\"",[44,742,139],{"class":108},[44,744,745,748,751,753,756,759],{"class":46,"line":160},[44,746,747],{"class":108},"    ws.write_formula(",[44,749,750],{"class":54},"\"C8\"",[44,752,237],{"class":108},[44,754,755],{"class":54},"\"=AVERAGE(C2:C6)\"",[44,757,758],{"class":108},")           ",[44,760,762],{"class":761},"s-wDw","# the report's own formula, written on purpose\n",[44,764,765,767,769,772,774,776],{"class":46,"line":171},[44,766,389],{"class":279},[44,768,298],{"class":108},[44,770,771],{"class":54},"\"formula cells:\"",[44,773,318],{"class":108},[44,775,648],{"class":54},[44,777,333],{"class":108},[10,779,780,781,784,785,787],{},"Only ",[14,782,783],{},"C8",", the formula the report meant to write, is a formula; the ",[14,786,73],{}," text is stored as text and shown as text.",[10,789,790,793,794,797,798,801],{},[621,791,792],{},"openpyxl",": after assigning a value, set the cell's ",[14,795,796],{},"data_type"," to ",[14,799,800],{},"\"s\""," for every string that came from data. A helper that writes a row keeps it in one place:",[35,803,805],{"className":95,"code":804,"language":97,"meta":40,"style":40},"def append_as_data(ws, values):\n    ws.append(values)\n    for cell in ws[ws.max_row]:\n        if isinstance(cell.value, str):\n            cell.data_type = \"s\"\n\nwb = Workbook()\nws = wb.active\nappend_as_data(ws, feedback.columns.tolist())\nfor row in feedback.values.tolist():\n    append_as_data(ws, row)\nws[\"C8\"] = \"=AVERAGE(C2:C6)\"\nwb.save(\"safe_openpyxl.xlsx\")\nprint(\"formula cells:\", formula_cells(\"safe_openpyxl.xlsx\"))\nprint(\"B3 stored as:\", repr(load_workbook(\"safe_openpyxl.xlsx\").active[\"B3\"].value))\n",[14,806,807,817,822,835,851,861,865,873,883,888,898,903,918,927,941],{"__ignoreMap":40},[44,808,809,811,814],{"class":46,"line":47},[44,810,150],{"class":104},[44,812,813],{"class":153}," append_as_data",[44,815,816],{"class":108},"(ws, values):\n",[44,818,819],{"class":46,"line":118},[44,820,821],{"class":108},"    ws.append(values)\n",[44,823,824,827,830,832],{"class":46,"line":125},[44,825,826],{"class":104},"    for",[44,828,829],{"class":108}," cell ",[44,831,186],{"class":104},[44,833,834],{"class":108}," ws[ws.max_row]:\n",[44,836,837,840,843,846,849],{"class":46,"line":142},[44,838,839],{"class":104},"        if",[44,841,842],{"class":279}," isinstance",[44,844,845],{"class":108},"(cell.value, ",[44,847,848],{"class":279},"str",[44,850,243],{"class":108},[44,852,853,856,858],{"class":46,"line":147},[44,854,855],{"class":108},"            cell.data_type ",[44,857,16],{"class":104},[44,859,860],{"class":54}," \"s\"\n",[44,862,863],{"class":46,"line":160},[44,864,122],{"emptyLinePlaceholder":121},[44,866,867,869,871],{"class":46,"line":171},[44,868,344],{"class":108},[44,870,16],{"class":104},[44,872,349],{"class":108},[44,874,875,878,880],{"class":46,"line":216},[44,876,877],{"class":108},"ws ",[44,879,16],{"class":104},[44,881,882],{"class":108}," wb.active\n",[44,884,885],{"class":46,"line":221},[44,886,887],{"class":108},"append_as_data(ws, feedback.columns.tolist())\n",[44,889,890,892,894,896],{"class":46,"line":246},[44,891,180],{"class":104},[44,893,183],{"class":108},[44,895,186],{"class":104},[44,897,366],{"class":108},[44,899,900],{"class":46,"line":292},[44,901,902],{"class":108},"    append_as_data(ws, row)\n",[44,904,905,908,910,913,915],{"class":46,"line":336},[44,906,907],{"class":108},"ws[",[44,909,750],{"class":54},[44,911,912],{"class":108},"] ",[44,914,16],{"class":104},[44,916,917],{"class":54}," \"=AVERAGE(C2:C6)\"\n",[44,919,920,922,925],{"class":46,"line":341},[44,921,378],{"class":108},[44,923,924],{"class":54},"\"safe_openpyxl.xlsx\"",[44,926,139],{"class":108},[44,928,929,931,933,935,937,939],{"class":46,"line":352},[44,930,389],{"class":279},[44,932,298],{"class":108},[44,934,771],{"class":54},[44,936,318],{"class":108},[44,938,924],{"class":54},[44,940,333],{"class":108},[44,942,943,945,947,950,952,955,958,960,963,966],{"class":46,"line":369},[44,944,389],{"class":279},[44,946,298],{"class":108},[44,948,949],{"class":54},"\"B3 stored as:\"",[44,951,237],{"class":108},[44,953,954],{"class":279},"repr",[44,956,957],{"class":108},"(load_workbook(",[44,959,924],{"class":54},[44,961,962],{"class":108},").active[",[44,964,965],{"class":54},"\"B3\"",[44,967,968],{"class":108},"].value))\n",[10,970,971,972,975,976,979],{},"The value is kept exactly as the user typed it, ",[14,973,974],{},"=1+1",", but as text. Nothing is altered, which matters for exports people search, filter and reconcile. Note that ",[14,977,978],{},"ws.append"," still decides the type first and the helper then overrides it, so the helper must be used for every data row; a single row appended directly brings the problem back. The test in Step 4 is what catches that.",[30,981,983],{"id":982},"step-3-csv-exports-need-escaping","Step 3 — CSV exports need escaping",[10,985,986,987,989,990,237,992,994,995,997,998,1000],{},"A CSV file has no cell types: the spreadsheet decides what each value is when it opens the file, and it treats a leading ",[14,988,16],{}," as a formula. Excel also treats values starting with ",[14,991,77],{},[14,993,418],{}," and ",[14,996,81],{}," as formulas, as documented in the OWASP guidance on CSV injection; LibreOffice's default import evaluates ",[14,999,16],{},". The standard defence is to prefix any value starting with one of those characters, or with a tab or carriage return, with a single quote, so it is read as text:",[35,1002,1004],{"className":95,"code":1003,"language":97,"meta":40,"style":40},"DANGEROUS = (\"=\", \"+\", \"-\", \"@\", \"\\t\", \"\\r\")\n\ndef neutralise(value):\n    if isinstance(value, str) and value.startswith(DANGEROUS):\n        return \"'\" + value\n    return value\n\nsafe_csv = feedback.map(neutralise)\nsafe_csv.to_csv(\"feedback_safe.csv\", index=False)\nfrom pathlib import Path\nprint(Path(\"feedback_safe.csv\").read_text(encoding=\"utf-8\"))\n",[14,1005,1006,1054,1058,1068,1093,1107,1113,1117,1127,1145,1157],{"__ignoreMap":40},[44,1007,1008,1011,1014,1016,1019,1021,1024,1026,1029,1031,1034,1036,1038,1041,1043,1045,1047,1050,1052],{"class":46,"line":47},[44,1009,1010],{"class":279},"DANGEROUS",[44,1012,1013],{"class":104}," =",[44,1015,231],{"class":108},[44,1017,1018],{"class":54},"\"=\"",[44,1020,237],{"class":108},[44,1022,1023],{"class":54},"\"+\"",[44,1025,237],{"class":108},[44,1027,1028],{"class":54},"\"-\"",[44,1030,237],{"class":108},[44,1032,1033],{"class":54},"\"@\"",[44,1035,237],{"class":108},[44,1037,315],{"class":54},[44,1039,1040],{"class":258},"\\t",[44,1042,315],{"class":54},[44,1044,237],{"class":108},[44,1046,315],{"class":54},[44,1048,1049],{"class":258},"\\r",[44,1051,315],{"class":54},[44,1053,139],{"class":108},[44,1055,1056],{"class":46,"line":118},[44,1057,122],{"emptyLinePlaceholder":121},[44,1059,1060,1062,1065],{"class":46,"line":125},[44,1061,150],{"class":104},[44,1063,1064],{"class":153}," neutralise",[44,1066,1067],{"class":108},"(value):\n",[44,1069,1070,1073,1075,1078,1080,1083,1086,1089,1091],{"class":46,"line":142},[44,1071,1072],{"class":104},"    if",[44,1074,842],{"class":279},[44,1076,1077],{"class":108},"(value, ",[44,1079,848],{"class":279},[44,1081,1082],{"class":108},") ",[44,1084,1085],{"class":104},"and",[44,1087,1088],{"class":108}," value.startswith(",[44,1090,1010],{"class":279},[44,1092,243],{"class":108},[44,1094,1095,1098,1101,1104],{"class":46,"line":147},[44,1096,1097],{"class":104},"        return",[44,1099,1100],{"class":54}," \"'\"",[44,1102,1103],{"class":104}," +",[44,1105,1106],{"class":108}," value\n",[44,1108,1109,1111],{"class":46,"line":160},[44,1110,174],{"class":104},[44,1112,1106],{"class":108},[44,1114,1115],{"class":46,"line":171},[44,1116,122],{"emptyLinePlaceholder":121},[44,1118,1119,1122,1124],{"class":46,"line":216},[44,1120,1121],{"class":108},"safe_csv ",[44,1123,16],{"class":104},[44,1125,1126],{"class":108}," feedback.map(neutralise)\n",[44,1128,1129,1132,1135,1137,1139,1141,1143],{"class":46,"line":221},[44,1130,1131],{"class":108},"safe_csv.to_csv(",[44,1133,1134],{"class":54},"\"feedback_safe.csv\"",[44,1136,237],{"class":108},[44,1138,274],{"class":273},[44,1140,16],{"class":104},[44,1142,280],{"class":279},[44,1144,139],{"class":108},[44,1146,1147,1149,1152,1154],{"class":46,"line":246},[44,1148,105],{"class":104},[44,1150,1151],{"class":108}," pathlib ",[44,1153,112],{"class":104},[44,1155,1156],{"class":108}," Path\n",[44,1158,1159,1161,1164,1166,1169,1172,1174,1177],{"class":46,"line":292},[44,1160,389],{"class":279},[44,1162,1163],{"class":108},"(Path(",[44,1165,1134],{"class":54},[44,1167,1168],{"class":108},").read_text(",[44,1170,1171],{"class":273},"encoding",[44,1173,16],{"class":104},[44,1175,1176],{"class":54},"\"utf-8\"",[44,1178,333],{"class":108},[10,1180,1181,1182,1184],{},"The prefix changes the data: the phone number now carries a leading apostrophe. That is the trade-off for CSV. If the file is going to people who will open it in a spreadsheet, prefer an ",[14,1183,412],{}," export with typed text cells from Step 2, which needs no alteration. If a program consumes the CSV, the prefix is noise it must strip; agree the convention with the consumer.",[430,1186,439,1192,439,1195,439,1198,439,1203,439,1210,439,1216,439,1220,439,1224,439,1228,439,1232,439,1236,439,1241,439,1243,439,1246,439,1250,439,1254,439,1257,439,1261,439,1263,439,1265,439,1267,439,1270,439,1273,439,1277,439,1279,439,1282,439,1284],{"viewBox":1187,"role":433,"ariaLabelledBy":1188,"xmlns":437,"style":1191},"159 0 441 144",[1189,1190],"fi-rules-t","fi-rules-d","width:100%;max-width:441px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[441,1193,1194],{"id":1189},"What each output needs",[445,1196,1197],{"id":1190},"A table of output formats and the protection each needs. For xlsx written by xlsxwriter, turn off strings_to_formulas and write intended formulas with write_formula. For xlsx written by openpyxl, set data_type to s for data strings. For CSV opened in a spreadsheet, prefix values starting with equals, plus, minus, at, tab or carriage return with a single quote.",[449,1199],{"x":1200,"y":452,"width":1201,"height":1202,"fill":455},"159","441","144",[449,1204],{"x":1205,"y":459,"width":1206,"height":1207,"rx":452,"fill":1208,"stroke":498,"style":1209},"175.9","148.0","28.0","#f0f4ff","stroke-width:1.5px",[478,1211,1215],{"x":1212,"y":1213,"style":1214},"250.0","35.0","font-size:11px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Output",[449,1217],{"x":1218,"y":459,"width":1219,"height":1207,"rx":452,"fill":1208,"stroke":498,"style":1209},"324.0","104.9",[478,1221,1223],{"x":1222,"y":1213,"style":1214},"376.4","Risky values",[449,1225],{"x":1226,"y":459,"width":1227,"height":1207,"rx":452,"fill":1208,"stroke":498,"style":1209},"428.8","155.2",[478,1229,1231],{"x":1230,"y":1213,"style":1214},"506.4","Protection",[449,1233],{"x":1205,"y":1234,"width":1206,"height":1207,"rx":452,"fill":455,"stroke":464,"style":1235},"44.0","stroke-width:1px",[478,1237,1240],{"x":1212,"y":1238,"style":1239},"63.0","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","xlsx via xlsxwriter",[449,1242],{"x":1218,"y":1234,"width":1219,"height":1207,"rx":452,"fill":455,"stroke":464,"style":1235},[478,1244,1245],{"x":1222,"y":1238,"style":1239},"start with =",[449,1247],{"x":1226,"y":1234,"width":1227,"height":1207,"rx":452,"fill":1248,"stroke":1249,"style":1209},"#d9f4f1","var(--teal,#0f9488)",[478,1251,1253],{"x":1230,"y":1238,"style":1252},"font-size:11px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","strings_to_formulas off",[449,1255],{"x":1205,"y":1256,"width":1206,"height":1207,"rx":452,"fill":455,"stroke":464,"style":1235},"72.0",[478,1258,1260],{"x":1212,"y":1259,"style":1239},"91.0","xlsx via openpyxl",[449,1262],{"x":1218,"y":1256,"width":1219,"height":1207,"rx":452,"fill":455,"stroke":464,"style":1235},[478,1264,1245],{"x":1222,"y":1259,"style":1239},[449,1266],{"x":1226,"y":1256,"width":1227,"height":1207,"rx":452,"fill":1248,"stroke":1249,"style":1209},[478,1268,1269],{"x":1230,"y":1259,"style":1252},"data_type = 's'",[449,1271],{"x":1205,"y":1272,"width":1206,"height":1207,"rx":452,"fill":455,"stroke":464,"style":1235},"100.0",[478,1274,1276],{"x":1212,"y":1275,"style":1239},"119.0","CSV for spreadsheets",[449,1278],{"x":1218,"y":1272,"width":1219,"height":1207,"rx":452,"fill":455,"stroke":464,"style":1235},[478,1280,1281],{"x":1222,"y":1275,"style":1239},"= + - @ tab CR",[449,1283],{"x":1226,"y":1272,"width":1227,"height":1207,"rx":452,"fill":1248,"stroke":1249,"style":1209},[478,1285,1286],{"x":1230,"y":1275,"style":1252},"prefix with '",[30,1288,1290],{"id":1289},"why-fix-it-in-the-export-not-in-the-form","Why fix it in the export, not in the form",[10,1292,1293,1294,1296,1297,1299,1300,1302,1303,1305,1306,1308],{},"It is tempting to reject ",[14,1295,16],{}," in the web form and consider the problem solved. It does not hold up. Exports combine data from many sources: imports from partners, records created before the validation existed, values edited directly in the database, fields nobody thought of as free text. And legitimate values start with these characters all the time: phone numbers with ",[14,1298,77],{},", negative adjustments with ",[14,1301,418],{},", social handles with ",[14,1304,81],{},", a surname typed with a stray ",[14,1307,16],{},". Input validation that rejects them annoys users and still misses the next source.",[10,1310,1311],{},"The export is the one place where every value passes, and the only place that knows the output format. Encoding for the output there is the same principle as escaping HTML when rendering a page rather than when accepting a comment: store what people typed, and make it safe for each destination at the moment it is written.",[430,1313,439,1318,439,1321,439,1324,439,1327,439,1332,439,1339,439,1345,439,1349,439,1354,439,1358,439,1362,439,1365,439,1372,439,1377,439,1381,439,1384,439,1387],{"viewBox":1314,"role":433,"ariaLabelledBy":1315,"xmlns":437,"style":438},"4 0 752 207",[1316,1317],"fi-where-t","fi-where-d",[441,1319,1320],{"id":1316},"Validate on the way in, encode on the way out",[445,1322,1323],{"id":1317},"Two panels. Validating input at the form catches some values but misses imports, old records and direct edits, and rejects legitimate values. Encoding at the export sees every value regardless of source and keeps the data unchanged in the database.",[449,1325],{"x":451,"y":452,"width":453,"height":1326,"fill":455},"207",[449,1328],{"x":458,"y":459,"width":1329,"height":1330,"rx":1331,"fill":588,"stroke":589,"style":465},"351.0","175.0","14",[467,1333,1335],{"transform":1334},"translate(182.5,29.0) scale(1.083)",[471,1336],{"d":1337,"fill":474,"stroke":596,"style":1338},"M3 5h18l-7 8v6l-4 2v-8z","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[478,1340,1344],{"x":1341,"y":1342,"style":1343},"195.5","78.0","font-size:14px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Reject at the form",[46,1346],{"x1":1347,"y1":1259,"x2":1348,"y2":1259,"stroke":589,"style":1235},"36.0","355.0",[478,1350,1353],{"x":1341,"y":1351,"style":1352},"111.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","misses imports and old rows",[478,1355,1357],{"x":1341,"y":1356,"style":1352},"137.0","rejects +44 and -12.50",[478,1359,1361],{"x":1341,"y":1360,"style":1352},"163.0","one source of many",[449,1363],{"x":1364,"y":459,"width":1329,"height":1330,"rx":1331,"fill":1248,"stroke":1249,"style":465},"389.0",[467,1366,1368],{"transform":1367},"translate(551.5,29.0) scale(1.083)",[471,1369],{"d":1370,"fill":474,"stroke":1371,"style":1338},"M5 12.5l4.5 4.5L19 7","var(--teal-ink,#0b6157)",[478,1373,1376],{"x":1374,"y":1342,"style":1375},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Encode at the export",[46,1378],{"x1":1379,"y1":1259,"x2":1380,"y2":1259,"stroke":1249,"style":1235},"405.0","724.0",[478,1382,1383],{"x":1374,"y":1351,"style":1352},"every value passes through",[478,1385,1386],{"x":1374,"y":1356,"style":1352},"data stays as typed",[478,1388,1389],{"x":1374,"y":1360,"style":1352},"knows the output format",[30,1391,1393],{"id":1392},"step-4-test-that-data-never-becomes-a-formula","Step 4 — Test that data never becomes a formula",[10,1395,1396],{},"The protection is easy to lose in a refactor: someone switches engines, or a new column is written with a different helper. A test that injects hostile values into every text column and checks the output catches that:",[35,1398,1400],{"className":95,"code":1399,"language":97,"meta":40,"style":40},"def assert_no_injected_formulas(export, allowed=()):\n    hostile = feedback.copy()\n    for col in hostile.select_dtypes(include=[\"object\", \"string\"]).columns:\n        hostile[col] = \"=1+1\"\n    path = export(hostile)\n    bad = [c for c in formula_cells(path) if c not in allowed]\n    assert not bad, f\"data written as formulas: {bad}\"\n    return \"ok\"\n\ndef my_export(frame, path=\"under_test.xlsx\"):\n    with pd.ExcelWriter(path, engine=\"xlsxwriter\",\n                        engine_kwargs={\"options\": {\"strings_to_formulas\": False}}) as writer:\n        frame.to_excel(writer, index=False)\n        writer.sheets[\"Sheet1\"].write_formula(\"C8\", \"=AVERAGE(C2:C6)\")\n    return path\n\nprint(assert_no_injected_formulas(my_export, allowed={\"C8\"}))\n",[14,1401,1402,1417,1427,1458,1468,1478,1510,1536,1543,1547,1564,1580,1605,1618,1637,1644,1648],{"__ignoreMap":40},[44,1403,1404,1406,1409,1412,1414],{"class":46,"line":47},[44,1405,150],{"class":104},[44,1407,1408],{"class":153}," assert_no_injected_formulas",[44,1410,1411],{"class":108},"(export, allowed",[44,1413,16],{"class":104},[44,1415,1416],{"class":108},"()):\n",[44,1418,1419,1422,1424],{"class":46,"line":118},[44,1420,1421],{"class":108},"    hostile ",[44,1423,16],{"class":104},[44,1425,1426],{"class":108}," feedback.copy()\n",[44,1428,1429,1431,1434,1436,1439,1442,1444,1447,1450,1452,1455],{"class":46,"line":125},[44,1430,826],{"class":104},[44,1432,1433],{"class":108}," col ",[44,1435,186],{"class":104},[44,1437,1438],{"class":108}," hostile.select_dtypes(",[44,1440,1441],{"class":273},"include",[44,1443,16],{"class":104},[44,1445,1446],{"class":108},"[",[44,1448,1449],{"class":54},"\"object\"",[44,1451,237],{"class":108},[44,1453,1454],{"class":54},"\"string\"",[44,1456,1457],{"class":108},"]).columns:\n",[44,1459,1460,1463,1465],{"class":46,"line":142},[44,1461,1462],{"class":108},"        hostile[col] ",[44,1464,16],{"class":104},[44,1466,1467],{"class":54}," \"=1+1\"\n",[44,1469,1470,1473,1475],{"class":46,"line":147},[44,1471,1472],{"class":108},"    path ",[44,1474,16],{"class":104},[44,1476,1477],{"class":108}," export(hostile)\n",[44,1479,1480,1483,1485,1488,1490,1492,1494,1497,1499,1501,1504,1507],{"class":46,"line":160},[44,1481,1482],{"class":108},"    bad ",[44,1484,16],{"class":104},[44,1486,1487],{"class":108}," [c ",[44,1489,180],{"class":104},[44,1491,194],{"class":108},[44,1493,186],{"class":104},[44,1495,1496],{"class":108}," formula_cells(path) ",[44,1498,201],{"class":104},[44,1500,194],{"class":108},[44,1502,1503],{"class":104},"not",[44,1505,1506],{"class":104}," in",[44,1508,1509],{"class":108}," allowed]\n",[44,1511,1512,1515,1518,1521,1523,1526,1528,1531,1533],{"class":46,"line":171},[44,1513,1514],{"class":104},"    assert",[44,1516,1517],{"class":104}," not",[44,1519,1520],{"class":108}," bad, ",[44,1522,252],{"class":104},[44,1524,1525],{"class":54},"\"data written as formulas: ",[44,1527,259],{"class":258},[44,1529,1530],{"class":108},"bad",[44,1532,265],{"class":258},[44,1534,1535],{"class":54},"\"\n",[44,1537,1538,1540],{"class":46,"line":216},[44,1539,174],{"class":104},[44,1541,1542],{"class":54}," \"ok\"\n",[44,1544,1545],{"class":46,"line":221},[44,1546,122],{"emptyLinePlaceholder":121},[44,1548,1549,1551,1554,1557,1559,1562],{"class":46,"line":246},[44,1550,150],{"class":104},[44,1552,1553],{"class":153}," my_export",[44,1555,1556],{"class":108},"(frame, path",[44,1558,16],{"class":104},[44,1560,1561],{"class":54},"\"under_test.xlsx\"",[44,1563,243],{"class":108},[44,1565,1566,1569,1572,1574,1576,1578],{"class":46,"line":292},[44,1567,1568],{"class":104},"    with",[44,1570,1571],{"class":108}," pd.ExcelWriter(path, ",[44,1573,262],{"class":273},[44,1575,16],{"class":104},[44,1577,240],{"class":54},[44,1579,659],{"class":108},[44,1581,1582,1585,1587,1589,1591,1593,1595,1597,1599,1601,1603],{"class":46,"line":336},[44,1583,1584],{"class":273},"                        engine_kwargs",[44,1586,16],{"class":104},[44,1588,259],{"class":108},[44,1590,671],{"class":54},[44,1592,674],{"class":108},[44,1594,677],{"class":54},[44,1596,680],{"class":108},[44,1598,280],{"class":279},[44,1600,685],{"class":108},[44,1602,688],{"class":104},[44,1604,691],{"class":108},[44,1606,1607,1610,1612,1614,1616],{"class":46,"line":341},[44,1608,1609],{"class":108},"        frame.to_excel(writer, ",[44,1611,274],{"class":273},[44,1613,16],{"class":104},[44,1615,280],{"class":279},[44,1617,139],{"class":108},[44,1619,1620,1623,1626,1629,1631,1633,1635],{"class":46,"line":352},[44,1621,1622],{"class":108},"        writer.sheets[",[44,1624,1625],{"class":54},"\"Sheet1\"",[44,1627,1628],{"class":108},"].write_formula(",[44,1630,750],{"class":54},[44,1632,237],{"class":108},[44,1634,755],{"class":54},[44,1636,139],{"class":108},[44,1638,1639,1641],{"class":46,"line":369},[44,1640,174],{"class":104},[44,1642,1643],{"class":108}," path\n",[44,1645,1646],{"class":46,"line":375},[44,1647,122],{"emptyLinePlaceholder":121},[44,1649,1650,1652,1655,1658,1660,1662,1664],{"class":46,"line":386},[44,1651,389],{"class":279},[44,1653,1654],{"class":108},"(assert_no_injected_formulas(my_export, ",[44,1656,1657],{"class":273},"allowed",[44,1659,16],{"class":104},[44,1661,259],{"class":108},[44,1663,750],{"class":54},[44,1665,1666],{"class":108},"}))\n",[10,1668,1669,1670,1672,1673,1677],{},"The ",[14,1671,1657],{}," set lists the report's own formula cells, so the test fails only on formulas that came from data. ",[24,1674,1676],{"href":1675},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Ftest-excel-output-with-pytest\u002F","Test Excel Output with pytest"," shows how to run checks like this in a test suite.",[30,1679,1681],{"id":1680},"common-pitfalls","Common pitfalls",[1683,1684,1685,1699,1705,1714,1731],"ul",{},[1686,1687,1688,1691,1692,1695,1696,1698],"li",{},[621,1689,1690],{},"Stripping or rejecting the characters."," A name like ",[14,1693,1694],{},"=Ahmed"," or a phone number with ",[14,1697,77],{}," is legitimate; store it as text instead of deleting characters.",[1686,1700,1701,1704],{},[621,1702,1703],{},"Protecting only one column."," Every text column that contains user input needs it, including names and addresses.",[1686,1706,1707,1713],{},[621,1708,1709,1710,1712],{},"Using the CSV apostrophe in ",[14,1711,412],{}," files."," Written from Python, the apostrophe becomes part of the value; use text cells instead.",[1686,1715,1716,1719,1720,1722,1723,1726,1727,28],{},[621,1717,1718],{},"Forgetting report formulas."," With ",[14,1721,627],{}," off, write intended formulas with ",[14,1724,1725],{},"write_formula",", or they become text too; see ",[24,1728,1730],{"href":1729},"\u002Fgetting-started-with-python-excel-automation\u002Ftroubleshooting-common-python-excel-errors\u002Ffix-formulas-showing-as-text-in-excel-output\u002F","Fix Formulas Showing as Text in Excel Output from Python",[1686,1732,1733,1736],{},[621,1734,1735],{},"Trusting the recipient's security settings."," Some warnings can be disabled; the export should not depend on them.",[30,1738,1740],{"id":1739},"conclusion","Conclusion",[10,1742,1743,1744,1746,1747,1749,1750,1753,1754,1757,1758,237,1760,237,1762,237,1764,1766],{},"openpyxl, xlsxwriter and pandas all write strings that start with ",[14,1745,16],{}," as formulas by default, so user input can become a live formula in an export. For ",[14,1748,412],{}," files, write data strings as text — ",[14,1751,1752],{},"strings_to_formulas: False"," with xlsxwriter, ",[14,1755,1756],{},"data_type = \"s\""," with openpyxl — and write the report's own formulas explicitly. For CSV files opened in spreadsheets, prefix values starting with ",[14,1759,16],{},[14,1761,77],{},[14,1763,418],{},[14,1765,81],{},", tab or carriage return with a single quote. Keep a test that feeds hostile values through the export.",[30,1768,1770],{"id":1769},"frequently-asked-questions","Frequently asked questions",[10,1772,1773,1776],{},[621,1774,1775],{},"What is formula injection in Excel?","\nWhen untrusted text that starts with a formula character is written to a spreadsheet and the application evaluates it as a formula. It can be used to show misleading links or messages to whoever opens the file.",[10,1778,1779,1782],{},[621,1780,1781],{},"Does pandas to_excel write strings starting with = as formulas?","\nYes, with both the openpyxl and xlsxwriter engines. Pass engine_kwargs={\"options\": {\"strings_to_formulas\": False}} with xlsxwriter to write them as text.",[10,1784,1785,1788],{},[621,1786,1787],{},"How do I stop openpyxl treating a string as a formula?","\nAssign the value, then set cell.data_type = \"s\". The value is stored unchanged as text.",[10,1790,1791,1794],{},[621,1792,1793],{},"How do I protect a CSV export against CSV injection?","\nPrefix any value starting with =, +, -, @, a tab or a carriage return with a single quote, following the OWASP recommendation, or export .xlsx with text cells instead.",[30,1796,1798],{"id":1797},"related","Related",[1683,1800,1801,1807,1814,1819,1826],{},[1686,1802,1803,1804,1806],{},"Up one level: ",[24,1805,27],{"href":26}," — making report jobs safe and reliable.",[1686,1808,1809,1813],{},[24,1810,1812],{"href":1811},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fread-untrusted-excel-files-safely-in-python\u002F","Read Untrusted Excel Files Safely in Python"," — the inbound side of the same problem.",[1686,1815,1816,1818],{},[24,1817,1730],{"href":1729}," — when protection goes too far.",[1686,1820,1821,1825],{},[24,1822,1824],{"href":1823},"\u002Fadvanced-data-transformation-and-cleaning\u002Fvalidating-excel-data-with-python\u002Fanonymise-personal-data-in-an-excel-file-with-python\u002F","Anonymise Personal Data in an Excel File with Python"," — another export safeguard.",[1686,1827,1828,1830],{},[24,1829,1676],{"href":1675}," — keeping the protection in place.",[1832,1833,1834],"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 .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}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 .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}",{"title":40,"searchDepth":118,"depth":118,"links":1836},[1837,1838,1839,1840,1841,1842,1843,1844,1845,1846],{"id":32,"depth":118,"text":33},{"id":84,"depth":118,"text":85},{"id":610,"depth":118,"text":611},{"id":982,"depth":118,"text":983},{"id":1289,"depth":118,"text":1290},{"id":1392,"depth":118,"text":1393},{"id":1680,"depth":118,"text":1681},{"id":1739,"depth":118,"text":1740},{"id":1769,"depth":118,"text":1770},{"id":1797,"depth":118,"text":1798},"2026-09-29","User-supplied text that starts with \"=\" becomes a live formula when an export writes it to Excel: openpyxl, xlsxwriter and pandas all do this by default. Find which writers turn strings into formulas, store data as text in .xlsx files, neutralise dangerous prefixes in CSV exports, and test that no data cell ever becomes a formula.","md",[1851,1853,1855,1857],{"q":1775,"a":1852},"When untrusted text that starts with a formula character is written to a spreadsheet and the application evaluates it as a formula. It can be used to show misleading links or messages to whoever opens the file.",{"q":1781,"a":1854},"Yes, with both the openpyxl and xlsxwriter engines. Pass engine_kwargs={\"options\": {\"strings_to_formulas\": False}} with xlsxwriter to write them as text.",{"q":1787,"a":1856},"Assign the value, then set cell.data_type = \"s\". The value is stored unchanged as text.",{"q":1793,"a":1858},"Prefix any value starting with =, +, -, @, a tab or a carriage return with a single quote, following the OWASP recommendation, or export .xlsx with text cells instead.","prevent formula injection in excel exports",null,{"breadcrumb":1862},[1863,1866,1869],{"name":1864,"item":1865},"Home","\u002F",{"name":1867,"item":1868},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":27,"item":26},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports",{"title":1872,"description":1873},"Prevent Formula (CSV) Injection in Excel Exports in Python","Stop formula injection in Excel and CSV exports from Python: which values openpyxl, xlsxwriter and pandas turn into formulas, write them as text with data_type, write_string or strings_to_formulas, escape CSV prefixes, and test.","prevent-formula-injection-in-excel-exports","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-formula-injection-in-excel-exports\u002Findex",[1877,1878],"reliable-jobs","writing-excel","how-to","NbvSbIi8Sq3PYN-xQ7CaKs_DHAZfNTynN3DWw7hyV68",[1882,1886],{"title":1883,"path":1884,"stem":1885,"children":-1},"Make an Excel Report Job Safe to Rerun","\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fmake-an-excel-report-job-safe-to-rerun\u002Findex",{"title":1887,"path":1888,"stem":1889,"children":-1},"Prevent Overlapping Runs of an Excel Report Job","\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-overlapping-runs-of-an-excel-report-job","automating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fprevent-overlapping-runs-of-an-excel-report-job\u002Findex",[1891,1893],{"path":1892,"title":27},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation",{"path":1894,"title":1867},"\u002Fautomating-reporting-workflows",[],[1897,1901,1904,1907,1910,1913],{"path":1898,"title":1899,"kind":1900},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts","Testing and Packaging Excel Automation Scripts","guide",{"path":1902,"title":1903,"kind":1879},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fblock-large-or-sensitive-excel-files-with-a-pre-commit-hook","Block Large or Sensitive Excel Files with a Pre-commit Hook",{"path":1905,"title":1906,"kind":1879},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fbuild-a-command-line-tool-for-excel-reports-with-argparse","Build a Command-Line Tool for Excel Reports with argparse",{"path":1908,"title":1909,"kind":1879},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcompare-excel-output-to-a-golden-file-in-tests","Compare Excel Output to a Golden File in Tests",{"path":1911,"title":1912,"kind":1879},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcreate-excel-test-fixtures-in-memory","Create Excel Test Fixtures in Memory for pytest",{"path":1914,"title":1915,"kind":1879},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fdiff-excel-files-in-git-with-a-textconv-driver","Diff Excel Files in Git with a textconv Driver",1790972132040]