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