[{"data":1,"prerenderedAt":3278},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps":3,"surround:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps":3269},{"id":4,"title":5,"body":6,"dateModified":3245,"datePublished":3246,"description":3247,"extension":3248,"faq":3249,"meta":3260,"navigation":206,"path":3261,"seo":3262,"slug":3265,"stem":3266,"type":3267,"__hash__":3268},"docs\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Findex.md","Serving Excel Files from Python Web Apps",{"type":7,"value":8,"toc":3224},"minimark",[9,19,160,165,173,369,385,389,392,436,447,451,458,700,715,719,730,960,971,1049,1053,1060,1283,1293,1297,1300,1417,1429,1433,1436,1484,1576,1580,1587,1855,1862,1866,1869,2029,2036,2040,2051,2055,2066,2310,2316,2380,2384,2387,2456,2459,2583,2594,2598,2601,2791,2917,2920,2924,2927,2933,2964,2970,3040,3046,3053,3057,3064,3075,3079,3108,3112,3121,3136,3142,3151,3157,3161,3164,3168,3220],[10,11,12,13,18],"p",{},"Sooner or later a Python web app needs an \"Export to Excel\" button. The mechanics are not hard, but the details decide whether it works under load: build the workbook in memory rather than on disk, send the right two headers, keep slow reports out of the request cycle, and make sure the export cannot be pointed at somebody else's data. This topic covers the pattern in Flask, FastAPI and Django, plus the background-job approach for reports too slow to generate inline. It extends ",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002F","Automating Reporting Workflows"," from scheduled delivery into on-demand delivery.",[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,98,29,102,29,105,29,109,29,114,29,117,29,122,29,126,29,134,29,140,29,145,29,148,29,152,29,156],"svg",{"viewBox":22,"role":23,"ariaLabelledBy":24,"xmlns":27,"style":28},"0 0 760 246","img",[25,26],"web-t","web-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 path of an export request",[35,36,37],"desc",{"id":26},"A request is authorised, the query runs, a workbook is built in a memory buffer, and the bytes are returned with a spreadsheet content type and an attachment disposition header.",[39,40],"rect",{"x":41,"y":41,"width":42,"height":43,"fill":44},"0","760","246","#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","Nothing touches the filesystem",[39,53],{"x":54,"y":55,"width":56,"height":57,"rx":58,"fill":59,"stroke":60},"24","86","128","66","11","#f0f2f5","var(--line,#cdd5e6)",[46,62,66],{"x":63,"y":64,"style":65},"88","114","font-size:12px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","GET \u002Fexport",[46,68,71],{"x":63,"y":69,"style":70},"136","font-size:11.5px;fill:var(--muted,#5b6780);text-anchor:middle","session cookie",[73,74],"line",{"x1":75,"y1":76,"x2":77,"y2":76,"stroke":78,"style":79},"152","119","182","var(--brand,#5b5cf0)","stroke-width:2px",[81,82],"polygon",{"points":83,"fill":84},"182,119 172,114 172,124","#5b5cf0",[39,86],{"x":87,"y":55,"width":56,"height":57,"rx":58,"fill":88,"stroke":60},"186","#fee8f2",[46,90,93],{"x":91,"y":64,"style":92},"250","font-size:12px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","authorise",[46,95,97],{"x":91,"y":69,"style":96},"font-size:11.5px;fill:var(--accent-ink,#be185d);text-anchor:middle","scope the query",[73,99],{"x1":100,"y1":76,"x2":101,"y2":76,"stroke":78,"style":79},"314","344",[81,103],{"points":104,"fill":84},"344,119 334,114 334,124",[39,106],{"x":107,"y":55,"width":56,"height":57,"rx":58,"fill":108,"stroke":60},"348","#ebebfd",[46,110,113],{"x":111,"y":64,"style":112},"412","font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","build in",[46,115,116],{"x":111,"y":69,"style":112},"BytesIO",[73,118],{"x1":119,"y1":76,"x2":120,"y2":76,"stroke":121,"style":79},"476","506","var(--teal,#0f9488)",[81,123],{"points":124,"fill":125},"506,119 496,114 496,124","#0f766e",[39,127],{"x":128,"y":129,"width":130,"height":131,"rx":132,"fill":133,"stroke":60},"510","60","226","52","10","#d9f4f1",[46,135,139],{"x":136,"y":137,"style":138},"623","82","font-size:11.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Content-Type:",[46,141,144],{"x":136,"y":142,"style":143},"100","font-size:11px;fill:var(--teal-ink,#0b6157);text-anchor:middle","…spreadsheetml.sheet",[39,146],{"x":128,"y":147,"width":130,"height":131,"rx":132,"fill":133,"stroke":60},"126",[46,149,151],{"x":136,"y":150,"style":138},"148","Content-Disposition:",[46,153,155],{"x":136,"y":154,"style":143},"166","attachment; filename=…",[46,157,159],{"x":48,"y":158,"style":70},"222","Two headers turn a byte stream into a saved workbook",[161,162,164],"h2",{"id":163},"build-the-workbook-in-memory","Build the workbook in memory",[10,166,167,168,172],{},"Every framework's answer starts the same way: write into a buffer, not a file. ",[169,170,171],"code",{},"io.BytesIO"," behaves like a file object, so both pandas and xlsxwriter accept it directly:",[174,175,180],"pre",{"className":176,"code":177,"language":178,"meta":179,"style":179},"language-python shiki shiki-themes github-light github-dark-high-contrast","\"\"\"Return an .xlsx as bytes, with no temporary file anywhere.\"\"\"\nimport io\n\nimport pandas as pd\n\ndef build_workbook(df: pd.DataFrame) -> bytes:\n    buffer = io.BytesIO()\n    with pd.ExcelWriter(buffer, engine=\"xlsxwriter\") as writer:\n        df.to_excel(writer, sheet_name=\"Report\", index=False)\n        sheet = writer.sheets[\"Report\"]\n        sheet.freeze_panes(1, 0)\n        sheet.autofit()\n    buffer.seek(0)\n    return buffer.getvalue()\n","python","",[169,181,182,190,201,208,222,227,247,259,285,313,329,344,350,360],{"__ignoreMap":179},[183,184,186],"span",{"class":73,"line":185},1,[183,187,189],{"class":188},"srMev","\"\"\"Return an .xlsx as bytes, with no temporary file anywhere.\"\"\"\n",[183,191,193,197],{"class":73,"line":192},2,[183,194,196],{"class":195},"s-kum","import",[183,198,200],{"class":199},"skGVy"," io\n",[183,202,204],{"class":73,"line":203},3,[183,205,207],{"emptyLinePlaceholder":206},true,"\n",[183,209,211,213,216,219],{"class":73,"line":210},4,[183,212,196],{"class":195},[183,214,215],{"class":199}," pandas ",[183,217,218],{"class":195},"as",[183,220,221],{"class":199}," pd\n",[183,223,225],{"class":73,"line":224},5,[183,226,207],{"emptyLinePlaceholder":206},[183,228,230,233,237,240,244],{"class":73,"line":229},6,[183,231,232],{"class":195},"def",[183,234,236],{"class":235},"s_Opv"," build_workbook",[183,238,239],{"class":199},"(df: pd.DataFrame) -> ",[183,241,243],{"class":242},"sP0c6","bytes",[183,245,246],{"class":199},":\n",[183,248,250,253,256],{"class":73,"line":249},7,[183,251,252],{"class":199},"    buffer ",[183,254,255],{"class":195},"=",[183,257,258],{"class":199}," io.BytesIO()\n",[183,260,262,265,268,272,274,277,280,282],{"class":73,"line":261},8,[183,263,264],{"class":195},"    with",[183,266,267],{"class":199}," pd.ExcelWriter(buffer, ",[183,269,271],{"class":270},"sa561","engine",[183,273,255],{"class":195},[183,275,276],{"class":188},"\"xlsxwriter\"",[183,278,279],{"class":199},") ",[183,281,218],{"class":195},[183,283,284],{"class":199}," writer:\n",[183,286,288,291,294,296,299,302,305,307,310],{"class":73,"line":287},9,[183,289,290],{"class":199},"        df.to_excel(writer, ",[183,292,293],{"class":270},"sheet_name",[183,295,255],{"class":195},[183,297,298],{"class":188},"\"Report\"",[183,300,301],{"class":199},", ",[183,303,304],{"class":270},"index",[183,306,255],{"class":195},[183,308,309],{"class":242},"False",[183,311,312],{"class":199},")\n",[183,314,316,319,321,324,326],{"class":73,"line":315},10,[183,317,318],{"class":199},"        sheet ",[183,320,255],{"class":195},[183,322,323],{"class":199}," writer.sheets[",[183,325,298],{"class":188},[183,327,328],{"class":199},"]\n",[183,330,332,335,338,340,342],{"class":73,"line":331},11,[183,333,334],{"class":199},"        sheet.freeze_panes(",[183,336,337],{"class":242},"1",[183,339,301],{"class":199},[183,341,41],{"class":242},[183,343,312],{"class":199},[183,345,347],{"class":73,"line":346},12,[183,348,349],{"class":199},"        sheet.autofit()\n",[183,351,353,356,358],{"class":73,"line":352},13,[183,354,355],{"class":199},"    buffer.seek(",[183,357,41],{"class":242},[183,359,312],{"class":199},[183,361,363,366],{"class":73,"line":362},14,[183,364,365],{"class":195},"    return",[183,367,368],{"class":199}," buffer.getvalue()\n",[10,370,371,372,375,376,379,380,384],{},"Two details are load-bearing. The ",[169,373,374],{},"with"," block closes the writer, which is when xlsxwriter actually finalises the zip — return the buffer before that and you ship a truncated file. And ",[169,377,378],{},"seek(0)"," rewinds the buffer so the whole workbook is read, not the zero bytes after the write position. The full mechanics are in ",[14,381,383],{"href":382},"\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",".",[161,386,388],{"id":387},"send-the-right-headers","Send the right headers",[10,390,391],{},"A download is defined by two headers. Get either wrong and the browser renders binary noise or saves a file Excel refuses to open:",[393,394,395,408],"table",{},[396,397,398],"thead",{},[399,400,401,405],"tr",{},[402,403,404],"th",{},"Header",[402,406,407],{},"Value",[409,410,411,424],"tbody",{},[399,412,413,419],{},[414,415,416],"td",{},[169,417,418],{},"Content-Type",[414,420,421],{},[169,422,423],{},"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet",[399,425,426,431],{},[414,427,428],{},[169,429,430],{},"Content-Disposition",[414,432,433],{},[169,434,435],{},"attachment; filename=\"sales_2026-08.xlsx\"",[10,437,438,439,442,443,446],{},"Add ",[169,440,441],{},"Content-Length"," when you have the bytes in hand — browsers show a progress bar with it and cannot without. For non-ASCII filenames use the RFC 5987 form, ",[169,444,445],{},"filename*=UTF-8''…",", since a raw Unicode filename in the plain parameter is not portable.",[161,448,450],{"id":449},"flask","Flask",[10,452,453,454,457],{},"Flask's ",[169,455,456],{},"send_file"," does the header work when you give it a buffer and a name:",[174,459,461],{"className":176,"code":460,"language":178,"meta":179,"style":179},"import io\n\nfrom flask import Flask, send_file\nimport pandas as pd\n\napp = Flask(__name__)\n\n@app.get(\"\u002Fexports\u002Fsales.xlsx\")\ndef export_sales():\n    df = pd.DataFrame({\"region\": [\"North\", \"South\"], \"revenue\": [128_400.5, 96_220.0]})\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    return send_file(\n        buffer,\n        mimetype=\"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n        as_attachment=True,\n        download_name=\"sales.xlsx\",\n    )\n",[169,462,463,469,473,486,496,500,515,519,532,542,585,593,611,632,640,648,654,668,681,694],{"__ignoreMap":179},[183,464,465,467],{"class":73,"line":185},[183,466,196],{"class":195},[183,468,200],{"class":199},[183,470,471],{"class":73,"line":192},[183,472,207],{"emptyLinePlaceholder":206},[183,474,475,478,481,483],{"class":73,"line":203},[183,476,477],{"class":195},"from",[183,479,480],{"class":199}," flask ",[183,482,196],{"class":195},[183,484,485],{"class":199}," Flask, send_file\n",[183,487,488,490,492,494],{"class":73,"line":210},[183,489,196],{"class":195},[183,491,215],{"class":199},[183,493,218],{"class":195},[183,495,221],{"class":199},[183,497,498],{"class":73,"line":224},[183,499,207],{"emptyLinePlaceholder":206},[183,501,502,505,507,510,513],{"class":73,"line":229},[183,503,504],{"class":199},"app ",[183,506,255],{"class":195},[183,508,509],{"class":199}," Flask(",[183,511,512],{"class":242},"__name__",[183,514,312],{"class":199},[183,516,517],{"class":73,"line":249},[183,518,207],{"emptyLinePlaceholder":206},[183,520,521,524,527,530],{"class":73,"line":261},[183,522,523],{"class":235},"@app.get",[183,525,526],{"class":199},"(",[183,528,529],{"class":188},"\"\u002Fexports\u002Fsales.xlsx\"",[183,531,312],{"class":199},[183,533,534,536,539],{"class":73,"line":287},[183,535,232],{"class":195},[183,537,538],{"class":235}," export_sales",[183,540,541],{"class":199},"():\n",[183,543,544,547,549,552,555,558,561,563,566,569,572,574,577,579,582],{"class":73,"line":315},[183,545,546],{"class":199},"    df ",[183,548,255],{"class":195},[183,550,551],{"class":199}," pd.DataFrame({",[183,553,554],{"class":188},"\"region\"",[183,556,557],{"class":199},": [",[183,559,560],{"class":188},"\"North\"",[183,562,301],{"class":199},[183,564,565],{"class":188},"\"South\"",[183,567,568],{"class":199},"], ",[183,570,571],{"class":188},"\"revenue\"",[183,573,557],{"class":199},[183,575,576],{"class":242},"128_400.5",[183,578,301],{"class":199},[183,580,581],{"class":242},"96_220.0",[183,583,584],{"class":199},"]})\n",[183,586,587,589,591],{"class":73,"line":331},[183,588,252],{"class":199},[183,590,255],{"class":195},[183,592,258],{"class":199},[183,594,595,597,599,601,603,605,607,609],{"class":73,"line":346},[183,596,264],{"class":195},[183,598,267],{"class":199},[183,600,271],{"class":270},[183,602,255],{"class":195},[183,604,276],{"class":188},[183,606,279],{"class":199},[183,608,218],{"class":195},[183,610,284],{"class":199},[183,612,613,615,617,619,621,623,625,627,630],{"class":73,"line":352},[183,614,290],{"class":199},[183,616,304],{"class":270},[183,618,255],{"class":195},[183,620,309],{"class":242},[183,622,301],{"class":199},[183,624,293],{"class":270},[183,626,255],{"class":195},[183,628,629],{"class":188},"\"Sales\"",[183,631,312],{"class":199},[183,633,634,636,638],{"class":73,"line":362},[183,635,355],{"class":199},[183,637,41],{"class":242},[183,639,312],{"class":199},[183,641,643,645],{"class":73,"line":642},15,[183,644,365],{"class":195},[183,646,647],{"class":199}," send_file(\n",[183,649,651],{"class":73,"line":650},16,[183,652,653],{"class":199},"        buffer,\n",[183,655,657,660,662,665],{"class":73,"line":656},17,[183,658,659],{"class":270},"        mimetype",[183,661,255],{"class":195},[183,663,664],{"class":188},"\"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet\"",[183,666,667],{"class":199},",\n",[183,669,671,674,676,679],{"class":73,"line":670},18,[183,672,673],{"class":270},"        as_attachment",[183,675,255],{"class":195},[183,677,678],{"class":242},"True",[183,680,667],{"class":199},[183,682,684,687,689,692],{"class":73,"line":683},19,[183,685,686],{"class":270},"        download_name",[183,688,255],{"class":195},[183,690,691],{"class":188},"\"sales.xlsx\"",[183,693,667],{"class":199},[183,695,697],{"class":73,"line":696},20,[183,698,699],{"class":199},"    )\n",[10,701,702,703,706,707,710,711,384],{},"The parameter is ",[169,704,705],{},"download_name"," in Flask 2.0 and later; older code uses ",[169,708,709],{},"attachment_filename",". The complete version, including error handling and dated filenames, is in ",[14,712,714],{"href":713},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Freturn-an-excel-file-from-a-flask-download-endpoint\u002F","Return an Excel file from a Flask download endpoint",[161,716,718],{"id":717},"fastapi","FastAPI",[10,720,721,722,725,726,729],{},"FastAPI returns a ",[169,723,724],{},"Response"," with explicit headers, or a ",[169,727,728],{},"StreamingResponse"," when you would rather not hold the whole file in one object twice:",[174,731,733],{"className":176,"code":732,"language":178,"meta":179,"style":179},"import io\n\nfrom fastapi import FastAPI\nfrom fastapi.responses import StreamingResponse\nimport pandas as pd\n\napp = FastAPI()\nXLSX = \"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet\"\n\n@app.get(\"\u002Fexports\u002Fsales.xlsx\")\ndef export_sales():\n    df = pd.DataFrame({\"region\": [\"North\", \"South\"], \"revenue\": [128_400.5, 96_220.0]})\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    return StreamingResponse(\n        buffer,\n        media_type=XLSX,\n        headers={\"Content-Disposition\": 'attachment; filename=\"sales.xlsx\"'},\n    )\n",[169,734,735,741,745,757,769,779,783,792,803,807,817,825,857,865,883,903,911,918,922,933,955],{"__ignoreMap":179},[183,736,737,739],{"class":73,"line":185},[183,738,196],{"class":195},[183,740,200],{"class":199},[183,742,743],{"class":73,"line":192},[183,744,207],{"emptyLinePlaceholder":206},[183,746,747,749,752,754],{"class":73,"line":203},[183,748,477],{"class":195},[183,750,751],{"class":199}," fastapi ",[183,753,196],{"class":195},[183,755,756],{"class":199}," FastAPI\n",[183,758,759,761,764,766],{"class":73,"line":210},[183,760,477],{"class":195},[183,762,763],{"class":199}," fastapi.responses ",[183,765,196],{"class":195},[183,767,768],{"class":199}," StreamingResponse\n",[183,770,771,773,775,777],{"class":73,"line":224},[183,772,196],{"class":195},[183,774,215],{"class":199},[183,776,218],{"class":195},[183,778,221],{"class":199},[183,780,781],{"class":73,"line":229},[183,782,207],{"emptyLinePlaceholder":206},[183,784,785,787,789],{"class":73,"line":249},[183,786,504],{"class":199},[183,788,255],{"class":195},[183,790,791],{"class":199}," FastAPI()\n",[183,793,794,797,800],{"class":73,"line":261},[183,795,796],{"class":242},"XLSX",[183,798,799],{"class":195}," =",[183,801,802],{"class":188}," \"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet\"\n",[183,804,805],{"class":73,"line":287},[183,806,207],{"emptyLinePlaceholder":206},[183,808,809,811,813,815],{"class":73,"line":315},[183,810,523],{"class":235},[183,812,526],{"class":199},[183,814,529],{"class":188},[183,816,312],{"class":199},[183,818,819,821,823],{"class":73,"line":331},[183,820,232],{"class":195},[183,822,538],{"class":235},[183,824,541],{"class":199},[183,826,827,829,831,833,835,837,839,841,843,845,847,849,851,853,855],{"class":73,"line":346},[183,828,546],{"class":199},[183,830,255],{"class":195},[183,832,551],{"class":199},[183,834,554],{"class":188},[183,836,557],{"class":199},[183,838,560],{"class":188},[183,840,301],{"class":199},[183,842,565],{"class":188},[183,844,568],{"class":199},[183,846,571],{"class":188},[183,848,557],{"class":199},[183,850,576],{"class":242},[183,852,301],{"class":199},[183,854,581],{"class":242},[183,856,584],{"class":199},[183,858,859,861,863],{"class":73,"line":352},[183,860,252],{"class":199},[183,862,255],{"class":195},[183,864,258],{"class":199},[183,866,867,869,871,873,875,877,879,881],{"class":73,"line":362},[183,868,264],{"class":195},[183,870,267],{"class":199},[183,872,271],{"class":270},[183,874,255],{"class":195},[183,876,276],{"class":188},[183,878,279],{"class":199},[183,880,218],{"class":195},[183,882,284],{"class":199},[183,884,885,887,889,891,893,895,897,899,901],{"class":73,"line":642},[183,886,290],{"class":199},[183,888,304],{"class":270},[183,890,255],{"class":195},[183,892,309],{"class":242},[183,894,301],{"class":199},[183,896,293],{"class":270},[183,898,255],{"class":195},[183,900,629],{"class":188},[183,902,312],{"class":199},[183,904,905,907,909],{"class":73,"line":650},[183,906,355],{"class":199},[183,908,41],{"class":242},[183,910,312],{"class":199},[183,912,913,915],{"class":73,"line":656},[183,914,365],{"class":195},[183,916,917],{"class":199}," StreamingResponse(\n",[183,919,920],{"class":73,"line":670},[183,921,653],{"class":199},[183,923,924,927,929,931],{"class":73,"line":683},[183,925,926],{"class":270},"        media_type",[183,928,255],{"class":195},[183,930,796],{"class":242},[183,932,667],{"class":199},[183,934,935,938,940,943,946,949,952],{"class":73,"line":696},[183,936,937],{"class":270},"        headers",[183,939,255],{"class":195},[183,941,942],{"class":199},"{",[183,944,945],{"class":188},"\"Content-Disposition\"",[183,947,948],{"class":199},": ",[183,950,951],{"class":188},"'attachment; filename=\"sales.xlsx\"'",[183,953,954],{"class":199},"},\n",[183,956,958],{"class":73,"line":957},21,[183,959,699],{"class":199},[10,961,962,963,966,967,384],{},"Because the endpoint is synchronous, FastAPI runs it in a threadpool and the event loop stays free — which is exactly what you want for a CPU-bound workbook build. Declaring it ",[169,964,965],{},"async def"," and doing the work inline would block every other request on the worker. That trap and the async-safe alternative are covered in ",[14,968,970],{"href":969},"\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",[20,972,29,977,29,980,29,983,29,986,29,992,29,997,29,1002,29,1009,29,1015,29,1017,29,1020,29,1022,29,1027,29,1031,29,1036,29,1038,29,1041,29,1043,29,1046],{"viewBox":973,"role":23,"ariaLabelledBy":974,"xmlns":27,"style":28},"0 0 760 236",[975,976],"web2-t","web2-d",[31,978,979],{"id":975},"Inline generation versus a background job",[35,981,982],{"id":976},"A fast report can be built inside the request, while a slow one should be queued so the request returns immediately and the file is collected once a worker has produced it.",[39,984],{"x":41,"y":41,"width":42,"height":985,"fill":44},"236",[46,987,991],{"x":988,"y":989,"style":990},"196","28","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","under a few seconds",[46,993,996],{"x":994,"y":989,"style":995},"566","font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","longer than that",[73,998],{"x1":48,"y1":999,"x2":48,"y2":1000,"stroke":60,"style":1001},"40","212","stroke-width:1px",[39,1003],{"x":1004,"y":1005,"width":1006,"height":1007,"rx":1008,"fill":133,"stroke":60},"30","48","326","44","9",[46,1010,1014],{"x":1011,"y":1012,"style":1013},"193","76","font-size:12px;fill:var(--teal-ink,#0b6157);text-anchor:middle","request builds the workbook inline",[39,1016],{"x":1004,"y":142,"width":1006,"height":1007,"rx":1008,"fill":133,"stroke":60},[46,1018,1019],{"x":1011,"y":56,"style":1013},"response is the file itself",[39,1021],{"x":1004,"y":75,"width":1006,"height":1007,"rx":1008,"fill":59,"stroke":60},[46,1023,1026],{"x":1011,"y":1024,"style":1025},"180","font-size:12px;fill:var(--text,#172033);text-anchor:middle","simple, no extra infrastructure",[39,1028],{"x":1029,"y":1005,"width":1006,"height":1007,"rx":1008,"fill":1030,"stroke":60},"404","#fdefd8",[46,1032,1035],{"x":1033,"y":1012,"style":1034},"567","font-size:12px;fill:var(--gold-ink,#7a4e06);text-anchor:middle","request enqueues a job, returns an id",[39,1037],{"x":1029,"y":142,"width":1006,"height":1007,"rx":1008,"fill":1030,"stroke":60},[46,1039,1040],{"x":1033,"y":56,"style":1034},"a worker builds and stores the file",[39,1042],{"x":1029,"y":75,"width":1006,"height":1007,"rx":1008,"fill":59,"stroke":60},[46,1044,1045],{"x":1033,"y":1024,"style":1025},"client polls, then downloads a link",[46,1047,1048],{"x":48,"y":130,"style":70},"Proxies and load balancers usually time out well before a slow report finishes",[161,1050,1052],{"id":1051},"django","Django",[10,1054,1055,1056,1059],{},"Django's ",[169,1057,1058],{},"HttpResponse"," takes the bytes and the headers directly, and a queryset converts to a DataFrame in one call:",[174,1061,1063],{"className":176,"code":1062,"language":178,"meta":179,"style":179},"import io\n\nimport pandas as pd\nfrom django.http import HttpResponse\n\nXLSX = \"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet\"\n\ndef export_orders(request):\n    rows = (Order.objects\n            .filter(owner=request.user)               # scope to the caller\n            .values(\"reference\", \"region\", \"total\"))\n    df = pd.DataFrame.from_records(rows)\n\n    buffer = io.BytesIO()\n    with pd.ExcelWriter(buffer, engine=\"xlsxwriter\") as writer:\n        df.to_excel(writer, index=False, sheet_name=\"Orders\")\n    buffer.seek(0)\n\n    response = HttpResponse(buffer.getvalue(), content_type=XLSX)\n    response[\"Content-Disposition\"] = 'attachment; filename=\"orders.xlsx\"'\n    return response\n",[169,1064,1065,1071,1075,1085,1097,1101,1109,1113,1123,1133,1150,1170,1179,1183,1191,1209,1230,1238,1242,1261,1276],{"__ignoreMap":179},[183,1066,1067,1069],{"class":73,"line":185},[183,1068,196],{"class":195},[183,1070,200],{"class":199},[183,1072,1073],{"class":73,"line":192},[183,1074,207],{"emptyLinePlaceholder":206},[183,1076,1077,1079,1081,1083],{"class":73,"line":203},[183,1078,196],{"class":195},[183,1080,215],{"class":199},[183,1082,218],{"class":195},[183,1084,221],{"class":199},[183,1086,1087,1089,1092,1094],{"class":73,"line":210},[183,1088,477],{"class":195},[183,1090,1091],{"class":199}," django.http ",[183,1093,196],{"class":195},[183,1095,1096],{"class":199}," HttpResponse\n",[183,1098,1099],{"class":73,"line":224},[183,1100,207],{"emptyLinePlaceholder":206},[183,1102,1103,1105,1107],{"class":73,"line":229},[183,1104,796],{"class":242},[183,1106,799],{"class":195},[183,1108,802],{"class":188},[183,1110,1111],{"class":73,"line":249},[183,1112,207],{"emptyLinePlaceholder":206},[183,1114,1115,1117,1120],{"class":73,"line":261},[183,1116,232],{"class":195},[183,1118,1119],{"class":235}," export_orders",[183,1121,1122],{"class":199},"(request):\n",[183,1124,1125,1128,1130],{"class":73,"line":287},[183,1126,1127],{"class":199},"    rows ",[183,1129,255],{"class":195},[183,1131,1132],{"class":199}," (Order.objects\n",[183,1134,1135,1138,1141,1143,1146],{"class":73,"line":315},[183,1136,1137],{"class":199},"            .filter(",[183,1139,1140],{"class":270},"owner",[183,1142,255],{"class":195},[183,1144,1145],{"class":199},"request.user)               ",[183,1147,1149],{"class":1148},"s-wDw","# scope to the caller\n",[183,1151,1152,1155,1158,1160,1162,1164,1167],{"class":73,"line":331},[183,1153,1154],{"class":199},"            .values(",[183,1156,1157],{"class":188},"\"reference\"",[183,1159,301],{"class":199},[183,1161,554],{"class":188},[183,1163,301],{"class":199},[183,1165,1166],{"class":188},"\"total\"",[183,1168,1169],{"class":199},"))\n",[183,1171,1172,1174,1176],{"class":73,"line":346},[183,1173,546],{"class":199},[183,1175,255],{"class":195},[183,1177,1178],{"class":199}," pd.DataFrame.from_records(rows)\n",[183,1180,1181],{"class":73,"line":352},[183,1182,207],{"emptyLinePlaceholder":206},[183,1184,1185,1187,1189],{"class":73,"line":362},[183,1186,252],{"class":199},[183,1188,255],{"class":195},[183,1190,258],{"class":199},[183,1192,1193,1195,1197,1199,1201,1203,1205,1207],{"class":73,"line":642},[183,1194,264],{"class":195},[183,1196,267],{"class":199},[183,1198,271],{"class":270},[183,1200,255],{"class":195},[183,1202,276],{"class":188},[183,1204,279],{"class":199},[183,1206,218],{"class":195},[183,1208,284],{"class":199},[183,1210,1211,1213,1215,1217,1219,1221,1223,1225,1228],{"class":73,"line":650},[183,1212,290],{"class":199},[183,1214,304],{"class":270},[183,1216,255],{"class":195},[183,1218,309],{"class":242},[183,1220,301],{"class":199},[183,1222,293],{"class":270},[183,1224,255],{"class":195},[183,1226,1227],{"class":188},"\"Orders\"",[183,1229,312],{"class":199},[183,1231,1232,1234,1236],{"class":73,"line":656},[183,1233,355],{"class":199},[183,1235,41],{"class":242},[183,1237,312],{"class":199},[183,1239,1240],{"class":73,"line":670},[183,1241,207],{"emptyLinePlaceholder":206},[183,1243,1244,1247,1249,1252,1255,1257,1259],{"class":73,"line":683},[183,1245,1246],{"class":199},"    response ",[183,1248,255],{"class":195},[183,1250,1251],{"class":199}," HttpResponse(buffer.getvalue(), ",[183,1253,1254],{"class":270},"content_type",[183,1256,255],{"class":195},[183,1258,796],{"class":242},[183,1260,312],{"class":199},[183,1262,1263,1266,1268,1271,1273],{"class":73,"line":696},[183,1264,1265],{"class":199},"    response[",[183,1267,945],{"class":188},[183,1269,1270],{"class":199},"] ",[183,1272,255],{"class":195},[183,1274,1275],{"class":188}," 'attachment; filename=\"orders.xlsx\"'\n",[183,1277,1278,1280],{"class":73,"line":957},[183,1279,365],{"class":195},[183,1281,1282],{"class":199}," response\n",[10,1284,1285,1288,1289,384],{},[169,1286,1287],{},".values(...)"," returns dictionaries rather than model instances, which avoids materialising objects you only want columns from. The full treatment — related fields, chunked iteration, formatting and permissions — is in ",[14,1290,1292],{"href":1291},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fexport-a-django-queryset-to-excel\u002F","Export a Django queryset to Excel",[161,1294,1296],{"id":1295},"keep-slow-reports-out-of-the-request","Keep slow reports out of the request",[10,1298,1299],{},"Anything past a few seconds does not belong inline. A proxy will time out, a user will double-click, and two identical builds will run at once. Queue it instead:",[174,1301,1303],{"className":176,"code":1302,"language":178,"meta":179,"style":179},"\"\"\"Enqueue, then let the client collect the finished file.\"\"\"\nfrom celery import shared_task\n\n@shared_task\ndef build_export(user_id: int, period: str) -> str:\n    data = fetch_rows(user_id, period)\n    key = f\"exports\u002F{user_id}\u002F{period}.xlsx\"\n    upload_bytes(key, build_workbook(data))     # to S3 or equivalent\n    return key\n",[169,1304,1305,1310,1322,1326,1331,1357,1367,1402,1410],{"__ignoreMap":179},[183,1306,1307],{"class":73,"line":185},[183,1308,1309],{"class":188},"\"\"\"Enqueue, then let the client collect the finished file.\"\"\"\n",[183,1311,1312,1314,1317,1319],{"class":73,"line":192},[183,1313,477],{"class":195},[183,1315,1316],{"class":199}," celery ",[183,1318,196],{"class":195},[183,1320,1321],{"class":199}," shared_task\n",[183,1323,1324],{"class":73,"line":203},[183,1325,207],{"emptyLinePlaceholder":206},[183,1327,1328],{"class":73,"line":210},[183,1329,1330],{"class":235},"@shared_task\n",[183,1332,1333,1335,1338,1341,1344,1347,1350,1353,1355],{"class":73,"line":224},[183,1334,232],{"class":195},[183,1336,1337],{"class":235}," build_export",[183,1339,1340],{"class":199},"(user_id: ",[183,1342,1343],{"class":242},"int",[183,1345,1346],{"class":199},", period: ",[183,1348,1349],{"class":242},"str",[183,1351,1352],{"class":199},") -> ",[183,1354,1349],{"class":242},[183,1356,246],{"class":199},[183,1358,1359,1362,1364],{"class":73,"line":229},[183,1360,1361],{"class":199},"    data ",[183,1363,255],{"class":195},[183,1365,1366],{"class":199}," fetch_rows(user_id, period)\n",[183,1368,1369,1372,1374,1377,1380,1383,1386,1389,1392,1394,1397,1399],{"class":73,"line":249},[183,1370,1371],{"class":199},"    key ",[183,1373,255],{"class":195},[183,1375,1376],{"class":195}," f",[183,1378,1379],{"class":188},"\"exports\u002F",[183,1381,942],{"class":1382},"sSjpA",[183,1384,1385],{"class":199},"user_id",[183,1387,1388],{"class":1382},"}",[183,1390,1391],{"class":188},"\u002F",[183,1393,942],{"class":1382},[183,1395,1396],{"class":199},"period",[183,1398,1388],{"class":1382},[183,1400,1401],{"class":188},".xlsx\"\n",[183,1403,1404,1407],{"class":73,"line":261},[183,1405,1406],{"class":199},"    upload_bytes(key, build_workbook(data))     ",[183,1408,1409],{"class":1148},"# to S3 or equivalent\n",[183,1411,1412,1414],{"class":73,"line":287},[183,1413,365],{"class":195},[183,1415,1416],{"class":199}," key\n",[10,1418,1419,1420,1424,1425,384],{},"The endpoint returns a job id; a status endpoint reports progress; the finished file is served from object storage with a short-lived signed URL. That also gives retries for free — see ",[14,1421,1423],{"href":1422},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fretry-a-failed-excel-report-job-in-python\u002F","Retry a failed Excel report job in Python"," and ",[14,1426,1428],{"href":1427},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fupload-an-excel-report-to-amazon-s3-with-boto3\u002F","Upload an Excel report to Amazon S3 with boto3",[161,1430,1432],{"id":1431},"make-the-export-safe","Make the export safe",[10,1434,1435],{},"An export endpoint is a data-egress endpoint, so it deserves the same scrutiny as any other:",[1437,1438,1439,1447,1453,1459,1478],"ul",{},[1440,1441,1442,1446],"li",{},[1443,1444,1445],"strong",{},"Scope every query to the authenticated user."," Filters may come from the request; the identity must not.",[1440,1448,1449,1452],{},[1443,1450,1451],{},"Never build a path from user input."," Generate filenames; do not echo a parameter into one.",[1440,1454,1455,1458],{},[1443,1456,1457],{},"Cap the row count."," An unbounded export is a denial-of-service vector as well as a memory risk. Refuse politely above a limit and offer the background route.",[1440,1460,1461,1464,1465,301,1467,301,1470,1473,1474,1477],{},[1443,1462,1463],{},"Escape leading formula characters"," in text cells. A value beginning ",[169,1466,255],{},[169,1468,1469],{},"+",[169,1471,1472],{},"-"," or ",[169,1475,1476],{},"@"," becomes a live formula when the file is opened — the spreadsheet injection problem.",[1440,1479,1480,1483],{},[1443,1481,1482],{},"Log who exported what."," An export is a copy of your data leaving the system.",[174,1485,1487],{"className":176,"code":1486,"language":178,"meta":179,"style":179},"def escape_formula(value):\n    \"\"\"Neutralise a cell that Excel would otherwise treat as a formula.\"\"\"\n    if isinstance(value, str) and value[:1] in (\"=\", \"+\", \"-\", \"@\"):\n        return \"'\" + value\n    return value\n",[169,1488,1489,1499,1504,1556,1570],{"__ignoreMap":179},[183,1490,1491,1493,1496],{"class":73,"line":185},[183,1492,232],{"class":195},[183,1494,1495],{"class":235}," escape_formula",[183,1497,1498],{"class":199},"(value):\n",[183,1500,1501],{"class":73,"line":192},[183,1502,1503],{"class":188},"    \"\"\"Neutralise a cell that Excel would otherwise treat as a formula.\"\"\"\n",[183,1505,1506,1509,1512,1515,1517,1519,1522,1525,1527,1529,1532,1535,1538,1540,1543,1545,1548,1550,1553],{"class":73,"line":203},[183,1507,1508],{"class":195},"    if",[183,1510,1511],{"class":242}," isinstance",[183,1513,1514],{"class":199},"(value, ",[183,1516,1349],{"class":242},[183,1518,279],{"class":199},[183,1520,1521],{"class":195},"and",[183,1523,1524],{"class":199}," value[:",[183,1526,337],{"class":242},[183,1528,1270],{"class":199},[183,1530,1531],{"class":195},"in",[183,1533,1534],{"class":199}," (",[183,1536,1537],{"class":188},"\"=\"",[183,1539,301],{"class":199},[183,1541,1542],{"class":188},"\"+\"",[183,1544,301],{"class":199},[183,1546,1547],{"class":188},"\"-\"",[183,1549,301],{"class":199},[183,1551,1552],{"class":188},"\"@\"",[183,1554,1555],{"class":199},"):\n",[183,1557,1558,1561,1564,1567],{"class":73,"line":210},[183,1559,1560],{"class":195},"        return",[183,1562,1563],{"class":188}," \"'\"",[183,1565,1566],{"class":195}," +",[183,1568,1569],{"class":199}," value\n",[183,1571,1572,1574],{"class":73,"line":224},[183,1573,365],{"class":195},[183,1575,1569],{"class":199},[161,1577,1579],{"id":1578},"format-the-output-do-not-just-dump-it","Format the output, do not just dump it",[10,1581,1582,1583,1586],{},"A raw ",[169,1584,1585],{},"to_excel"," dump is a table of unformatted numbers. Since the file is being generated anyway, spend a few lines on making it usable:",[174,1588,1590],{"className":176,"code":1589,"language":178,"meta":179,"style":179},"import io\n\nimport pandas as pd\n\ndef styled_workbook(df: pd.DataFrame, sheet: str = \"Report\") -> bytes:\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        money = book.add_format({\"num_format\": \"#,##0.00\"})\n        header = book.add_format({\"bold\": True, \"bg_color\": \"#DDEBF7\", \"border\": 1})\n        for col, name in enumerate(df.columns):\n            ws.write(0, col, name, header)\n            if pd.api.types.is_numeric_dtype(df[name]):\n                ws.set_column(col, col, 14, money)\n        ws.freeze_panes(1, 0)\n        ws.autofilter(0, 0, len(df), len(df.columns) - 1)\n    buffer.seek(0)\n    return buffer.getvalue()\n",[169,1591,1592,1598,1602,1612,1616,1639,1647,1665,1684,1694,1715,1752,1768,1778,1786,1797,1810,1841,1849],{"__ignoreMap":179},[183,1593,1594,1596],{"class":73,"line":185},[183,1595,196],{"class":195},[183,1597,200],{"class":199},[183,1599,1600],{"class":73,"line":192},[183,1601,207],{"emptyLinePlaceholder":206},[183,1603,1604,1606,1608,1610],{"class":73,"line":203},[183,1605,196],{"class":195},[183,1607,215],{"class":199},[183,1609,218],{"class":195},[183,1611,221],{"class":199},[183,1613,1614],{"class":73,"line":210},[183,1615,207],{"emptyLinePlaceholder":206},[183,1617,1618,1620,1623,1626,1628,1630,1633,1635,1637],{"class":73,"line":224},[183,1619,232],{"class":195},[183,1621,1622],{"class":235}," styled_workbook",[183,1624,1625],{"class":199},"(df: pd.DataFrame, sheet: ",[183,1627,1349],{"class":242},[183,1629,799],{"class":195},[183,1631,1632],{"class":188}," \"Report\"",[183,1634,1352],{"class":199},[183,1636,243],{"class":242},[183,1638,246],{"class":199},[183,1640,1641,1643,1645],{"class":73,"line":229},[183,1642,252],{"class":199},[183,1644,255],{"class":195},[183,1646,258],{"class":199},[183,1648,1649,1651,1653,1655,1657,1659,1661,1663],{"class":73,"line":249},[183,1650,264],{"class":195},[183,1652,267],{"class":199},[183,1654,271],{"class":270},[183,1656,255],{"class":195},[183,1658,276],{"class":188},[183,1660,279],{"class":199},[183,1662,218],{"class":195},[183,1664,284],{"class":199},[183,1666,1667,1669,1671,1673,1675,1677,1679,1681],{"class":73,"line":261},[183,1668,290],{"class":199},[183,1670,304],{"class":270},[183,1672,255],{"class":195},[183,1674,309],{"class":242},[183,1676,301],{"class":199},[183,1678,293],{"class":270},[183,1680,255],{"class":195},[183,1682,1683],{"class":199},"sheet)\n",[183,1685,1686,1689,1691],{"class":73,"line":287},[183,1687,1688],{"class":199},"        book, ws ",[183,1690,255],{"class":195},[183,1692,1693],{"class":199}," writer.book, writer.sheets[sheet]\n",[183,1695,1696,1699,1701,1704,1707,1709,1712],{"class":73,"line":315},[183,1697,1698],{"class":199},"        money ",[183,1700,255],{"class":195},[183,1702,1703],{"class":199}," book.add_format({",[183,1705,1706],{"class":188},"\"num_format\"",[183,1708,948],{"class":199},[183,1710,1711],{"class":188},"\"#,##0.00\"",[183,1713,1714],{"class":199},"})\n",[183,1716,1717,1720,1722,1724,1727,1729,1731,1733,1736,1738,1741,1743,1746,1748,1750],{"class":73,"line":331},[183,1718,1719],{"class":199},"        header ",[183,1721,255],{"class":195},[183,1723,1703],{"class":199},[183,1725,1726],{"class":188},"\"bold\"",[183,1728,948],{"class":199},[183,1730,678],{"class":242},[183,1732,301],{"class":199},[183,1734,1735],{"class":188},"\"bg_color\"",[183,1737,948],{"class":199},[183,1739,1740],{"class":188},"\"#DDEBF7\"",[183,1742,301],{"class":199},[183,1744,1745],{"class":188},"\"border\"",[183,1747,948],{"class":199},[183,1749,337],{"class":242},[183,1751,1714],{"class":199},[183,1753,1754,1757,1760,1762,1765],{"class":73,"line":346},[183,1755,1756],{"class":195},"        for",[183,1758,1759],{"class":199}," col, name ",[183,1761,1531],{"class":195},[183,1763,1764],{"class":242}," enumerate",[183,1766,1767],{"class":199},"(df.columns):\n",[183,1769,1770,1773,1775],{"class":73,"line":352},[183,1771,1772],{"class":199},"            ws.write(",[183,1774,41],{"class":242},[183,1776,1777],{"class":199},", col, name, header)\n",[183,1779,1780,1783],{"class":73,"line":362},[183,1781,1782],{"class":195},"            if",[183,1784,1785],{"class":199}," pd.api.types.is_numeric_dtype(df[name]):\n",[183,1787,1788,1791,1794],{"class":73,"line":642},[183,1789,1790],{"class":199},"                ws.set_column(col, col, ",[183,1792,1793],{"class":242},"14",[183,1795,1796],{"class":199},", money)\n",[183,1798,1799,1802,1804,1806,1808],{"class":73,"line":650},[183,1800,1801],{"class":199},"        ws.freeze_panes(",[183,1803,337],{"class":242},[183,1805,301],{"class":199},[183,1807,41],{"class":242},[183,1809,312],{"class":199},[183,1811,1812,1815,1817,1819,1821,1823,1826,1829,1831,1834,1836,1839],{"class":73,"line":656},[183,1813,1814],{"class":199},"        ws.autofilter(",[183,1816,41],{"class":242},[183,1818,301],{"class":199},[183,1820,41],{"class":242},[183,1822,301],{"class":199},[183,1824,1825],{"class":242},"len",[183,1827,1828],{"class":199},"(df), ",[183,1830,1825],{"class":242},[183,1832,1833],{"class":199},"(df.columns) ",[183,1835,1472],{"class":195},[183,1837,1838],{"class":242}," 1",[183,1840,312],{"class":199},[183,1842,1843,1845,1847],{"class":73,"line":670},[183,1844,355],{"class":199},[183,1846,41],{"class":242},[183,1848,312],{"class":199},[183,1850,1851,1853],{"class":73,"line":683},[183,1852,365],{"class":195},[183,1854,368],{"class":199},[10,1856,1857,1858,384],{},"That is the difference between an export people tolerate and one they rely on. The formatting vocabulary is covered across ",[14,1859,1861],{"href":1860},"\u002Fformatting-and-charting-excel-reports-with-python\u002F","Formatting and Charting Excel Reports with Python",[161,1863,1865],{"id":1864},"test-the-endpoint-like-an-endpoint","Test the endpoint like an endpoint",[10,1867,1868],{},"An export is easy to test because the response body is a real workbook — read it back and assert on it:",[174,1870,1872],{"className":176,"code":1871,"language":178,"meta":179,"style":179},"import io\n\nimport pandas as pd\n\ndef test_export_returns_valid_workbook(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) == 2\n",[169,1873,1874,1880,1884,1894,1898,1908,1922,1936,1954,1970,1974,1992,2014],{"__ignoreMap":179},[183,1875,1876,1878],{"class":73,"line":185},[183,1877,196],{"class":195},[183,1879,200],{"class":199},[183,1881,1882],{"class":73,"line":192},[183,1883,207],{"emptyLinePlaceholder":206},[183,1885,1886,1888,1890,1892],{"class":73,"line":203},[183,1887,196],{"class":195},[183,1889,215],{"class":199},[183,1891,218],{"class":195},[183,1893,221],{"class":199},[183,1895,1896],{"class":73,"line":210},[183,1897,207],{"emptyLinePlaceholder":206},[183,1899,1900,1902,1905],{"class":73,"line":224},[183,1901,232],{"class":195},[183,1903,1904],{"class":235}," test_export_returns_valid_workbook",[183,1906,1907],{"class":199},"(client):\n",[183,1909,1910,1913,1915,1918,1920],{"class":73,"line":229},[183,1911,1912],{"class":199},"    resp ",[183,1914,255],{"class":195},[183,1916,1917],{"class":199}," client.get(",[183,1919,529],{"class":188},[183,1921,312],{"class":199},[183,1923,1924,1927,1930,1933],{"class":73,"line":249},[183,1925,1926],{"class":195},"    assert",[183,1928,1929],{"class":199}," resp.status_code ",[183,1931,1932],{"class":195},"==",[183,1934,1935],{"class":242}," 200\n",[183,1937,1938,1940,1943,1946,1949,1952],{"class":73,"line":261},[183,1939,1926],{"class":195},[183,1941,1942],{"class":199}," resp.headers[",[183,1944,1945],{"class":188},"\"Content-Type\"",[183,1947,1948],{"class":199},"].endswith(",[183,1950,1951],{"class":188},"\"spreadsheetml.sheet\"",[183,1953,312],{"class":199},[183,1955,1956,1958,1961,1964,1966,1968],{"class":73,"line":287},[183,1957,1926],{"class":195},[183,1959,1960],{"class":188}," \"attachment\"",[183,1962,1963],{"class":195}," in",[183,1965,1942],{"class":199},[183,1967,945],{"class":188},[183,1969,328],{"class":199},[183,1971,1972],{"class":73,"line":315},[183,1973,207],{"emptyLinePlaceholder":206},[183,1975,1976,1978,1980,1983,1985,1987,1990],{"class":73,"line":331},[183,1977,546],{"class":199},[183,1979,255],{"class":195},[183,1981,1982],{"class":199}," pd.read_excel(io.BytesIO(resp.data), ",[183,1984,271],{"class":270},[183,1986,255],{"class":195},[183,1988,1989],{"class":188},"\"openpyxl\"",[183,1991,312],{"class":199},[183,1993,1994,1996,1999,2001,2003,2006,2008,2010,2012],{"class":73,"line":346},[183,1995,1926],{"class":195},[183,1997,1998],{"class":242}," list",[183,2000,1833],{"class":199},[183,2002,1932],{"class":195},[183,2004,2005],{"class":199}," [",[183,2007,554],{"class":188},[183,2009,301],{"class":199},[183,2011,571],{"class":188},[183,2013,328],{"class":199},[183,2015,2016,2018,2021,2024,2026],{"class":73,"line":352},[183,2017,1926],{"class":195},[183,2019,2020],{"class":242}," len",[183,2022,2023],{"class":199},"(df) ",[183,2025,1932],{"class":195},[183,2027,2028],{"class":242}," 2\n",[10,2030,2031,2032,384],{},"Round-tripping the response through pandas catches the two failures that matter — a truncated buffer and a wrong sheet layout — without any knowledge of the internals. The wider testing approach is in ",[14,2033,2035],{"href":2034},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Ftest-excel-output-with-pytest\u002F","Test Excel output with pytest",[161,2037,2039],{"id":2038},"watch-memory-under-concurrency","Watch memory under concurrency",[10,2041,2042,2043,2046,2047,384],{},"One export at a time is cheap; twenty concurrent exports of a large report are not. xlsxwriter builds the workbook in memory, so peak usage is roughly the size of the finished file plus the DataFrame it came from — multiplied by the number of simultaneous requests. Three mitigations, in order of preference: cap the rows an inline export may return, move large exports to the background queue, and use xlsxwriter's ",[169,2044,2045],{},"constant_memory"," mode for the genuinely large ones, accepting its restrictions. Sizing that trade-off is covered in ",[14,2048,2050],{"href":2049},"\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",[161,2052,2054],{"id":2053},"name-the-file-so-it-makes-sense-on-a-desktop","Name the file so it makes sense on a desktop",[10,2056,2057,2058,2061,2062,2065],{},"A download called ",[169,2059,2060],{},"export.xlsx"," becomes ",[169,2063,2064],{},"export (3).xlsx"," within a week. Generate a name that carries the report, the scope and the period, and sanitise it so nothing from user data reaches the header:",[174,2067,2069],{"className":176,"code":2068,"language":178,"meta":179,"style":179},"\"\"\"Build a filename that is informative, unique and header-safe.\"\"\"\nimport re\nfrom datetime import date\n\nSAFE = re.compile(r\"[^A-Za-z0-9._-]+\")\n\ndef export_filename(report: str, scope: str, when: date | None = None) -> str:\n    when = when or date.today()\n    stem = SAFE.sub(\"-\", f\"{report}-{scope}-{when:%Y-%m-%d}\").strip(\"-\")\n    return f\"{stem[:80]}.xlsx\"\n\nprint(export_filename(\"sales\", \"EMEA \u002F North\"))   # sales-EMEA-North-2026-08-27.xlsx\n",[169,2070,2071,2076,2083,2095,2099,2133,2137,2173,2189,2262,2284,2288],{"__ignoreMap":179},[183,2072,2073],{"class":73,"line":185},[183,2074,2075],{"class":188},"\"\"\"Build a filename that is informative, unique and header-safe.\"\"\"\n",[183,2077,2078,2080],{"class":73,"line":192},[183,2079,196],{"class":195},[183,2081,2082],{"class":199}," re\n",[183,2084,2085,2087,2090,2092],{"class":73,"line":203},[183,2086,477],{"class":195},[183,2088,2089],{"class":199}," datetime ",[183,2091,196],{"class":195},[183,2093,2094],{"class":199}," date\n",[183,2096,2097],{"class":73,"line":210},[183,2098,207],{"emptyLinePlaceholder":206},[183,2100,2101,2104,2106,2109,2112,2115,2118,2121,2124,2127,2129,2131],{"class":73,"line":224},[183,2102,2103],{"class":242},"SAFE",[183,2105,799],{"class":195},[183,2107,2108],{"class":199}," re.compile(",[183,2110,2111],{"class":195},"r",[183,2113,2114],{"class":188},"\"",[183,2116,2117],{"class":242},"[",[183,2119,2120],{"class":195},"^",[183,2122,2123],{"class":1382},"A-Za-z0-9._-",[183,2125,2126],{"class":242},"]",[183,2128,1469],{"class":195},[183,2130,2114],{"class":188},[183,2132,312],{"class":199},[183,2134,2135],{"class":73,"line":229},[183,2136,207],{"emptyLinePlaceholder":206},[183,2138,2139,2141,2144,2147,2149,2152,2154,2157,2160,2163,2165,2167,2169,2171],{"class":73,"line":249},[183,2140,232],{"class":195},[183,2142,2143],{"class":235}," export_filename",[183,2145,2146],{"class":199},"(report: ",[183,2148,1349],{"class":242},[183,2150,2151],{"class":199},", scope: ",[183,2153,1349],{"class":242},[183,2155,2156],{"class":199},", when: date ",[183,2158,2159],{"class":195},"|",[183,2161,2162],{"class":242}," None",[183,2164,799],{"class":195},[183,2166,2162],{"class":242},[183,2168,1352],{"class":199},[183,2170,1349],{"class":242},[183,2172,246],{"class":199},[183,2174,2175,2178,2180,2183,2186],{"class":73,"line":261},[183,2176,2177],{"class":199},"    when ",[183,2179,255],{"class":195},[183,2181,2182],{"class":199}," when ",[183,2184,2185],{"class":195},"or",[183,2187,2188],{"class":199}," date.today()\n",[183,2190,2191,2194,2196,2199,2202,2204,2206,2209,2211,2213,2216,2218,2220,2222,2225,2227,2229,2231,2234,2237,2240,2243,2246,2248,2251,2253,2255,2258,2260],{"class":73,"line":287},[183,2192,2193],{"class":199},"    stem ",[183,2195,255],{"class":195},[183,2197,2198],{"class":242}," SAFE",[183,2200,2201],{"class":199},".sub(",[183,2203,1547],{"class":188},[183,2205,301],{"class":199},[183,2207,2208],{"class":195},"f",[183,2210,2114],{"class":188},[183,2212,942],{"class":1382},[183,2214,2215],{"class":199},"report",[183,2217,1388],{"class":1382},[183,2219,1472],{"class":188},[183,2221,942],{"class":1382},[183,2223,2224],{"class":199},"scope",[183,2226,1388],{"class":1382},[183,2228,1472],{"class":188},[183,2230,942],{"class":1382},[183,2232,2233],{"class":199},"when:",[183,2235,2236],{"class":195},"%",[183,2238,2239],{"class":199},"Y",[183,2241,2242],{"class":195},"-%",[183,2244,2245],{"class":199},"m",[183,2247,2242],{"class":195},[183,2249,2250],{"class":199},"d",[183,2252,1388],{"class":1382},[183,2254,2114],{"class":188},[183,2256,2257],{"class":199},").strip(",[183,2259,1547],{"class":188},[183,2261,312],{"class":199},[183,2263,2264,2266,2268,2270,2272,2275,2278,2280,2282],{"class":73,"line":315},[183,2265,365],{"class":195},[183,2267,1376],{"class":195},[183,2269,2114],{"class":188},[183,2271,942],{"class":1382},[183,2273,2274],{"class":199},"stem[:",[183,2276,2277],{"class":242},"80",[183,2279,2126],{"class":199},[183,2281,1388],{"class":1382},[183,2283,1401],{"class":188},[183,2285,2286],{"class":73,"line":331},[183,2287,207],{"emptyLinePlaceholder":206},[183,2289,2290,2293,2296,2299,2301,2304,2307],{"class":73,"line":346},[183,2291,2292],{"class":242},"print",[183,2294,2295],{"class":199},"(export_filename(",[183,2297,2298],{"class":188},"\"sales\"",[183,2300,301],{"class":199},[183,2302,2303],{"class":188},"\"EMEA \u002F North\"",[183,2305,2306],{"class":199},"))   ",[183,2308,2309],{"class":1148},"# sales-EMEA-North-2026-08-27.xlsx\n",[10,2311,2312,2313,2315],{},"Stripping everything outside a small character set matters for more than tidiness: a newline or a quote in a ",[169,2314,430],{}," header is a response-splitting bug, and a scope name coming from data is exactly where one would arrive. For non-ASCII names, add the RFC 5987 form alongside the plain one:",[174,2317,2319],{"className":176,"code":2318,"language":178,"meta":179,"style":179},"from urllib.parse import quote\n\nname = \"regional-résumé.xlsx\"\ndisposition = f\"attachment; filename=\\\"report.xlsx\\\"; filename*=UTF-8''{quote(name)}\"\n",[169,2320,2321,2333,2337,2347],{"__ignoreMap":179},[183,2322,2323,2325,2328,2330],{"class":73,"line":185},[183,2324,477],{"class":195},[183,2326,2327],{"class":199}," urllib.parse ",[183,2329,196],{"class":195},[183,2331,2332],{"class":199}," quote\n",[183,2334,2335],{"class":73,"line":192},[183,2336,207],{"emptyLinePlaceholder":206},[183,2338,2339,2342,2344],{"class":73,"line":203},[183,2340,2341],{"class":199},"name ",[183,2343,255],{"class":195},[183,2345,2346],{"class":188}," \"regional-résumé.xlsx\"\n",[183,2348,2349,2352,2354,2356,2359,2362,2365,2367,2370,2372,2375,2377],{"class":73,"line":210},[183,2350,2351],{"class":199},"disposition ",[183,2353,255],{"class":195},[183,2355,1376],{"class":195},[183,2357,2358],{"class":188},"\"attachment; filename=",[183,2360,2361],{"class":1382},"\\\"",[183,2363,2364],{"class":188},"report.xlsx",[183,2366,2361],{"class":1382},[183,2368,2369],{"class":188},"; filename*=UTF-8''",[183,2371,942],{"class":1382},[183,2373,2374],{"class":199},"quote(name)",[183,2376,1388],{"class":1382},[183,2378,2379],{"class":188},"\"\n",[161,2381,2383],{"id":2382},"offer-csv-where-excel-is-not-really-needed","Offer CSV where Excel is not really needed",[10,2385,2386],{},"Not every export needs a workbook. If the recipient is loading the file into another system rather than reading it, CSV is faster to generate, streams incrementally, and has no memory ceiling:",[393,2388,2389,2401],{},[396,2390,2391],{},[399,2392,2393,2395,2398],{},[402,2394],{},[402,2396,2397],{},"Excel export",[402,2399,2400],{},"CSV export",[409,2402,2403,2414,2424,2435,2445],{},[399,2404,2405,2408,2411],{},[414,2406,2407],{},"Formatting, multiple sheets, formulas",[414,2409,2410],{},"Yes",[414,2412,2413],{},"No",[399,2415,2416,2419,2422],{},[414,2417,2418],{},"True streaming, row by row",[414,2420,2421],{},"No — the zip finalises at close",[414,2423,2410],{},[399,2425,2426,2429,2432],{},[414,2427,2428],{},"Memory for a million rows",[414,2430,2431],{},"High",[414,2433,2434],{},"Flat",[399,2436,2437,2440,2442],{},[414,2438,2439],{},"Opens cleanly in Excel",[414,2441,2410],{},[414,2443,2444],{},"Mostly, with encoding caveats",[399,2446,2447,2450,2453],{},[414,2448,2449],{},"Right for",[414,2451,2452],{},"A report a person reads",[414,2454,2455],{},"Data a system ingests",[10,2457,2458],{},"Offering both is a few lines, and it moves the largest exports off the expensive path:",[174,2460,2462],{"className":176,"code":2461,"language":178,"meta":179,"style":179},"@app.get(\"\u002Fexports\u002Fsales.csv\")\ndef export_sales_csv():\n    def rows():\n        yield \"region,revenue\\n\"\n        for region, revenue in fetch_rows():\n            yield f\"{region},{revenue}\\n\"\n    return Response(rows(), mimetype=\"text\u002Fcsv\",\n                    headers={\"Content-Disposition\": 'attachment; filename=\"sales.csv\"'})\n",[169,2463,2464,2475,2484,2494,2507,2519,2548,2565],{"__ignoreMap":179},[183,2465,2466,2468,2470,2473],{"class":73,"line":185},[183,2467,523],{"class":235},[183,2469,526],{"class":199},[183,2471,2472],{"class":188},"\"\u002Fexports\u002Fsales.csv\"",[183,2474,312],{"class":199},[183,2476,2477,2479,2482],{"class":73,"line":192},[183,2478,232],{"class":195},[183,2480,2481],{"class":235}," export_sales_csv",[183,2483,541],{"class":199},[183,2485,2486,2489,2492],{"class":73,"line":203},[183,2487,2488],{"class":195},"    def",[183,2490,2491],{"class":235}," rows",[183,2493,541],{"class":199},[183,2495,2496,2499,2502,2505],{"class":73,"line":210},[183,2497,2498],{"class":195},"        yield",[183,2500,2501],{"class":188}," \"region,revenue",[183,2503,2504],{"class":1382},"\\n",[183,2506,2379],{"class":188},[183,2508,2509,2511,2514,2516],{"class":73,"line":224},[183,2510,1756],{"class":195},[183,2512,2513],{"class":199}," region, revenue ",[183,2515,1531],{"class":195},[183,2517,2518],{"class":199}," fetch_rows():\n",[183,2520,2521,2524,2526,2528,2530,2533,2535,2538,2540,2543,2546],{"class":73,"line":229},[183,2522,2523],{"class":195},"            yield",[183,2525,1376],{"class":195},[183,2527,2114],{"class":188},[183,2529,942],{"class":1382},[183,2531,2532],{"class":199},"region",[183,2534,1388],{"class":1382},[183,2536,2537],{"class":188},",",[183,2539,942],{"class":1382},[183,2541,2542],{"class":199},"revenue",[183,2544,2545],{"class":1382},"}\\n",[183,2547,2379],{"class":188},[183,2549,2550,2552,2555,2558,2560,2563],{"class":73,"line":249},[183,2551,365],{"class":195},[183,2553,2554],{"class":199}," Response(rows(), ",[183,2556,2557],{"class":270},"mimetype",[183,2559,255],{"class":195},[183,2561,2562],{"class":188},"\"text\u002Fcsv\"",[183,2564,667],{"class":199},[183,2566,2567,2570,2572,2574,2576,2578,2581],{"class":73,"line":261},[183,2568,2569],{"class":270},"                    headers",[183,2571,255],{"class":195},[183,2573,942],{"class":199},[183,2575,945],{"class":188},[183,2577,948],{"class":199},[183,2579,2580],{"class":188},"'attachment; filename=\"sales.csv\"'",[183,2582,1714],{"class":199},[10,2584,2585,2586,2589,2590,384],{},"Note the encoding caveat: Excel opens a UTF-8 CSV correctly only when it begins with a byte-order mark on some versions, so write ",[169,2587,2588],{},"utf-8-sig"," if the file is destined for a double-click rather than a parser. The conversion trade-offs are covered in ",[14,2591,2593],{"href":2592},"\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",[161,2595,2597],{"id":2596},"cache-a-report-that-many-people-request","Cache a report that many people request",[10,2599,2600],{},"Reports are rarely per-user in content even when they are per-user in access. If ten managers export the same monthly summary within an hour, generating it ten times is wasted work — and each generation is the expensive part of the request:",[174,2602,2604],{"className":176,"code":2603,"language":178,"meta":179,"style":179},"\"\"\"Serve a cached workbook when one was built recently enough.\"\"\"\nimport hashlib\nimport time\n\nCACHE: dict[str, tuple[float, bytes]] = {}\nTTL_SECONDS = 900\n\ndef cached_workbook(key_parts: tuple[str, ...], build) -> bytes:\n    key = hashlib.sha256(\"|\".join(key_parts).encode()).hexdigest()\n    hit = CACHE.get(key)\n    if hit and time.time() - hit[0] \u003C TTL_SECONDS:\n        return hit[1]\n    data = build()\n    CACHE[key] = (time.time(), data)\n    return data\n",[169,2605,2606,2611,2618,2625,2629,2657,2667,2671,2695,2710,2723,2752,2762,2771,2784],{"__ignoreMap":179},[183,2607,2608],{"class":73,"line":185},[183,2609,2610],{"class":188},"\"\"\"Serve a cached workbook when one was built recently enough.\"\"\"\n",[183,2612,2613,2615],{"class":73,"line":192},[183,2614,196],{"class":195},[183,2616,2617],{"class":199}," hashlib\n",[183,2619,2620,2622],{"class":73,"line":203},[183,2621,196],{"class":195},[183,2623,2624],{"class":199}," time\n",[183,2626,2627],{"class":73,"line":210},[183,2628,207],{"emptyLinePlaceholder":206},[183,2630,2631,2634,2637,2639,2642,2645,2647,2649,2652,2654],{"class":73,"line":224},[183,2632,2633],{"class":242},"CACHE",[183,2635,2636],{"class":199},": dict[",[183,2638,1349],{"class":242},[183,2640,2641],{"class":199},", tuple[",[183,2643,2644],{"class":242},"float",[183,2646,301],{"class":199},[183,2648,243],{"class":242},[183,2650,2651],{"class":199},"]] ",[183,2653,255],{"class":195},[183,2655,2656],{"class":199}," {}\n",[183,2658,2659,2662,2664],{"class":73,"line":229},[183,2660,2661],{"class":242},"TTL_SECONDS",[183,2663,799],{"class":195},[183,2665,2666],{"class":242}," 900\n",[183,2668,2669],{"class":73,"line":249},[183,2670,207],{"emptyLinePlaceholder":206},[183,2672,2673,2675,2678,2681,2683,2685,2688,2691,2693],{"class":73,"line":261},[183,2674,232],{"class":195},[183,2676,2677],{"class":235}," cached_workbook",[183,2679,2680],{"class":199},"(key_parts: tuple[",[183,2682,1349],{"class":242},[183,2684,301],{"class":199},[183,2686,2687],{"class":242},"...",[183,2689,2690],{"class":199},"], build) -> ",[183,2692,243],{"class":242},[183,2694,246],{"class":199},[183,2696,2697,2699,2701,2704,2707],{"class":73,"line":287},[183,2698,1371],{"class":199},[183,2700,255],{"class":195},[183,2702,2703],{"class":199}," hashlib.sha256(",[183,2705,2706],{"class":188},"\"|\"",[183,2708,2709],{"class":199},".join(key_parts).encode()).hexdigest()\n",[183,2711,2712,2715,2717,2720],{"class":73,"line":315},[183,2713,2714],{"class":199},"    hit ",[183,2716,255],{"class":195},[183,2718,2719],{"class":242}," CACHE",[183,2721,2722],{"class":199},".get(key)\n",[183,2724,2725,2727,2730,2732,2735,2737,2740,2742,2744,2747,2750],{"class":73,"line":331},[183,2726,1508],{"class":195},[183,2728,2729],{"class":199}," hit ",[183,2731,1521],{"class":195},[183,2733,2734],{"class":199}," time.time() ",[183,2736,1472],{"class":195},[183,2738,2739],{"class":199}," hit[",[183,2741,41],{"class":242},[183,2743,1270],{"class":199},[183,2745,2746],{"class":195},"\u003C",[183,2748,2749],{"class":242}," TTL_SECONDS",[183,2751,246],{"class":199},[183,2753,2754,2756,2758,2760],{"class":73,"line":346},[183,2755,1560],{"class":195},[183,2757,2739],{"class":199},[183,2759,337],{"class":242},[183,2761,328],{"class":199},[183,2763,2764,2766,2768],{"class":73,"line":352},[183,2765,1361],{"class":199},[183,2767,255],{"class":195},[183,2769,2770],{"class":199}," build()\n",[183,2772,2773,2776,2779,2781],{"class":73,"line":362},[183,2774,2775],{"class":242},"    CACHE",[183,2777,2778],{"class":199},"[key] ",[183,2780,255],{"class":195},[183,2782,2783],{"class":199}," (time.time(), data)\n",[183,2785,2786,2788],{"class":73,"line":642},[183,2787,365],{"class":195},[183,2789,2790],{"class":199}," data\n",[20,2792,29,2797,29,2800,29,2803,29,2806,29,2809,29,2813,29,2818,29,2821,29,2825,29,2827,29,2831,29,2835,29,2838,29,2841,29,2844,29,2847,29,2850,29,2854,29,2860,29,2864,29,2869,29,2873,29,2876,29,2879,29,2883,29,2888,29,2890,29,2895,29,2900,29,2903,29,2905,29,2908,29,2912],{"viewBox":2793,"role":23,"ariaLabelledBy":2794,"xmlns":27,"style":28},"0 0 760 202",[2795,2796],"web3-t","web3-d",[31,2798,2799],{"id":2795},"A short-lived cache in front of the build step",[35,2801,2802],{"id":2796},"Requests that share the same report, scope and period reuse a workbook built within the cache window, so only the first request pays the generation cost.",[39,2804],{"x":41,"y":41,"width":42,"height":2805,"fill":44},"202",[46,2807,2808],{"x":48,"y":49,"style":50},"Same report, same period, one build",[39,2810],{"x":989,"y":1007,"width":2811,"height":999,"rx":2812,"fill":59,"stroke":60},"140","8",[46,2814,2817],{"x":2815,"y":2816,"style":1025},"98","69","request A",[39,2819],{"x":989,"y":2820,"width":2811,"height":999,"rx":2812,"fill":59,"stroke":60},"92",[46,2822,2824],{"x":2815,"y":2823,"style":1025},"117","request B",[39,2826],{"x":989,"y":2811,"width":2811,"height":999,"rx":2812,"fill":59,"stroke":60},[46,2828,2830],{"x":2815,"y":2829,"style":1025},"165","request C",[73,2832],{"x1":2833,"y1":2834,"x2":1000,"y2":2815,"stroke":78,"style":79},"168","64",[81,2836],{"points":2837,"fill":84},"212,98 200,95 205,87",[73,2839],{"x1":2833,"y1":2840,"x2":1000,"y2":2840,"stroke":78,"style":79},"112",[81,2842],{"points":2843,"fill":84},"212,112 202,107 202,117",[73,2845],{"x1":2833,"y1":2846,"x2":1000,"y2":147,"stroke":78,"style":79},"160",[81,2848],{"points":2849,"fill":84},"212,126 205,137 200,129",[39,2851],{"x":2852,"y":55,"width":2853,"height":131,"rx":132,"fill":108,"stroke":60},"216","170",[46,2855,2859],{"x":2856,"y":2857,"style":2858},"301","108","font-size:12.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","cache key",[46,2861,2863],{"x":2856,"y":147,"style":2862},"font-size:11px;fill:var(--muted,#5b6780);text-anchor:middle","report + scope + period",[73,2865],{"x1":2866,"y1":142,"x2":2867,"y2":1012,"stroke":2868,"style":79},"386","430","var(--gold,#b4740a)",[81,2870],{"points":2871,"fill":2872},"430,76 423,87 418,79","#7a4e06",[73,2874],{"x1":2866,"y1":2875,"x2":2867,"y2":150,"stroke":121,"style":79},"124",[81,2877],{"points":2878,"fill":125},"430,148 418,145 423,137",[39,2880],{"x":2881,"y":1005,"width":2882,"height":131,"rx":132,"fill":1030,"stroke":60},"434","150",[46,2884,2887],{"x":2885,"y":2277,"style":2886},"509","font-size:12px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","miss: build once",[39,2889],{"x":2881,"y":2875,"width":2882,"height":131,"rx":132,"fill":133,"stroke":60},[46,2891,2894],{"x":2885,"y":2892,"style":2893},"156","font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","hit: reuse bytes",[73,2896],{"x1":2897,"y1":2898,"x2":2899,"y2":2815,"stroke":2868,"style":79},"584","74","628",[81,2901],{"points":2902,"fill":2872},"628,98 616,95 621,87",[73,2904],{"x1":2897,"y1":2882,"x2":2899,"y2":147,"stroke":121,"style":79},[81,2906],{"points":2907,"fill":125},"628,126 621,137 616,129",[39,2909],{"x":2910,"y":55,"width":2911,"height":131,"rx":132,"fill":59,"stroke":60},"632","104",[46,2913,2916],{"x":2914,"y":2915,"style":65},"684","118","download",[10,2918,2919],{},"Keep the cache key honest: it must include everything that changes the contents, including the permission scope. A cache keyed only on the report name will happily serve one team's numbers to another, which is the same bug as a missing authorisation check with a longer fuse. In a multi-process deployment, put the cache in Redis or object storage rather than a module-level dictionary, since each worker otherwise keeps its own copy.",[161,2921,2923],{"id":2922},"handle-the-failures-a-user-will-actually-hit","Handle the failures a user will actually hit",[10,2925,2926],{},"Three things go wrong often enough to design for, and all three are worse when the response is a binary download — a browser that has already started saving cannot show an error page.",[10,2928,2929,2932],{},[1443,2930,2931],{},"An empty result."," A filter that matches nothing should still produce a workbook, with a header row and a note, rather than a zero-row file that looks broken:",[174,2934,2936],{"className":176,"code":2935,"language":178,"meta":179,"style":179},"if df.empty:\n    df = pd.DataFrame({\"message\": [\"No rows matched the selected filters.\"]})\n",[169,2937,2938,2946],{"__ignoreMap":179},[183,2939,2940,2943],{"class":73,"line":185},[183,2941,2942],{"class":195},"if",[183,2944,2945],{"class":199}," df.empty:\n",[183,2947,2948,2950,2952,2954,2957,2959,2962],{"class":73,"line":192},[183,2949,546],{"class":199},[183,2951,255],{"class":195},[183,2953,551],{"class":199},[183,2955,2956],{"class":188},"\"message\"",[183,2958,557],{"class":199},[183,2960,2961],{"class":188},"\"No rows matched the selected filters.\"",[183,2963,584],{"class":199},[10,2965,2966,2969],{},[1443,2967,2968],{},"A generation error."," Build the bytes fully before starting the response. If the build raises, you can still return a normal error page; if you have already begun streaming, you cannot:",[174,2971,2973],{"className":176,"code":2972,"language":178,"meta":179,"style":179},"try:\n    payload = build_workbook(df)\nexcept Exception:\n    app.logger.exception(\"export failed for user %s\", user.id)\n    abort(500)\nreturn send_file(io.BytesIO(payload), ...)\n",[169,2974,2975,2982,2992,3002,3018,3028],{"__ignoreMap":179},[183,2976,2977,2980],{"class":73,"line":185},[183,2978,2979],{"class":195},"try",[183,2981,246],{"class":199},[183,2983,2984,2987,2989],{"class":73,"line":192},[183,2985,2986],{"class":199},"    payload ",[183,2988,255],{"class":195},[183,2990,2991],{"class":199}," build_workbook(df)\n",[183,2993,2994,2997,3000],{"class":73,"line":203},[183,2995,2996],{"class":195},"except",[183,2998,2999],{"class":242}," Exception",[183,3001,246],{"class":199},[183,3003,3004,3007,3010,3013,3015],{"class":73,"line":210},[183,3005,3006],{"class":199},"    app.logger.exception(",[183,3008,3009],{"class":188},"\"export failed for user ",[183,3011,3012],{"class":1382},"%s",[183,3014,2114],{"class":188},[183,3016,3017],{"class":199},", user.id)\n",[183,3019,3020,3023,3026],{"class":73,"line":224},[183,3021,3022],{"class":199},"    abort(",[183,3024,3025],{"class":242},"500",[183,3027,312],{"class":199},[183,3029,3030,3033,3036,3038],{"class":73,"line":229},[183,3031,3032],{"class":195},"return",[183,3034,3035],{"class":199}," send_file(io.BytesIO(payload), ",[183,3037,2687],{"class":242},[183,3039,312],{"class":199},[10,3041,3042,3045],{},[1443,3043,3044],{},"A double-click."," Users click export twice when nothing appears to happen. An idempotency key — or simply the cache above — turns the second click into a cheap repeat rather than a second full build. Disabling the button client-side while the request is in flight solves the visible half of the problem.",[10,3047,3048,3049,384],{},"Logging is what makes these diagnosable after the fact: record the user, the filters, the row count and the elapsed time on every export. The pattern is the same one used for scheduled jobs in ",[14,3050,3052],{"href":3051},"\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",[161,3054,3056],{"id":3055},"deploy-behind-a-proxy-that-knows-what-to-expect","Deploy behind a proxy that knows what to expect",[10,3058,3059,3060,3063],{},"Two settings on the way out are easy to miss. Response buffering in nginx will hold a large download in memory or spool it to disk before forwarding it, which adds latency to every export; ",[169,3061,3062],{},"proxy_buffering off"," on the export location avoids that. And the proxy's read timeout must exceed the slowest inline export you allow, or users see a gateway error on exactly the reports that took the longest to produce — which is the strongest argument for the background-job route.",[10,3065,3066,3067,3070,3071,3074],{},"Compression is the other consideration: an ",[169,3068,3069],{},".xlsx"," is already a zip, so gzip at the proxy adds CPU for essentially no saving. Exclude the spreadsheet content type from ",[169,3072,3073],{},"gzip_types"," and let the file through as it is.",[161,3076,3078],{"id":3077},"key-takeaways","Key takeaways",[1437,3080,3081,3087,3093,3099,3102,3105],{},[1440,3082,3083,3084,3086],{},"Build workbooks in ",[169,3085,171],{},"; a temporary file adds cleanup, a race between concurrent requests, and nothing else.",[1440,3088,3089,3090,3092],{},"Close the writer before reading the buffer, and ",[169,3091,378],{}," before returning it.",[1440,3094,3095,3096,384],{},"Two headers define the download: the spreadsheet content type and ",[169,3097,3098],{},"Content-Disposition: attachment",[1440,3100,3101],{},"Keep generation synchronous only while it is fast; queue anything that takes longer than a few seconds.",[1440,3103,3104],{},"Scope every export query to the authenticated user, cap the row count, and escape leading formula characters.",[1440,3106,3107],{},"Test the endpoint by reading its response back with pandas — it catches truncation and layout errors in one assertion.",[161,3109,3111],{"id":3110},"frequently-asked-questions","Frequently asked questions",[10,3113,3114,3117,3118,3120],{},[1443,3115,3116],{},"Do I need to write the file to disk first?","\nNo, and you should not. Build the workbook in an ",[169,3119,171],{}," buffer and return the bytes. Nothing touches the filesystem, so concurrent requests cannot collide over a filename and there is nothing to clean up.",[10,3122,3123,3126,3128,3129,3131,3132,3135],{},[1443,3124,3125],{},"What content type should an .xlsx download use?",[169,3127,423],{},". Pair it with a ",[169,3130,430],{}," header of ",[169,3133,3134],{},"attachment; filename=\"report.xlsx\""," so browsers save rather than try to display it.",[10,3137,3138,3141],{},[1443,3139,3140],{},"How do I handle a report that takes two minutes to build?","\nDo not build it in the request. Queue a background job, return a job id immediately, and let the client poll or receive a link when the file is ready. Anything past a few seconds risks a proxy timeout.",[10,3143,3144,3147,3148,3150],{},[1443,3145,3146],{},"Can I stream an Excel file the way I can stream CSV?","\nNot incrementally in the same sense — an ",[169,3149,3069],{}," is a zip, finalised only when the workbook closes. You can stream the finished bytes in chunks, which helps memory on large files, but the workbook must be complete first.",[10,3152,3153,3156],{},[1443,3154,3155],{},"How do I stop one user downloading another user's report?","\nDerive every filter from the authenticated session rather than from query parameters, and never build a file path from user input. Filenames in the response should be generated, not echoed back.",[161,3158,3160],{"id":3159},"conclusion","Conclusion",[10,3162,3163],{},"On-demand Excel is the same pipeline as a scheduled report, compressed into a request: query, build, deliver. Keep the build in memory, set the two headers that make it a download, format the output enough to be useful, and move anything slow to a queue before a proxy makes the decision for you. The guides below work each framework through in full, including the security and testing details that turn a working endpoint into one you can leave running.",[161,3165,3167],{"id":3166},"related","Related",[1437,3169,3170,3179,3184,3189,3194,3199,3206],{},[1440,3171,3172,3175,3176,3178],{},[1443,3173,3174],{},"Up:"," ",[14,3177,17],{"href":16}," — the scheduled counterpart to this on-demand delivery.",[1440,3180,3181,3183],{},[14,3182,714],{"href":713}," — the smallest complete implementation.",[1440,3185,3186,3188],{},[14,3187,970],{"href":969}," — async-safe generation and streaming responses.",[1440,3190,3191,3193],{},[14,3192,383],{"href":382}," — the buffer pattern every framework shares.",[1440,3195,3196,3198],{},[14,3197,1292],{"href":1291}," — queryset to workbook, including related fields and permissions.",[1440,3200,3201,3205],{},[14,3202,3204],{"href":3203},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fhandle-an-uploaded-excel-file-in-flask-and-fastapi\u002F","Handle an Uploaded Excel File in Flask and FastAPI"," — the other direction: receiving a spreadsheet safely and validating it.",[1440,3207,3208,3175,3211,1424,3215,3219],{},[1443,3209,3210],{},"Sibling topics:",[14,3212,3214],{"href":3213},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002F","Emailing Excel Reports with smtplib",[14,3216,3218],{"href":3217},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002F","Publishing Excel Reports to Cloud Storage"," — the other two ways a finished workbook reaches a person.",[3221,3222,3223],"style",{},"html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html .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-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":179,"searchDepth":192,"depth":192,"links":3225},[3226,3227,3228,3229,3230,3231,3232,3233,3234,3235,3236,3237,3238,3239,3240,3241,3242,3243,3244],{"id":163,"depth":192,"text":164},{"id":387,"depth":192,"text":388},{"id":449,"depth":192,"text":450},{"id":717,"depth":192,"text":718},{"id":1051,"depth":192,"text":1052},{"id":1295,"depth":192,"text":1296},{"id":1431,"depth":192,"text":1432},{"id":1578,"depth":192,"text":1579},{"id":1864,"depth":192,"text":1865},{"id":2038,"depth":192,"text":2039},{"id":2053,"depth":192,"text":2054},{"id":2382,"depth":192,"text":2383},{"id":2596,"depth":192,"text":2597},{"id":2922,"depth":192,"text":2923},{"id":3055,"depth":192,"text":3056},{"id":3077,"depth":192,"text":3078},{"id":3110,"depth":192,"text":3111},{"id":3159,"depth":192,"text":3160},{"id":3166,"depth":192,"text":3167},"2026-09-05","2026-08-27","Generate and return .xlsx from Flask, FastAPI and Django: in-memory workbooks, correct headers, streaming, background jobs for slow reports, and safe filenames.","md",[3250,3252,3254,3256,3258],{"q":3116,"a":3251},"No, and you should not. Build the workbook in an io.BytesIO buffer and return the bytes. Nothing touches the filesystem, so concurrent requests cannot collide over a filename and there is nothing to clean up.",{"q":3125,"a":3253},"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet. Pair it with a Content-Disposition header of attachment; filename=\"report.xlsx\" so browsers save rather than try to display it.",{"q":3140,"a":3255},"Do not build it in the request. Queue a background job, return a job id immediately, and let the client poll or receive a link when the file is ready. Anything past a few seconds risks a proxy timeout.",{"q":3146,"a":3257},"Not incrementally in the same sense — an xlsx is a zip, finalised only when the workbook closes. You can stream the finished bytes in chunks, which helps memory on large files, but the workbook must be complete first.",{"q":3155,"a":3259},"Derive every filter from the authenticated session rather than from query parameters, and never build a file path from user input. Filenames in the response should be generated, not echoed back.",{},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps",{"title":3263,"description":3264},"Serve Excel Downloads from Python Apps","Build Excel downloads into a web app: BytesIO workbooks, the right content type and Content-Disposition, streaming responses, background generation, and security checks.","serving-excel-files-from-python-web-apps","automating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Findex","guide","_D8cl1jszLaLNN3R6SfByqp_IuZ_eTD73P_PZrWvVS8",[3270,3274],{"title":3271,"path":3272,"stem":3273,"children":-1},"Schedule Recurring Excel Reports with APScheduler","\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-recurring-excel-reports-with-apscheduler","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-recurring-excel-reports-with-apscheduler\u002Findex",{"title":3275,"path":3276,"stem":3277,"children":-1},"Build an Excel Workbook in Memory with BytesIO","\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-an-excel-workbook-in-memory-with-bytesio","automating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-an-excel-workbook-in-memory-with-bytesio\u002Findex",1788710151645]