[{"data":1,"prerenderedAt":2635},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Freturn-an-excel-file-from-a-flask-download-endpoint":3,"surround:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Freturn-an-excel-file-from-a-flask-download-endpoint":2626},{"id":4,"title":5,"body":6,"dateModified":2603,"datePublished":2603,"description":2604,"extension":2605,"faq":2606,"meta":2617,"navigation":217,"path":2618,"seo":2619,"slug":2622,"stem":2623,"type":2624,"__hash__":2625},"docs\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Freturn-an-excel-file-from-a-flask-download-endpoint\u002Findex.md","Return an Excel File from a Flask Download Endpoint",{"type":7,"value":8,"toc":2586},"minimark",[9,19,143,148,182,185,189,553,564,568,579,877,880,884,887,1109,1112,1116,1322,1329,1410,1414,1417,1546,1553,1557,1571,1673,1677,1907,1910,1914,1921,2094,2105,2109,2112,2294,2305,2379,2383,2433,2437,2449,2453,2464,2468,2486,2504,2518,2524,2539,2543,2582],[10,11,12,13,18],"p",{},"An \"Export to Excel\" button in a Flask app is about twenty lines, and roughly five of them are where people get stuck: the buffer must be closed before it is read, rewound before it is sent, and labelled with two headers before a browser treats it as a download. This guide builds the endpoint completely — formatting, empty results, error handling, safe filenames and a test — so it can be dropped into a real application. It is the Flask branch of ",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002F","Serving Excel Files from Python Web Apps",".",[20,21,29,30,29,34,29,38,29,45,29,52,29,61,29,67,29,72,29,80,29,85,29,89,29,94,29,97,29,100,29,103,29,107,29,112,29,116,29,121,29,125,29,130,29,135,29,139],"svg",{"viewBox":22,"role":23,"ariaLabelledBy":24,"xmlns":27,"style":28},"0 0 760 218","img",[25,26],"fl-t","fl-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:760px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[31,32,33],"title",{"id":25},"The four steps inside a Flask export route",[35,36,37],"desc",{"id":26},"The route scopes a query to the caller, writes a workbook into a memory buffer, closes the writer and rewinds, then hands the buffer to send_file with a download name.",[39,40],"rect",{"x":41,"y":41,"width":42,"height":43,"fill":44},"0","760","218","#ffffff",[46,47,51],"text",{"x":48,"y":49,"style":50},"380","26","font-size:13px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","Four steps, and the one that breaks is always step three",[39,53],{"x":54,"y":55,"width":56,"height":57,"rx":58,"fill":59,"stroke":60},"24","62","160","76","12","#f0f2f5","var(--line,#cdd5e6)",[46,62,66],{"x":63,"y":64,"style":65},"104","94","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","1. query",[46,68,71],{"x":63,"y":69,"style":70},"116","font-size:11.5px;fill:var(--muted,#5b6780);text-anchor:middle","scoped to the user",[73,74],"line",{"x1":75,"y1":76,"x2":77,"y2":76,"stroke":78,"style":79},"184","100","216","var(--brand,#5b5cf0)","stroke-width:2px",[81,82],"polygon",{"points":83,"fill":84},"216,100 206,95 206,105","#5b5cf0",[39,86],{"x":87,"y":55,"width":56,"height":57,"rx":58,"fill":88,"stroke":60},"220","#ebebfd",[46,90,93],{"x":91,"y":64,"style":92},"300","font-size:12.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","2. write",[46,95,96],{"x":91,"y":69,"style":70},"into BytesIO",[73,98],{"x1":48,"y1":76,"x2":99,"y2":76,"stroke":78,"style":79},"412",[81,101],{"points":102,"fill":84},"412,100 402,95 402,105",[39,104],{"x":105,"y":55,"width":56,"height":57,"rx":58,"fill":106,"stroke":60},"416","#fdefd8",[46,108,111],{"x":109,"y":64,"style":110},"496","font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","3. close, seek(0)",[46,113,115],{"x":109,"y":69,"style":114},"font-size:11.5px;fill:var(--gold-ink,#7a4e06);text-anchor:middle","skip it and the file breaks",[73,117],{"x1":118,"y1":76,"x2":119,"y2":76,"stroke":120,"style":79},"576","608","var(--teal,#0f9488)",[81,122],{"points":123,"fill":124},"608,100 598,95 598,105","#0f766e",[39,126],{"x":127,"y":55,"width":128,"height":57,"rx":58,"fill":129,"stroke":60},"612","124","#d9f4f1",[46,131,134],{"x":132,"y":64,"style":133},"674","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","4. send_file",[46,136,138],{"x":132,"y":69,"style":137},"font-size:11.5px;fill:var(--teal-ink,#0b6157);text-anchor:middle","as_attachment",[46,140,142],{"x":48,"y":141,"style":70},"192","xlsxwriter finalises the zip on close — a buffer read before that is truncated",[144,145,147],"h2",{"id":146},"prerequisites","Prerequisites",[149,150,155],"pre",{"className":151,"code":152,"language":153,"meta":154,"style":154},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install flask pandas xlsxwriter openpyxl\n","bash","",[156,157,158],"code",{"__ignoreMap":154},[159,160,162,166,170,173,176,179],"span",{"class":73,"line":161},1,[159,163,165],{"class":164},"sMTad","pip",[159,167,169],{"class":168},"srMev"," install",[159,171,172],{"class":168}," flask",[159,174,175],{"class":168}," pandas",[159,177,178],{"class":168}," xlsxwriter",[159,180,181],{"class":168}," openpyxl\n",[10,183,184],{},"xlsxwriter writes the workbook; openpyxl is here only so the test at the end can read it back.",[144,186,188],{"id":187},"the-smallest-working-endpoint","The smallest working endpoint",[149,190,194],{"className":191,"code":192,"language":193,"meta":154,"style":154},"language-python shiki shiki-themes github-light github-dark-high-contrast","\"\"\"app.py — a minimal Excel export route.\"\"\"\nimport io\n\nimport pandas as pd\nfrom flask import Flask, send_file\n\napp = Flask(__name__)\nXLSX = \"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet\"\n\n@app.get(\"\u002Fexports\u002Fsales.xlsx\")\ndef export_sales():\n    df = pd.DataFrame({\n        \"region\": [\"North\", \"South\", \"East\", \"West\"],\n        \"revenue\": [128_400.50, 96_220.00, 51_130.25, 74_905.75],\n    })\n\n    buffer = io.BytesIO()\n    with pd.ExcelWriter(buffer, engine=\"xlsxwriter\") as writer:\n        df.to_excel(writer, index=False, sheet_name=\"Sales\")\n    buffer.seek(0)\n\n    return send_file(buffer, mimetype=XLSX, as_attachment=True,\n                     download_name=\"sales.xlsx\")\n\nif __name__ == \"__main__\":\n    app.run(debug=True)\n","python",[156,195,196,201,212,219,233,247,252,271,283,288,303,315,326,357,385,391,396,407,433,459,469,474,502,515,520,538],{"__ignoreMap":154},[159,197,198],{"class":73,"line":161},[159,199,200],{"class":168},"\"\"\"app.py — a minimal Excel export route.\"\"\"\n",[159,202,204,208],{"class":73,"line":203},2,[159,205,207],{"class":206},"s-kum","import",[159,209,211],{"class":210},"skGVy"," io\n",[159,213,215],{"class":73,"line":214},3,[159,216,218],{"emptyLinePlaceholder":217},true,"\n",[159,220,222,224,227,230],{"class":73,"line":221},4,[159,223,207],{"class":206},[159,225,226],{"class":210}," pandas ",[159,228,229],{"class":206},"as",[159,231,232],{"class":210}," pd\n",[159,234,236,239,242,244],{"class":73,"line":235},5,[159,237,238],{"class":206},"from",[159,240,241],{"class":210}," flask ",[159,243,207],{"class":206},[159,245,246],{"class":210}," Flask, send_file\n",[159,248,250],{"class":73,"line":249},6,[159,251,218],{"emptyLinePlaceholder":217},[159,253,255,258,261,264,268],{"class":73,"line":254},7,[159,256,257],{"class":210},"app ",[159,259,260],{"class":206},"=",[159,262,263],{"class":210}," Flask(",[159,265,267],{"class":266},"sP0c6","__name__",[159,269,270],{"class":210},")\n",[159,272,274,277,280],{"class":73,"line":273},8,[159,275,276],{"class":266},"XLSX",[159,278,279],{"class":206}," =",[159,281,282],{"class":168}," \"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet\"\n",[159,284,286],{"class":73,"line":285},9,[159,287,218],{"emptyLinePlaceholder":217},[159,289,291,295,298,301],{"class":73,"line":290},10,[159,292,294],{"class":293},"s_Opv","@app.get",[159,296,297],{"class":210},"(",[159,299,300],{"class":168},"\"\u002Fexports\u002Fsales.xlsx\"",[159,302,270],{"class":210},[159,304,306,309,312],{"class":73,"line":305},11,[159,307,308],{"class":206},"def",[159,310,311],{"class":293}," export_sales",[159,313,314],{"class":210},"():\n",[159,316,318,321,323],{"class":73,"line":317},12,[159,319,320],{"class":210},"    df ",[159,322,260],{"class":206},[159,324,325],{"class":210}," pd.DataFrame({\n",[159,327,329,332,335,338,341,344,346,349,351,354],{"class":73,"line":328},13,[159,330,331],{"class":168},"        \"region\"",[159,333,334],{"class":210},": [",[159,336,337],{"class":168},"\"North\"",[159,339,340],{"class":210},", ",[159,342,343],{"class":168},"\"South\"",[159,345,340],{"class":210},[159,347,348],{"class":168},"\"East\"",[159,350,340],{"class":210},[159,352,353],{"class":168},"\"West\"",[159,355,356],{"class":210},"],\n",[159,358,360,363,365,368,370,373,375,378,380,383],{"class":73,"line":359},14,[159,361,362],{"class":168},"        \"revenue\"",[159,364,334],{"class":210},[159,366,367],{"class":266},"128_400.50",[159,369,340],{"class":210},[159,371,372],{"class":266},"96_220.00",[159,374,340],{"class":210},[159,376,377],{"class":266},"51_130.25",[159,379,340],{"class":210},[159,381,382],{"class":266},"74_905.75",[159,384,356],{"class":210},[159,386,388],{"class":73,"line":387},15,[159,389,390],{"class":210},"    })\n",[159,392,394],{"class":73,"line":393},16,[159,395,218],{"emptyLinePlaceholder":217},[159,397,399,402,404],{"class":73,"line":398},17,[159,400,401],{"class":210},"    buffer ",[159,403,260],{"class":206},[159,405,406],{"class":210}," io.BytesIO()\n",[159,408,410,413,416,420,422,425,428,430],{"class":73,"line":409},18,[159,411,412],{"class":206},"    with",[159,414,415],{"class":210}," pd.ExcelWriter(buffer, ",[159,417,419],{"class":418},"sa561","engine",[159,421,260],{"class":206},[159,423,424],{"class":168},"\"xlsxwriter\"",[159,426,427],{"class":210},") ",[159,429,229],{"class":206},[159,431,432],{"class":210}," writer:\n",[159,434,436,439,442,444,447,449,452,454,457],{"class":73,"line":435},19,[159,437,438],{"class":210},"        df.to_excel(writer, ",[159,440,441],{"class":418},"index",[159,443,260],{"class":206},[159,445,446],{"class":266},"False",[159,448,340],{"class":210},[159,450,451],{"class":418},"sheet_name",[159,453,260],{"class":206},[159,455,456],{"class":168},"\"Sales\"",[159,458,270],{"class":210},[159,460,462,465,467],{"class":73,"line":461},20,[159,463,464],{"class":210},"    buffer.seek(",[159,466,41],{"class":266},[159,468,270],{"class":210},[159,470,472],{"class":73,"line":471},21,[159,473,218],{"emptyLinePlaceholder":217},[159,475,477,480,483,486,488,490,492,494,496,499],{"class":73,"line":476},22,[159,478,479],{"class":206},"    return",[159,481,482],{"class":210}," send_file(buffer, ",[159,484,485],{"class":418},"mimetype",[159,487,260],{"class":206},[159,489,276],{"class":266},[159,491,340],{"class":210},[159,493,138],{"class":418},[159,495,260],{"class":206},[159,497,498],{"class":266},"True",[159,500,501],{"class":210},",\n",[159,503,505,508,510,513],{"class":73,"line":504},23,[159,506,507],{"class":418},"                     download_name",[159,509,260],{"class":206},[159,511,512],{"class":168},"\"sales.xlsx\"",[159,514,270],{"class":210},[159,516,518],{"class":73,"line":517},24,[159,519,218],{"emptyLinePlaceholder":217},[159,521,523,526,529,532,535],{"class":73,"line":522},25,[159,524,525],{"class":206},"if",[159,527,528],{"class":266}," __name__",[159,530,531],{"class":206}," ==",[159,533,534],{"class":168}," \"__main__\"",[159,536,537],{"class":210},":\n",[159,539,541,544,547,549,551],{"class":73,"line":540},26,[159,542,543],{"class":210},"    app.run(",[159,545,546],{"class":418},"debug",[159,548,260],{"class":206},[159,550,498],{"class":266},[159,552,270],{"class":210},[10,554,555,556,559,560,563],{},"Run it and open ",[156,557,558],{},"\u002Fexports\u002Fsales.xlsx","; the browser saves a workbook. The ",[156,561,562],{},"with"," block is what closes the writer — xlsxwriter finalises the zip at that moment, and reading the buffer before it produces the truncated file that opens as gibberish.",[144,565,567],{"id":566},"format-the-sheet-while-you-have-the-writer","Format the sheet while you have the writer",[10,569,570,571,574,575,578],{},"An export nobody has to reformat is worth the extra ten lines. ",[156,572,573],{},"writer.book"," and ",[156,576,577],{},"writer.sheets"," expose the xlsxwriter objects:",[149,580,582],{"className":191,"code":581,"language":193,"meta":154,"style":154},"def build_workbook(df: pd.DataFrame, sheet: str = \"Sales\") -> io.BytesIO:\n    buffer = io.BytesIO()\n    with pd.ExcelWriter(buffer, engine=\"xlsxwriter\") as writer:\n        df.to_excel(writer, index=False, sheet_name=sheet)\n        book, ws = writer.book, writer.sheets[sheet]\n\n        header = book.add_format({\"bold\": True, \"bg_color\": \"#DDEBF7\", \"border\": 1})\n        money = book.add_format({\"num_format\": \"#,##0.00\"})\n\n        for col, name in enumerate(df.columns):\n            ws.write(0, col, name, header)\n            width = max(len(str(name)) + 2, 12)\n            if pd.api.types.is_numeric_dtype(df[name]):\n                ws.set_column(col, col, width, money)\n            else:\n                ws.set_column(col, col, width)\n\n        ws.freeze_panes(1, 0)\n        ws.autofilter(0, 0, len(df), len(df.columns) - 1)\n    buffer.seek(0)\n    return buffer\n",[156,583,584,605,613,631,650,660,664,705,724,728,745,755,789,797,802,809,814,818,831,862,870],{"__ignoreMap":154},[159,585,586,588,591,594,597,599,602],{"class":73,"line":161},[159,587,308],{"class":206},[159,589,590],{"class":293}," build_workbook",[159,592,593],{"class":210},"(df: pd.DataFrame, sheet: ",[159,595,596],{"class":266},"str",[159,598,279],{"class":206},[159,600,601],{"class":168}," \"Sales\"",[159,603,604],{"class":210},") -> io.BytesIO:\n",[159,606,607,609,611],{"class":73,"line":203},[159,608,401],{"class":210},[159,610,260],{"class":206},[159,612,406],{"class":210},[159,614,615,617,619,621,623,625,627,629],{"class":73,"line":214},[159,616,412],{"class":206},[159,618,415],{"class":210},[159,620,419],{"class":418},[159,622,260],{"class":206},[159,624,424],{"class":168},[159,626,427],{"class":210},[159,628,229],{"class":206},[159,630,432],{"class":210},[159,632,633,635,637,639,641,643,645,647],{"class":73,"line":221},[159,634,438],{"class":210},[159,636,441],{"class":418},[159,638,260],{"class":206},[159,640,446],{"class":266},[159,642,340],{"class":210},[159,644,451],{"class":418},[159,646,260],{"class":206},[159,648,649],{"class":210},"sheet)\n",[159,651,652,655,657],{"class":73,"line":235},[159,653,654],{"class":210},"        book, ws ",[159,656,260],{"class":206},[159,658,659],{"class":210}," writer.book, writer.sheets[sheet]\n",[159,661,662],{"class":73,"line":249},[159,663,218],{"emptyLinePlaceholder":217},[159,665,666,669,671,674,677,680,682,684,687,689,692,694,697,699,702],{"class":73,"line":254},[159,667,668],{"class":210},"        header ",[159,670,260],{"class":206},[159,672,673],{"class":210}," book.add_format({",[159,675,676],{"class":168},"\"bold\"",[159,678,679],{"class":210},": ",[159,681,498],{"class":266},[159,683,340],{"class":210},[159,685,686],{"class":168},"\"bg_color\"",[159,688,679],{"class":210},[159,690,691],{"class":168},"\"#DDEBF7\"",[159,693,340],{"class":210},[159,695,696],{"class":168},"\"border\"",[159,698,679],{"class":210},[159,700,701],{"class":266},"1",[159,703,704],{"class":210},"})\n",[159,706,707,710,712,714,717,719,722],{"class":73,"line":273},[159,708,709],{"class":210},"        money ",[159,711,260],{"class":206},[159,713,673],{"class":210},[159,715,716],{"class":168},"\"num_format\"",[159,718,679],{"class":210},[159,720,721],{"class":168},"\"#,##0.00\"",[159,723,704],{"class":210},[159,725,726],{"class":73,"line":285},[159,727,218],{"emptyLinePlaceholder":217},[159,729,730,733,736,739,742],{"class":73,"line":290},[159,731,732],{"class":206},"        for",[159,734,735],{"class":210}," col, name ",[159,737,738],{"class":206},"in",[159,740,741],{"class":266}," enumerate",[159,743,744],{"class":210},"(df.columns):\n",[159,746,747,750,752],{"class":73,"line":305},[159,748,749],{"class":210},"            ws.write(",[159,751,41],{"class":266},[159,753,754],{"class":210},", col, name, header)\n",[159,756,757,760,762,765,767,770,772,774,777,780,783,785,787],{"class":73,"line":317},[159,758,759],{"class":210},"            width ",[159,761,260],{"class":206},[159,763,764],{"class":266}," max",[159,766,297],{"class":210},[159,768,769],{"class":266},"len",[159,771,297],{"class":210},[159,773,596],{"class":266},[159,775,776],{"class":210},"(name)) ",[159,778,779],{"class":206},"+",[159,781,782],{"class":266}," 2",[159,784,340],{"class":210},[159,786,58],{"class":266},[159,788,270],{"class":210},[159,790,791,794],{"class":73,"line":328},[159,792,793],{"class":206},"            if",[159,795,796],{"class":210}," pd.api.types.is_numeric_dtype(df[name]):\n",[159,798,799],{"class":73,"line":359},[159,800,801],{"class":210},"                ws.set_column(col, col, width, money)\n",[159,803,804,807],{"class":73,"line":387},[159,805,806],{"class":206},"            else",[159,808,537],{"class":210},[159,810,811],{"class":73,"line":393},[159,812,813],{"class":210},"                ws.set_column(col, col, width)\n",[159,815,816],{"class":73,"line":398},[159,817,218],{"emptyLinePlaceholder":217},[159,819,820,823,825,827,829],{"class":73,"line":409},[159,821,822],{"class":210},"        ws.freeze_panes(",[159,824,701],{"class":266},[159,826,340],{"class":210},[159,828,41],{"class":266},[159,830,270],{"class":210},[159,832,833,836,838,840,842,844,846,849,851,854,857,860],{"class":73,"line":435},[159,834,835],{"class":210},"        ws.autofilter(",[159,837,41],{"class":266},[159,839,340],{"class":210},[159,841,41],{"class":266},[159,843,340],{"class":210},[159,845,769],{"class":266},[159,847,848],{"class":210},"(df), ",[159,850,769],{"class":266},[159,852,853],{"class":210},"(df.columns) ",[159,855,856],{"class":206},"-",[159,858,859],{"class":266}," 1",[159,861,270],{"class":210},[159,863,864,866,868],{"class":73,"line":461},[159,865,464],{"class":210},[159,867,41],{"class":266},[159,869,270],{"class":210},[159,871,872,874],{"class":73,"line":471},[159,873,479],{"class":206},[159,875,876],{"class":210}," buffer\n",[10,878,879],{},"Separating the build from the route also makes the workbook testable without a request context, which is the shape the test at the end depends on.",[144,881,883],{"id":882},"scope-the-data-to-the-caller","Scope the data to the caller",[10,885,886],{},"The export must not become a way to read someone else's rows. Filters may come from the request; identity must come from the session:",[149,888,890],{"className":191,"code":889,"language":193,"meta":154,"style":154},"from flask import abort, request, session\n\n@app.get(\"\u002Fexports\u002Forders.xlsx\")\ndef export_orders():\n    user_id = session.get(\"user_id\")\n    if not user_id:\n        abort(401)\n\n    region = request.args.get(\"region\")            # narrows only\n    rows = fetch_orders(owner_id=user_id, region=region)\n    if not rows:\n        df = pd.DataFrame({\"message\": [\"No orders matched the selected filters.\"]})\n    else:\n        df = pd.DataFrame(rows)\n\n    return send_file(build_workbook(df, \"Orders\"), mimetype=XLSX,\n                     as_attachment=True, download_name=export_name(\"orders\", region))\n",[156,891,892,903,907,918,927,942,953,963,967,987,1013,1022,1043,1050,1059,1063,1084],{"__ignoreMap":154},[159,893,894,896,898,900],{"class":73,"line":161},[159,895,238],{"class":206},[159,897,241],{"class":210},[159,899,207],{"class":206},[159,901,902],{"class":210}," abort, request, session\n",[159,904,905],{"class":73,"line":203},[159,906,218],{"emptyLinePlaceholder":217},[159,908,909,911,913,916],{"class":73,"line":214},[159,910,294],{"class":293},[159,912,297],{"class":210},[159,914,915],{"class":168},"\"\u002Fexports\u002Forders.xlsx\"",[159,917,270],{"class":210},[159,919,920,922,925],{"class":73,"line":221},[159,921,308],{"class":206},[159,923,924],{"class":293}," export_orders",[159,926,314],{"class":210},[159,928,929,932,934,937,940],{"class":73,"line":235},[159,930,931],{"class":210},"    user_id ",[159,933,260],{"class":206},[159,935,936],{"class":210}," session.get(",[159,938,939],{"class":168},"\"user_id\"",[159,941,270],{"class":210},[159,943,944,947,950],{"class":73,"line":249},[159,945,946],{"class":206},"    if",[159,948,949],{"class":206}," not",[159,951,952],{"class":210}," user_id:\n",[159,954,955,958,961],{"class":73,"line":254},[159,956,957],{"class":210},"        abort(",[159,959,960],{"class":266},"401",[159,962,270],{"class":210},[159,964,965],{"class":73,"line":273},[159,966,218],{"emptyLinePlaceholder":217},[159,968,969,972,974,977,980,983],{"class":73,"line":285},[159,970,971],{"class":210},"    region ",[159,973,260],{"class":206},[159,975,976],{"class":210}," request.args.get(",[159,978,979],{"class":168},"\"region\"",[159,981,982],{"class":210},")            ",[159,984,986],{"class":985},"s-wDw","# narrows only\n",[159,988,989,992,994,997,1000,1002,1005,1008,1010],{"class":73,"line":290},[159,990,991],{"class":210},"    rows ",[159,993,260],{"class":206},[159,995,996],{"class":210}," fetch_orders(",[159,998,999],{"class":418},"owner_id",[159,1001,260],{"class":206},[159,1003,1004],{"class":210},"user_id, ",[159,1006,1007],{"class":418},"region",[159,1009,260],{"class":206},[159,1011,1012],{"class":210},"region)\n",[159,1014,1015,1017,1019],{"class":73,"line":305},[159,1016,946],{"class":206},[159,1018,949],{"class":206},[159,1020,1021],{"class":210}," rows:\n",[159,1023,1024,1027,1029,1032,1035,1037,1040],{"class":73,"line":317},[159,1025,1026],{"class":210},"        df ",[159,1028,260],{"class":206},[159,1030,1031],{"class":210}," pd.DataFrame({",[159,1033,1034],{"class":168},"\"message\"",[159,1036,334],{"class":210},[159,1038,1039],{"class":168},"\"No orders matched the selected filters.\"",[159,1041,1042],{"class":210},"]})\n",[159,1044,1045,1048],{"class":73,"line":328},[159,1046,1047],{"class":206},"    else",[159,1049,537],{"class":210},[159,1051,1052,1054,1056],{"class":73,"line":359},[159,1053,1026],{"class":210},[159,1055,260],{"class":206},[159,1057,1058],{"class":210}," pd.DataFrame(rows)\n",[159,1060,1061],{"class":73,"line":387},[159,1062,218],{"emptyLinePlaceholder":217},[159,1064,1065,1067,1070,1073,1076,1078,1080,1082],{"class":73,"line":393},[159,1066,479],{"class":206},[159,1068,1069],{"class":210}," send_file(build_workbook(df, ",[159,1071,1072],{"class":168},"\"Orders\"",[159,1074,1075],{"class":210},"), ",[159,1077,485],{"class":418},[159,1079,260],{"class":206},[159,1081,276],{"class":266},[159,1083,501],{"class":210},[159,1085,1086,1089,1091,1093,1095,1098,1100,1103,1106],{"class":73,"line":398},[159,1087,1088],{"class":418},"                     as_attachment",[159,1090,260],{"class":206},[159,1092,498],{"class":266},[159,1094,340],{"class":210},[159,1096,1097],{"class":418},"download_name",[159,1099,260],{"class":206},[159,1101,1102],{"class":210},"export_name(",[159,1104,1105],{"class":168},"\"orders\"",[159,1107,1108],{"class":210},", region))\n",[10,1110,1111],{},"Note the empty case. Returning a workbook that says why it is empty is far better than returning a file with a header row and nothing under it, which reads as a bug.",[144,1113,1115],{"id":1114},"generate-a-filename-worth-keeping","Generate a filename worth keeping",[149,1117,1119],{"className":191,"code":1118,"language":193,"meta":154,"style":154},"import re\nfrom datetime import date\n\nSAFE = re.compile(r\"[^A-Za-z0-9._-]+\")\n\ndef export_name(report: str, scope: str | None = None) -> str:\n    parts = [report, scope or \"all\", f\"{date.today():%Y-%m-%d}\"]\n    stem = SAFE.sub(\"-\", \"-\".join(parts)).strip(\"-\")\n    return f\"{stem[:80]}.xlsx\"\n",[156,1120,1121,1128,1140,1144,1179,1183,1217,1271,1298],{"__ignoreMap":154},[159,1122,1123,1125],{"class":73,"line":161},[159,1124,207],{"class":206},[159,1126,1127],{"class":210}," re\n",[159,1129,1130,1132,1135,1137],{"class":73,"line":203},[159,1131,238],{"class":206},[159,1133,1134],{"class":210}," datetime ",[159,1136,207],{"class":206},[159,1138,1139],{"class":210}," date\n",[159,1141,1142],{"class":73,"line":214},[159,1143,218],{"emptyLinePlaceholder":217},[159,1145,1146,1149,1151,1154,1157,1160,1163,1166,1170,1173,1175,1177],{"class":73,"line":221},[159,1147,1148],{"class":266},"SAFE",[159,1150,279],{"class":206},[159,1152,1153],{"class":210}," re.compile(",[159,1155,1156],{"class":206},"r",[159,1158,1159],{"class":168},"\"",[159,1161,1162],{"class":266},"[",[159,1164,1165],{"class":206},"^",[159,1167,1169],{"class":1168},"sSjpA","A-Za-z0-9._-",[159,1171,1172],{"class":266},"]",[159,1174,779],{"class":206},[159,1176,1159],{"class":168},[159,1178,270],{"class":210},[159,1180,1181],{"class":73,"line":235},[159,1182,218],{"emptyLinePlaceholder":217},[159,1184,1185,1187,1190,1193,1195,1198,1200,1203,1206,1208,1210,1213,1215],{"class":73,"line":249},[159,1186,308],{"class":206},[159,1188,1189],{"class":293}," export_name",[159,1191,1192],{"class":210},"(report: ",[159,1194,596],{"class":266},[159,1196,1197],{"class":210},", scope: ",[159,1199,596],{"class":266},[159,1201,1202],{"class":206}," |",[159,1204,1205],{"class":266}," None",[159,1207,279],{"class":206},[159,1209,1205],{"class":266},[159,1211,1212],{"class":210},") -> ",[159,1214,596],{"class":266},[159,1216,537],{"class":210},[159,1218,1219,1222,1224,1227,1230,1233,1235,1238,1240,1243,1246,1249,1252,1255,1258,1260,1263,1266,1268],{"class":73,"line":254},[159,1220,1221],{"class":210},"    parts ",[159,1223,260],{"class":206},[159,1225,1226],{"class":210}," [report, scope ",[159,1228,1229],{"class":206},"or",[159,1231,1232],{"class":168}," \"all\"",[159,1234,340],{"class":210},[159,1236,1237],{"class":206},"f",[159,1239,1159],{"class":168},[159,1241,1242],{"class":1168},"{",[159,1244,1245],{"class":210},"date.today():",[159,1247,1248],{"class":206},"%",[159,1250,1251],{"class":210},"Y",[159,1253,1254],{"class":206},"-%",[159,1256,1257],{"class":210},"m",[159,1259,1254],{"class":206},[159,1261,1262],{"class":210},"d",[159,1264,1265],{"class":1168},"}",[159,1267,1159],{"class":168},[159,1269,1270],{"class":210},"]\n",[159,1272,1273,1276,1278,1281,1284,1287,1289,1291,1294,1296],{"class":73,"line":273},[159,1274,1275],{"class":210},"    stem ",[159,1277,260],{"class":206},[159,1279,1280],{"class":266}," SAFE",[159,1282,1283],{"class":210},".sub(",[159,1285,1286],{"class":168},"\"-\"",[159,1288,340],{"class":210},[159,1290,1286],{"class":168},[159,1292,1293],{"class":210},".join(parts)).strip(",[159,1295,1286],{"class":168},[159,1297,270],{"class":210},[159,1299,1300,1302,1305,1307,1309,1312,1315,1317,1319],{"class":73,"line":285},[159,1301,479],{"class":206},[159,1303,1304],{"class":206}," f",[159,1306,1159],{"class":168},[159,1308,1242],{"class":1168},[159,1310,1311],{"class":210},"stem[:",[159,1313,1314],{"class":266},"80",[159,1316,1172],{"class":210},[159,1318,1265],{"class":1168},[159,1320,1321],{"class":168},".xlsx\"\n",[10,1323,1324,1325,1328],{},"Sanitising is not cosmetic: a newline or quote reaching the ",[156,1326,1327],{},"Content-Disposition"," header is a header-injection bug, and a scope value taken from data is exactly where one would come from.",[20,1330,29,1335,29,1338,29,1341,29,1344,29,1347,29,1353,29,1357,29,1362,29,1366,29,1370,29,1373,29,1377,29,1380,29,1383,29,1386,29,1391,29,1396,29,1399,29,1402,29,1406],{"viewBox":1331,"role":23,"ariaLabelledBy":1332,"xmlns":27,"style":28},"0 0 760 214",[1333,1334],"fl2-t","fl2-d",[31,1336,1337],{"id":1333},"Which parts of an export request may be trusted",[35,1339,1340],{"id":1334},"Identity and permissions come from the session, filters from query parameters may only narrow the result, and the filename is generated rather than echoed from input.",[39,1342],{"x":41,"y":41,"width":42,"height":1343,"fill":44},"214",[46,1345,1346],{"x":48,"y":49,"style":50},"Where each part of the response comes from",[39,1348],{"x":1349,"y":1350,"width":1351,"height":1352,"rx":58,"fill":129,"stroke":60},"30","46","222","120",[46,1354,1356],{"x":1355,"y":57,"style":133},"141","session",[46,1358,1361],{"x":1355,"y":1359,"style":1360},"106","font-size:11.5px;fill:var(--text,#172033);text-anchor:middle","who is asking",[46,1363,1365],{"x":1355,"y":1364,"style":1360},"130","what they may see",[46,1367,1369],{"x":1355,"y":1368,"style":137},"152","trusted",[39,1371],{"x":1372,"y":1350,"width":1351,"height":1352,"rx":58,"fill":106,"stroke":60},"268",[46,1374,1376],{"x":1375,"y":57,"style":110},"379","query parameters",[46,1378,1379],{"x":1375,"y":1359,"style":1360},"region, date range",[46,1381,1382],{"x":1375,"y":1364,"style":1360},"may narrow only",[46,1384,1385],{"x":1375,"y":1368,"style":114},"validated",[39,1387],{"x":1388,"y":1350,"width":1389,"height":1352,"rx":58,"fill":1390,"stroke":60},"506","224","#fee8f2",[46,1392,1395],{"x":1393,"y":57,"style":1394},"618","font-size:12.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","filename",[46,1397,1398],{"x":1393,"y":1359,"style":1360},"never echoed back",[46,1400,1401],{"x":1393,"y":1364,"style":1360},"generated and sanitised",[46,1403,1405],{"x":1393,"y":1368,"style":1404},"font-size:11.5px;fill:var(--accent-ink,#be185d);text-anchor:middle","untrusted input",[46,1407,1409],{"x":48,"y":1408,"style":70},"196","A parameter that can widen the query is an authorisation bug wearing a filter's clothes",[144,1411,1413],{"id":1412},"handle-errors-before-the-response-starts","Handle errors before the response starts",[10,1415,1416],{},"Once Flask begins sending a file, it is too late to return an error page. Build the bytes first, then respond:",[149,1418,1420],{"className":191,"code":1419,"language":193,"meta":154,"style":154},"from flask import abort\n\n@app.get(\"\u002Fexports\u002Fsales.xlsx\")\ndef export_sales():\n    try:\n        df = pd.DataFrame(fetch_sales())\n        buffer = build_workbook(df)\n    except Exception:\n        app.logger.exception(\"sales export failed\")\n        abort(500)\n    return send_file(buffer, mimetype=XLSX, as_attachment=True,\n                     download_name=export_name(\"sales\"))\n",[156,1421,1422,1433,1437,1447,1455,1462,1471,1481,1491,1501,1510,1532],{"__ignoreMap":154},[159,1423,1424,1426,1428,1430],{"class":73,"line":161},[159,1425,238],{"class":206},[159,1427,241],{"class":210},[159,1429,207],{"class":206},[159,1431,1432],{"class":210}," abort\n",[159,1434,1435],{"class":73,"line":203},[159,1436,218],{"emptyLinePlaceholder":217},[159,1438,1439,1441,1443,1445],{"class":73,"line":214},[159,1440,294],{"class":293},[159,1442,297],{"class":210},[159,1444,300],{"class":168},[159,1446,270],{"class":210},[159,1448,1449,1451,1453],{"class":73,"line":221},[159,1450,308],{"class":206},[159,1452,311],{"class":293},[159,1454,314],{"class":210},[159,1456,1457,1460],{"class":73,"line":235},[159,1458,1459],{"class":206},"    try",[159,1461,537],{"class":210},[159,1463,1464,1466,1468],{"class":73,"line":249},[159,1465,1026],{"class":210},[159,1467,260],{"class":206},[159,1469,1470],{"class":210}," pd.DataFrame(fetch_sales())\n",[159,1472,1473,1476,1478],{"class":73,"line":254},[159,1474,1475],{"class":210},"        buffer ",[159,1477,260],{"class":206},[159,1479,1480],{"class":210}," build_workbook(df)\n",[159,1482,1483,1486,1489],{"class":73,"line":273},[159,1484,1485],{"class":206},"    except",[159,1487,1488],{"class":266}," Exception",[159,1490,537],{"class":210},[159,1492,1493,1496,1499],{"class":73,"line":285},[159,1494,1495],{"class":210},"        app.logger.exception(",[159,1497,1498],{"class":168},"\"sales export failed\"",[159,1500,270],{"class":210},[159,1502,1503,1505,1508],{"class":73,"line":290},[159,1504,957],{"class":210},[159,1506,1507],{"class":266},"500",[159,1509,270],{"class":210},[159,1511,1512,1514,1516,1518,1520,1522,1524,1526,1528,1530],{"class":73,"line":305},[159,1513,479],{"class":206},[159,1515,482],{"class":210},[159,1517,485],{"class":418},[159,1519,260],{"class":206},[159,1521,276],{"class":266},[159,1523,340],{"class":210},[159,1525,138],{"class":418},[159,1527,260],{"class":206},[159,1529,498],{"class":266},[159,1531,501],{"class":210},[159,1533,1534,1536,1538,1540,1543],{"class":73,"line":317},[159,1535,507],{"class":418},[159,1537,260],{"class":206},[159,1539,1102],{"class":210},[159,1541,1542],{"class":168},"\"sales\"",[159,1544,1545],{"class":210},"))\n",[10,1547,1548,1549,18],{},"Log the exception with context — user, filters, row count — so a failure is diagnosable without reproducing it. The logging setup is the same one scheduled jobs use in ",[14,1550,1552],{"href":1551},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Flog-python-excel-script-output-to-a-file\u002F","Log Python Excel script output to a file",[144,1554,1556],{"id":1555},"escape-values-excel-would-treat-as-formulas","Escape values Excel would treat as formulas",[10,1558,1559,1560,340,1562,340,1564,1566,1567,1570],{},"Any text cell whose first character is ",[156,1561,260],{},[156,1563,779],{},[156,1565,856],{}," or ",[156,1568,1569],{},"@"," becomes a live formula when the workbook is opened. If the data came from users, that is a spreadsheet-injection risk:",[149,1572,1574],{"className":191,"code":1573,"language":193,"meta":154,"style":154},"def escape_formula(value):\n    if isinstance(value, str) and value[:1] in (\"=\", \"+\", \"-\", \"@\"):\n        return \"'\" + value\n    return value\n\ndf = df.map(escape_formula)          # DataFrame.applymap on pandas \u003C 2.1\n",[156,1575,1576,1586,1636,1650,1656,1660],{"__ignoreMap":154},[159,1577,1578,1580,1583],{"class":73,"line":161},[159,1579,308],{"class":206},[159,1581,1582],{"class":293}," escape_formula",[159,1584,1585],{"class":210},"(value):\n",[159,1587,1588,1590,1593,1596,1598,1600,1603,1606,1608,1611,1613,1616,1619,1621,1624,1626,1628,1630,1633],{"class":73,"line":203},[159,1589,946],{"class":206},[159,1591,1592],{"class":266}," isinstance",[159,1594,1595],{"class":210},"(value, ",[159,1597,596],{"class":266},[159,1599,427],{"class":210},[159,1601,1602],{"class":206},"and",[159,1604,1605],{"class":210}," value[:",[159,1607,701],{"class":266},[159,1609,1610],{"class":210},"] ",[159,1612,738],{"class":206},[159,1614,1615],{"class":210}," (",[159,1617,1618],{"class":168},"\"=\"",[159,1620,340],{"class":210},[159,1622,1623],{"class":168},"\"+\"",[159,1625,340],{"class":210},[159,1627,1286],{"class":168},[159,1629,340],{"class":210},[159,1631,1632],{"class":168},"\"@\"",[159,1634,1635],{"class":210},"):\n",[159,1637,1638,1641,1644,1647],{"class":73,"line":214},[159,1639,1640],{"class":206},"        return",[159,1642,1643],{"class":168}," \"'\"",[159,1645,1646],{"class":206}," +",[159,1648,1649],{"class":210}," value\n",[159,1651,1652,1654],{"class":73,"line":221},[159,1653,479],{"class":206},[159,1655,1649],{"class":210},[159,1657,1658],{"class":73,"line":235},[159,1659,218],{"emptyLinePlaceholder":217},[159,1661,1662,1665,1667,1670],{"class":73,"line":249},[159,1663,1664],{"class":210},"df ",[159,1666,260],{"class":206},[159,1668,1669],{"class":210}," df.map(escape_formula)          ",[159,1671,1672],{"class":985},"# DataFrame.applymap on pandas \u003C 2.1\n",[144,1674,1676],{"id":1675},"test-the-endpoint-by-reading-the-file-back","Test the endpoint by reading the file back",[149,1678,1680],{"className":191,"code":1679,"language":193,"meta":154,"style":154},"\"\"\"test_exports.py\"\"\"\nimport io\n\nimport pandas as pd\nimport pytest\n\nfrom app import app\n\n@pytest.fixture\ndef client():\n    app.config[\"TESTING\"] = True\n    return app.test_client()\n\ndef test_sales_export(client):\n    resp = client.get(\"\u002Fexports\u002Fsales.xlsx\")\n    assert resp.status_code == 200\n    assert resp.headers[\"Content-Type\"].endswith(\"spreadsheetml.sheet\")\n    assert \"attachment\" in resp.headers[\"Content-Disposition\"]\n\n    df = pd.read_excel(io.BytesIO(resp.data), engine=\"openpyxl\")\n    assert list(df.columns) == [\"region\", \"revenue\"]\n    assert len(df) == 4\n",[156,1681,1682,1687,1693,1697,1707,1714,1718,1730,1734,1739,1748,1763,1770,1774,1784,1798,1812,1830,1847,1851,1869,1892],{"__ignoreMap":154},[159,1683,1684],{"class":73,"line":161},[159,1685,1686],{"class":168},"\"\"\"test_exports.py\"\"\"\n",[159,1688,1689,1691],{"class":73,"line":203},[159,1690,207],{"class":206},[159,1692,211],{"class":210},[159,1694,1695],{"class":73,"line":214},[159,1696,218],{"emptyLinePlaceholder":217},[159,1698,1699,1701,1703,1705],{"class":73,"line":221},[159,1700,207],{"class":206},[159,1702,226],{"class":210},[159,1704,229],{"class":206},[159,1706,232],{"class":210},[159,1708,1709,1711],{"class":73,"line":235},[159,1710,207],{"class":206},[159,1712,1713],{"class":210}," pytest\n",[159,1715,1716],{"class":73,"line":249},[159,1717,218],{"emptyLinePlaceholder":217},[159,1719,1720,1722,1725,1727],{"class":73,"line":254},[159,1721,238],{"class":206},[159,1723,1724],{"class":210}," app ",[159,1726,207],{"class":206},[159,1728,1729],{"class":210}," app\n",[159,1731,1732],{"class":73,"line":273},[159,1733,218],{"emptyLinePlaceholder":217},[159,1735,1736],{"class":73,"line":285},[159,1737,1738],{"class":293},"@pytest.fixture\n",[159,1740,1741,1743,1746],{"class":73,"line":290},[159,1742,308],{"class":206},[159,1744,1745],{"class":293}," client",[159,1747,314],{"class":210},[159,1749,1750,1753,1756,1758,1760],{"class":73,"line":305},[159,1751,1752],{"class":210},"    app.config[",[159,1754,1755],{"class":168},"\"TESTING\"",[159,1757,1610],{"class":210},[159,1759,260],{"class":206},[159,1761,1762],{"class":266}," True\n",[159,1764,1765,1767],{"class":73,"line":317},[159,1766,479],{"class":206},[159,1768,1769],{"class":210}," app.test_client()\n",[159,1771,1772],{"class":73,"line":328},[159,1773,218],{"emptyLinePlaceholder":217},[159,1775,1776,1778,1781],{"class":73,"line":359},[159,1777,308],{"class":206},[159,1779,1780],{"class":293}," test_sales_export",[159,1782,1783],{"class":210},"(client):\n",[159,1785,1786,1789,1791,1794,1796],{"class":73,"line":387},[159,1787,1788],{"class":210},"    resp ",[159,1790,260],{"class":206},[159,1792,1793],{"class":210}," client.get(",[159,1795,300],{"class":168},[159,1797,270],{"class":210},[159,1799,1800,1803,1806,1809],{"class":73,"line":393},[159,1801,1802],{"class":206},"    assert",[159,1804,1805],{"class":210}," resp.status_code ",[159,1807,1808],{"class":206},"==",[159,1810,1811],{"class":266}," 200\n",[159,1813,1814,1816,1819,1822,1825,1828],{"class":73,"line":398},[159,1815,1802],{"class":206},[159,1817,1818],{"class":210}," resp.headers[",[159,1820,1821],{"class":168},"\"Content-Type\"",[159,1823,1824],{"class":210},"].endswith(",[159,1826,1827],{"class":168},"\"spreadsheetml.sheet\"",[159,1829,270],{"class":210},[159,1831,1832,1834,1837,1840,1842,1845],{"class":73,"line":409},[159,1833,1802],{"class":206},[159,1835,1836],{"class":168}," \"attachment\"",[159,1838,1839],{"class":206}," in",[159,1841,1818],{"class":210},[159,1843,1844],{"class":168},"\"Content-Disposition\"",[159,1846,1270],{"class":210},[159,1848,1849],{"class":73,"line":435},[159,1850,218],{"emptyLinePlaceholder":217},[159,1852,1853,1855,1857,1860,1862,1864,1867],{"class":73,"line":461},[159,1854,320],{"class":210},[159,1856,260],{"class":206},[159,1858,1859],{"class":210}," pd.read_excel(io.BytesIO(resp.data), ",[159,1861,419],{"class":418},[159,1863,260],{"class":206},[159,1865,1866],{"class":168},"\"openpyxl\"",[159,1868,270],{"class":210},[159,1870,1871,1873,1876,1878,1880,1883,1885,1887,1890],{"class":73,"line":471},[159,1872,1802],{"class":206},[159,1874,1875],{"class":266}," list",[159,1877,853],{"class":210},[159,1879,1808],{"class":206},[159,1881,1882],{"class":210}," [",[159,1884,979],{"class":168},[159,1886,340],{"class":210},[159,1888,1889],{"class":168},"\"revenue\"",[159,1891,1270],{"class":210},[159,1893,1894,1896,1899,1902,1904],{"class":73,"line":476},[159,1895,1802],{"class":206},[159,1897,1898],{"class":266}," len",[159,1900,1901],{"class":210},"(df) ",[159,1903,1808],{"class":206},[159,1905,1906],{"class":266}," 4\n",[10,1908,1909],{},"Round-tripping through pandas is what catches the truncated-buffer bug: a file that is 200 bytes short still has the right headers and status code, and only fails when something tries to read it.",[144,1911,1913],{"id":1912},"return-several-sheets-from-one-route","Return several sheets from one route",[10,1915,1916,1917,1920],{},"A summary plus its detail is the most requested export shape, and it costs one extra ",[156,1918,1919],{},"to_excel"," call inside the same writer:",[149,1922,1924],{"className":191,"code":1923,"language":193,"meta":154,"style":154},"def build_multi_sheet(summary: pd.DataFrame, detail: pd.DataFrame) -> io.BytesIO:\n    buffer = io.BytesIO()\n    with pd.ExcelWriter(buffer, engine=\"xlsxwriter\") as writer:\n        summary.to_excel(writer, index=False, sheet_name=\"Summary\")\n        detail.to_excel(writer, index=False, sheet_name=\"Detail\")\n        for name, frame in ((\"Summary\", summary), (\"Detail\", detail)):\n            ws = writer.sheets[name]\n            ws.freeze_panes(1, 0)\n            ws.autofilter(0, 0, len(frame), len(frame.columns) - 1)\n    buffer.seek(0)\n    return buffer\n",[156,1925,1926,1936,1944,1962,1984,2006,2028,2038,2051,2080,2088],{"__ignoreMap":154},[159,1927,1928,1930,1933],{"class":73,"line":161},[159,1929,308],{"class":206},[159,1931,1932],{"class":293}," build_multi_sheet",[159,1934,1935],{"class":210},"(summary: pd.DataFrame, detail: pd.DataFrame) -> io.BytesIO:\n",[159,1937,1938,1940,1942],{"class":73,"line":203},[159,1939,401],{"class":210},[159,1941,260],{"class":206},[159,1943,406],{"class":210},[159,1945,1946,1948,1950,1952,1954,1956,1958,1960],{"class":73,"line":214},[159,1947,412],{"class":206},[159,1949,415],{"class":210},[159,1951,419],{"class":418},[159,1953,260],{"class":206},[159,1955,424],{"class":168},[159,1957,427],{"class":210},[159,1959,229],{"class":206},[159,1961,432],{"class":210},[159,1963,1964,1967,1969,1971,1973,1975,1977,1979,1982],{"class":73,"line":221},[159,1965,1966],{"class":210},"        summary.to_excel(writer, ",[159,1968,441],{"class":418},[159,1970,260],{"class":206},[159,1972,446],{"class":266},[159,1974,340],{"class":210},[159,1976,451],{"class":418},[159,1978,260],{"class":206},[159,1980,1981],{"class":168},"\"Summary\"",[159,1983,270],{"class":210},[159,1985,1986,1989,1991,1993,1995,1997,1999,2001,2004],{"class":73,"line":235},[159,1987,1988],{"class":210},"        detail.to_excel(writer, ",[159,1990,441],{"class":418},[159,1992,260],{"class":206},[159,1994,446],{"class":266},[159,1996,340],{"class":210},[159,1998,451],{"class":418},[159,2000,260],{"class":206},[159,2002,2003],{"class":168},"\"Detail\"",[159,2005,270],{"class":210},[159,2007,2008,2010,2013,2015,2018,2020,2023,2025],{"class":73,"line":249},[159,2009,732],{"class":206},[159,2011,2012],{"class":210}," name, frame ",[159,2014,738],{"class":206},[159,2016,2017],{"class":210}," ((",[159,2019,1981],{"class":168},[159,2021,2022],{"class":210},", summary), (",[159,2024,2003],{"class":168},[159,2026,2027],{"class":210},", detail)):\n",[159,2029,2030,2033,2035],{"class":73,"line":254},[159,2031,2032],{"class":210},"            ws ",[159,2034,260],{"class":206},[159,2036,2037],{"class":210}," writer.sheets[name]\n",[159,2039,2040,2043,2045,2047,2049],{"class":73,"line":273},[159,2041,2042],{"class":210},"            ws.freeze_panes(",[159,2044,701],{"class":266},[159,2046,340],{"class":210},[159,2048,41],{"class":266},[159,2050,270],{"class":210},[159,2052,2053,2056,2058,2060,2062,2064,2066,2069,2071,2074,2076,2078],{"class":73,"line":285},[159,2054,2055],{"class":210},"            ws.autofilter(",[159,2057,41],{"class":266},[159,2059,340],{"class":210},[159,2061,41],{"class":266},[159,2063,340],{"class":210},[159,2065,769],{"class":266},[159,2067,2068],{"class":210},"(frame), ",[159,2070,769],{"class":266},[159,2072,2073],{"class":210},"(frame.columns) ",[159,2075,856],{"class":206},[159,2077,859],{"class":266},[159,2079,270],{"class":210},[159,2081,2082,2084,2086],{"class":73,"line":290},[159,2083,464],{"class":210},[159,2085,41],{"class":266},[159,2087,270],{"class":210},[159,2089,2090,2092],{"class":73,"line":305},[159,2091,479],{"class":206},[159,2093,876],{"class":210},[10,2095,2096,2097,2100,2101,18],{},"Sheet names are subject to Excel's rules — 31 characters, no ",[156,2098,2099],{},"\u002F \\ ? * [ ]"," — so sanitise any name derived from data before passing it. A workbook that breaks those rules opens with a repair prompt rather than an error in your code, which is covered in ",[14,2102,2104],{"href":2103},"\u002Fgetting-started-with-python-excel-automation\u002Ftroubleshooting-common-python-excel-errors\u002Ffix-excel-repaired-content-after-writing-with-python\u002F","Fix \"Excel found unreadable content\" after writing with Python",[144,2106,2108],{"id":2107},"offer-the-same-data-as-csv","Offer the same data as CSV",[10,2110,2111],{},"Not every consumer wants a workbook. A second route costs a few lines, streams row by row, and takes the largest exports off the memory-hungry path entirely:",[149,2113,2115],{"className":191,"code":2114,"language":193,"meta":154,"style":154},"from flask import Response\n\n@app.get(\"\u002Fexports\u002Fsales.csv\")\ndef export_sales_csv():\n    def rows():\n        yield \"region,revenue\\r\\n\"\n        for region, revenue in fetch_sales():\n            yield f\"{region},{revenue:.2f}\\r\\n\"\n\n    return Response(\n        rows(),\n        mimetype=\"text\u002Fcsv\",\n        headers={\"Content-Disposition\": f'attachment; filename=\"{export_name(\"sales\")[:-5]}.csv\"'},\n    )\n",[156,2116,2117,2128,2132,2143,2152,2162,2176,2188,2219,2223,2230,2235,2247,2289],{"__ignoreMap":154},[159,2118,2119,2121,2123,2125],{"class":73,"line":161},[159,2120,238],{"class":206},[159,2122,241],{"class":210},[159,2124,207],{"class":206},[159,2126,2127],{"class":210}," Response\n",[159,2129,2130],{"class":73,"line":203},[159,2131,218],{"emptyLinePlaceholder":217},[159,2133,2134,2136,2138,2141],{"class":73,"line":214},[159,2135,294],{"class":293},[159,2137,297],{"class":210},[159,2139,2140],{"class":168},"\"\u002Fexports\u002Fsales.csv\"",[159,2142,270],{"class":210},[159,2144,2145,2147,2150],{"class":73,"line":221},[159,2146,308],{"class":206},[159,2148,2149],{"class":293}," export_sales_csv",[159,2151,314],{"class":210},[159,2153,2154,2157,2160],{"class":73,"line":235},[159,2155,2156],{"class":206},"    def",[159,2158,2159],{"class":293}," rows",[159,2161,314],{"class":210},[159,2163,2164,2167,2170,2173],{"class":73,"line":249},[159,2165,2166],{"class":206},"        yield",[159,2168,2169],{"class":168}," \"region,revenue",[159,2171,2172],{"class":1168},"\\r\\n",[159,2174,2175],{"class":168},"\"\n",[159,2177,2178,2180,2183,2185],{"class":73,"line":254},[159,2179,732],{"class":206},[159,2181,2182],{"class":210}," region, revenue ",[159,2184,738],{"class":206},[159,2186,2187],{"class":210}," fetch_sales():\n",[159,2189,2190,2193,2195,2197,2199,2201,2203,2206,2208,2211,2214,2217],{"class":73,"line":273},[159,2191,2192],{"class":206},"            yield",[159,2194,1304],{"class":206},[159,2196,1159],{"class":168},[159,2198,1242],{"class":1168},[159,2200,1007],{"class":210},[159,2202,1265],{"class":1168},[159,2204,2205],{"class":168},",",[159,2207,1242],{"class":1168},[159,2209,2210],{"class":210},"revenue",[159,2212,2213],{"class":206},":.2f",[159,2215,2216],{"class":1168},"}\\r\\n",[159,2218,2175],{"class":168},[159,2220,2221],{"class":73,"line":285},[159,2222,218],{"emptyLinePlaceholder":217},[159,2224,2225,2227],{"class":73,"line":290},[159,2226,479],{"class":206},[159,2228,2229],{"class":210}," Response(\n",[159,2231,2232],{"class":73,"line":305},[159,2233,2234],{"class":210},"        rows(),\n",[159,2236,2237,2240,2242,2245],{"class":73,"line":317},[159,2238,2239],{"class":418},"        mimetype",[159,2241,260],{"class":206},[159,2243,2244],{"class":168},"\"text\u002Fcsv\"",[159,2246,501],{"class":210},[159,2248,2249,2252,2254,2256,2258,2260,2262,2265,2267,2269,2271,2274,2276,2279,2281,2283,2286],{"class":73,"line":328},[159,2250,2251],{"class":418},"        headers",[159,2253,260],{"class":206},[159,2255,1242],{"class":210},[159,2257,1844],{"class":168},[159,2259,679],{"class":210},[159,2261,1237],{"class":206},[159,2263,2264],{"class":168},"'attachment; filename=\"",[159,2266,1242],{"class":1168},[159,2268,1102],{"class":210},[159,2270,1542],{"class":168},[159,2272,2273],{"class":210},")[:",[159,2275,856],{"class":206},[159,2277,2278],{"class":266},"5",[159,2280,1172],{"class":210},[159,2282,1265],{"class":1168},[159,2284,2285],{"class":168},".csv\"'",[159,2287,2288],{"class":210},"},\n",[159,2290,2291],{"class":73,"line":359},[159,2292,2293],{"class":210},"    )\n",[10,2295,2296,2297,2300,2301,18],{},"Because this is a generator, Flask streams it: memory stays flat regardless of row count, and the first bytes reach the client immediately. That is the one thing an ",[156,2298,2299],{},".xlsx"," cannot do, since the zip is only finalised at the end — the trade-off set out in ",[14,2302,2304],{"href":2303},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-large-excel-files-in-python\u002Fconvert-excel-to-csv-with-python\u002F","Convert Excel to CSV with Python",[20,2306,29,2311,29,2314,29,2317,29,2319,29,2322,29,2328,29,2335,29,2341,29,2344,29,2347,29,2350,29,2353,29,2357,29,2361,29,2367,29,2370,29,2375],{"viewBox":2307,"role":23,"ariaLabelledBy":2308,"xmlns":27,"style":28},"0 0 760 196",[2309,2310],"fl3-t","fl3-d",[31,2312,2313],{"id":2309},"Why CSV streams and xlsx cannot",[35,2315,2316],{"id":2310},"CSV rows leave the server as they are produced, while an xlsx must be fully built and zipped before the first byte can be sent.",[39,2318],{"x":41,"y":41,"width":42,"height":1408,"fill":44},[46,2320,2321],{"x":48,"y":49,"style":50},"First byte out, by format",[46,2323,2327],{"x":2324,"y":2325,"style":2326},"86","72","font-size:12px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","CSV",[39,2329],{"x":2330,"y":2331,"width":2332,"height":2333,"rx":2334,"fill":129,"stroke":60},"140","54","90","28","6",[46,2336,2340],{"x":2337,"y":2338,"style":2339},"185","74","font-size:11px;fill:var(--teal-ink,#0b6157);text-anchor:middle","rows out",[39,2342],{"x":2343,"y":2331,"width":2332,"height":2333,"rx":2334,"fill":129,"stroke":60},"234",[46,2345,2340],{"x":2346,"y":2338,"style":2339},"279",[39,2348],{"x":2349,"y":2331,"width":2332,"height":2333,"rx":2334,"fill":129,"stroke":60},"328",[46,2351,2340],{"x":2352,"y":2338,"style":2339},"373",[46,2354,2356],{"x":2324,"y":2355,"style":2326},"132","xlsx",[39,2358],{"x":2330,"y":2359,"width":2360,"height":2333,"rx":2334,"fill":88,"stroke":60},"114","372",[46,2362,2366],{"x":2363,"y":2364,"style":2365},"326","134","font-size:11px;fill:var(--brand-strong,#4338ca);text-anchor:middle","build the whole workbook in memory",[39,2368],{"x":2369,"y":2359,"width":1364,"height":2333,"rx":2334,"fill":106,"stroke":60},"516",[46,2371,2374],{"x":2372,"y":2364,"style":2373},"581","font-size:11px;fill:var(--gold-ink,#7a4e06);text-anchor:middle","zip, then send",[46,2376,2378],{"x":48,"y":2377,"style":70},"176","The zip's directory lives at the end of the file, so nothing can leave early",[144,2380,2382],{"id":2381},"common-pitfalls-and-gotchas","Common pitfalls and gotchas",[2384,2385,2386,2397,2406,2418,2427],"ul",{},[2387,2388,2389,2396],"li",{},[2390,2391,2392,2393,2395],"strong",{},"Reading the buffer inside the ",[156,2394,562],{}," block."," The zip is not finished until the writer closes.",[2387,2398,2399,2405],{},[2390,2400,2401,2402,18],{},"Forgetting ",[156,2403,2404],{},"seek(0)"," The response is then empty, because the read starts at the end of the buffer.",[2387,2407,2408,2414,2415,2417],{},[2390,2409,2410,2413],{},[156,2411,2412],{},"attachment_filename"," on Flask 2+."," It is ",[156,2416,1097],{}," now.",[2387,2419,2420,2426],{},[2390,2421,2422,2423,18],{},"Returning ",[156,2424,2425],{},"df.to_excel(\"file.xlsx\")"," Writing to disk in a web process creates a race between concurrent requests and leaves files behind.",[2387,2428,2429,2432],{},[2390,2430,2431],{},"Building a slow report inline."," Past a few seconds, move it to a queue before a proxy times the request out.",[144,2434,2436],{"id":2435},"performance-and-scale-notes","Performance and scale notes",[10,2438,2439,2440,2443,2444,2448],{},"xlsxwriter holds the workbook in memory, so peak usage per request is roughly the DataFrame plus the finished file. Under concurrency that multiplies by the number of simultaneous exports, which is the number to cap — either with a row limit on inline exports or by moving large ones to a background worker. If a single export is genuinely large, construct the workbook with ",[156,2441,2442],{},"{\"constant_memory\": True}"," and accept its restrictions, as described in ",[14,2445,2447],{"href":2446},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fbuilding-excel-reports-with-xlsxwriter\u002Fwrite-a-million-rows-to-excel-with-xlsxwriter-constant-memory\u002F","Write a million rows to Excel with xlsxwriter constant memory",". Also turn off proxy buffering for the export route so nginx does not spool the whole download before forwarding it.",[144,2450,2452],{"id":2451},"conclusion","Conclusion",[10,2454,2455,2456,2459,2460,2463],{},"The Flask half of an Excel export is four steps: scope the query to the session, write into a ",[156,2457,2458],{},"BytesIO",", close and rewind, then hand the buffer to ",[156,2461,2462],{},"send_file"," with a mimetype and a download name. Everything else — formatting, empty results, filename generation, formula escaping — is a few lines each and turns a working endpoint into one you can leave in production. Test it by reading the response back with pandas, and the two failure modes that matter cannot ship unnoticed.",[144,2465,2467],{"id":2466},"frequently-asked-questions","Frequently asked questions",[10,2469,2470,2473,2474,2477,2478,2480,2481,2483,2484,18],{},[2390,2471,2472],{},"Why does the downloaded file open as gibberish or refuse to open?","\nAlmost always the buffer was read before the writer closed, or it was not rewound. Close the ",[156,2475,2476],{},"ExcelWriter"," (a ",[156,2479,562],{}," block does it), then call ",[156,2482,2404],{}," before handing the buffer to ",[156,2485,2462],{},[10,2487,2488,2491,2493,2494,2497,2498,574,2501,2503],{},[2390,2489,2490],{},"Should I use send_file or make_response?",[156,2492,2462],{}," when you have a buffer and want Flask to set the headers; ",[156,2495,2496],{},"make_response"," when you already have bytes and prefer to set ",[156,2499,2500],{},"Content-Type",[156,2502,1327],{}," yourself. Both produce the same download.",[10,2505,2506,2509,2511,2512,2514,2515,18],{},[2390,2507,2508],{},"Is download_name or attachment_filename correct?",[156,2510,1097],{},", since Flask 2.0. ",[156,2513,2412],{}," is the old name and still appears in older tutorials; using it on a current Flask raises a ",[156,2516,2517],{},"TypeError",[10,2519,2520,2523],{},[2390,2521,2522],{},"How do I keep two users from getting each other's data?","\nBuild the query from the session's identity, not from request parameters. Parameters may narrow the result; they must never widen it beyond what the caller is allowed to see.",[10,2525,2526,2529,2530,2532,2533,2535,2536,2538],{},[2390,2527,2528],{},"Can I return a workbook with several sheets?","\nYes. Write each DataFrame with a different ",[156,2531,451],{}," through the same ",[156,2534,2476],{}," before the ",[156,2537,562],{}," block closes; the response code is unchanged.",[144,2540,2542],{"id":2541},"related","Related",[2384,2544,2545,2554,2561,2568,2575],{},[2387,2546,2547,2550,2551,2553],{},[2390,2548,2549],{},"Up:"," ",[14,2552,17],{"href":16}," — the same pattern across frameworks, plus background jobs.",[2387,2555,2556,2560],{},[14,2557,2559],{"href":2558},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-an-excel-workbook-in-memory-with-bytesio\u002F","Build an Excel workbook in memory with BytesIO"," — the buffer mechanics in detail.",[2387,2562,2563,2567],{},[14,2564,2566],{"href":2565},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fstream-an-excel-file-from-a-fastapi-endpoint\u002F","Stream an Excel file from a FastAPI endpoint"," — the async equivalent and its threadpool caveat.",[2387,2569,2570,2574],{},[14,2571,2573],{"href":2572},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fbuilding-excel-reports-with-xlsxwriter\u002Fwrite-a-formatted-excel-report-with-xlsxwriter\u002F","Write a formatted Excel report with xlsxwriter"," — the formatting options available inside the writer.",[2387,2576,2577,2581],{},[14,2578,2580],{"href":2579},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Ftest-excel-output-with-pytest\u002F","Test Excel output with pytest"," — asserting on generated workbooks beyond the shape check above.",[2583,2584,2585],"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 .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}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 .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":154,"searchDepth":203,"depth":203,"links":2587},[2588,2589,2590,2591,2592,2593,2594,2595,2596,2597,2598,2599,2600,2601,2602],{"id":146,"depth":203,"text":147},{"id":187,"depth":203,"text":188},{"id":566,"depth":203,"text":567},{"id":882,"depth":203,"text":883},{"id":1114,"depth":203,"text":1115},{"id":1412,"depth":203,"text":1413},{"id":1555,"depth":203,"text":1556},{"id":1675,"depth":203,"text":1676},{"id":1912,"depth":203,"text":1913},{"id":2107,"depth":203,"text":2108},{"id":2381,"depth":203,"text":2382},{"id":2435,"depth":203,"text":2436},{"id":2451,"depth":203,"text":2452},{"id":2466,"depth":203,"text":2467},{"id":2541,"depth":203,"text":2542},"2026-08-27","A complete Flask export route: build the workbook in memory, set the download headers, format the sheet, handle empty results and errors, and test the response.","md",[2607,2609,2611,2613,2615],{"q":2472,"a":2608},"Almost always the buffer was read before the writer closed, or it was not rewound. Close the ExcelWriter (a with block does it), then call seek(0) before handing the buffer to send_file.",{"q":2490,"a":2610},"send_file when you have a buffer and want Flask to set the headers; make_response when you already have bytes and prefer to set Content-Type and Content-Disposition yourself. Both produce the same download.",{"q":2508,"a":2612},"download_name, since Flask 2.0. attachment_filename is the old name and still appears in older tutorials; using it on a current Flask raises a TypeError.",{"q":2522,"a":2614},"Build the query from the session's identity, not from request parameters. Parameters may narrow the result; they must never widen it beyond what the caller is allowed to see.",{"q":2528,"a":2616},"Yes. Write each DataFrame with a different sheet_name through the same ExcelWriter before the with block closes; the response code is unchanged.",{},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Freturn-an-excel-file-from-a-flask-download-endpoint",{"title":2620,"description":2621},"Flask Excel Download Endpoint","Serve .xlsx from Flask with send_file and BytesIO: correct mimetype and download_name, formatted output, empty-result handling, safe filenames, and a pytest check.","return-an-excel-file-from-a-flask-download-endpoint","automating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Freturn-an-excel-file-from-a-flask-download-endpoint\u002Findex","how-to","l_AIxoCjSf1OFODGXpxhaXbwjJyMgnt7IOPhAZPJYVk",[2627,2631],{"title":2628,"path":2629,"stem":2630,"children":-1},"Handle an Uploaded Excel File in Flask and FastAPI","\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fhandle-an-uploaded-excel-file-in-flask-and-fastapi","automating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fhandle-an-uploaded-excel-file-in-flask-and-fastapi\u002Findex",{"title":2632,"path":2633,"stem":2634,"children":-1},"Stream an Excel File from a FastAPI Endpoint","\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fstream-an-excel-file-from-a-fastapi-endpoint","automating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fstream-an-excel-file-from-a-fastapi-endpoint\u002Findex",1788710158851]