[{"data":1,"prerenderedAt":2614},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fhandle-an-uploaded-excel-file-in-flask-and-fastapi":3,"surround:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fhandle-an-uploaded-excel-file-in-flask-and-fastapi":2606},{"id":4,"title":5,"body":6,"dateModified":2579,"datePublished":2579,"description":2580,"extension":2581,"faq":2582,"meta":2591,"navigation":243,"path":2599,"seo":2600,"slug":2602,"stem":2603,"type":2604,"__hash__":2605},"docs\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fhandle-an-uploaded-excel-file-in-flask-and-fastapi\u002Findex.md","Handle an Uploaded Excel File in Flask and FastAPI",{"type":7,"value":8,"toc":2563},"minimark",[9,19,137,142,183,189,193,681,691,695,989,996,1000,1104,1107,1429,1432,1436,1547,1555,1559,1562,1754,1765,1769,1772,1930,1936,1939,1943,1946,1953,2000,2007,2011,2114,2118,2121,2247,2258,2262,2346,2349,2352,2472,2479,2483,2490,2494,2501,2507,2513,2519,2523,2559],[10,11,12,13,18],"p",{},"Letting people upload a spreadsheet is the other half of serving one, and it is the half with the\nsecurity and validation problems. The file arrives as an untrusted stream with a user-supplied name,\nand everything downstream depends on it being what it claims. This guide, part of\n",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002F","Serving Excel Files from Python Web Apps",",\ncovers receiving the upload in Flask and FastAPI, verifying it, and returning errors somebody can act\non.",[20,21,29,30,29,34,29,38,29,45,29,54,29,60,29,66,29,72,29,77,29,80,29,83,29,87,29,91,29,95,29,98,29,101,29,105,29,109,29,113,29,116,29,119,29,123,29,127,29,131],"svg",{"viewBox":22,"role":23,"ariaLabelledBy":24,"xmlns":27,"style":28},"0 0 760 308","img",[25,26],"up-gate-t","up-gate-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},"Four checks before an upload is trusted",[35,36,37],"desc",{"id":26},"Reject an oversized body at the server, check the extension, confirm the leading bytes are a zip signature, and only then parse and validate the columns the application needs.",[39,40],"rect",{"x":41,"y":41,"width":42,"height":43,"fill":44},"0","760","308","#ffffff",[39,46],{"x":47,"y":48,"width":49,"height":50,"rx":51,"fill":52,"stroke":53},"22","28","716","52","10","#d9f4f1","var(--teal,#0f9488)",[55,56],"circle",{"cx":50,"cy":57,"r":58,"fill":59},"54.0","15","#5b5cf0",[61,62,65],"text",{"x":50,"y":63,"style":64},"59.0","font-size:13px;font-weight:700;fill:#ffffff;text-anchor:middle","1",[61,67,71],{"x":68,"y":69,"style":70},"82","50","font-size:13px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:start","Limit the body size",[61,73,76],{"x":68,"y":74,"style":75},"68","font-size:11.5px;font-weight:400;fill:var(--muted,#5b6780);text-anchor:start","at the server or proxy, before your code runs",[39,78],{"x":47,"y":79,"width":49,"height":50,"rx":51,"fill":52,"stroke":53},"90",[55,81],{"cx":50,"cy":82,"r":58,"fill":59},"116.0",[61,84,86],{"x":50,"y":85,"style":64},"121.0","2",[61,88,90],{"x":68,"y":89,"style":70},"112","Check the extension",[61,92,94],{"x":68,"y":93,"style":75},"130","cheap, and entirely user-supplied",[39,96],{"x":47,"y":97,"width":49,"height":50,"rx":51,"fill":52,"stroke":53},"152",[55,99],{"cx":50,"cy":100,"r":58,"fill":59},"178.0",[61,102,104],{"x":50,"y":103,"style":64},"183.0","3",[61,106,108],{"x":68,"y":107,"style":70},"174","Check the leading bytes",[61,110,112],{"x":68,"y":111,"style":75},"192","every .xlsx starts with PK\\x03\\x04",[39,114],{"x":47,"y":115,"width":49,"height":50,"rx":51,"fill":52,"stroke":53},"214",[55,117],{"cx":50,"cy":118,"r":58,"fill":59},"240.0",[61,120,122],{"x":50,"y":121,"style":64},"245.0","4",[61,124,126],{"x":68,"y":125,"style":70},"236","Validate the columns",[61,128,130],{"x":68,"y":129,"style":75},"254","a file that parses is not a file you can use",[61,132,136],{"x":133,"y":134,"style":135},"380.0","296","font-size:12.5px;font-weight:400;fill:var(--muted,#5b6780);text-anchor:middle","each check is cheaper than the one after it",[138,139,141],"h2",{"id":140},"prerequisites","Prerequisites",[143,144,149],"pre",{"className":145,"code":146,"language":147,"meta":148,"style":148},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install flask fastapi \"uvicorn[standard]\" python-multipart pandas python-calamine\n","bash","",[150,151,152],"code",{"__ignoreMap":148},[153,154,157,161,165,168,171,174,177,180],"span",{"class":155,"line":156},"line",1,[153,158,160],{"class":159},"sMTad","pip",[153,162,164],{"class":163},"srMev"," install",[153,166,167],{"class":163}," flask",[153,169,170],{"class":163}," fastapi",[153,172,173],{"class":163}," \"uvicorn[standard]\"",[153,175,176],{"class":163}," python-multipart",[153,178,179],{"class":163}," pandas",[153,181,182],{"class":163}," python-calamine\n",[10,184,185,188],{},[150,186,187],{},"python-multipart"," is what lets FastAPI parse a form upload; without it the endpoint raises at\nimport time with a message naming the package.",[138,190,192],{"id":191},"receive-it-in-flask","Receive it in Flask",[143,194,198],{"className":195,"code":196,"language":197,"meta":148,"style":148},"language-python shiki shiki-themes github-light github-dark-high-contrast","import io\nfrom flask import Flask, jsonify, request\nimport pandas as pd\n\napp = Flask(__name__)\napp.config[\"MAX_CONTENT_LENGTH\"] = 16 * 1024 * 1024      # 16 MB, enforced by Werkzeug\n\nALLOWED = {\".xlsx\", \".xlsm\"}\nZIP_MAGIC = b\"PK\\x03\\x04\"\n\n@app.post(\"\u002Fupload\")\ndef upload():\n    if \"file\" not in request.files:\n        return jsonify(error=\"no file part in the request\"), 400\n    upload = request.files[\"file\"]\n    if not upload.filename:\n        return jsonify(error=\"no file selected\"), 400\n\n    suffix = \".\" + upload.filename.rsplit(\".\", 1)[-1].lower() if \".\" in upload.filename else \"\"\n    if suffix not in ALLOWED:\n        return jsonify(error=f\"unsupported file type {suffix!r}; expected .xlsx or .xlsm\"), 415\n\n    payload = upload.read()\n    if not payload.startswith(ZIP_MAGIC):\n        return jsonify(error=\"that file is not a workbook\"), 415\n\n    frame = pd.read_excel(io.BytesIO(payload), engine=\"calamine\")\n    return jsonify(rows=len(frame), columns=list(frame.columns))\n","python",[150,199,200,210,224,238,245,264,295,300,324,345,350,365,377,395,419,436,446,464,469,520,539,576,581,592,607,625,630,651],{"__ignoreMap":148},[153,201,202,206],{"class":155,"line":156},[153,203,205],{"class":204},"s-kum","import",[153,207,209],{"class":208},"skGVy"," io\n",[153,211,213,216,219,221],{"class":155,"line":212},2,[153,214,215],{"class":204},"from",[153,217,218],{"class":208}," flask ",[153,220,205],{"class":204},[153,222,223],{"class":208}," Flask, jsonify, request\n",[153,225,227,229,232,235],{"class":155,"line":226},3,[153,228,205],{"class":204},[153,230,231],{"class":208}," pandas ",[153,233,234],{"class":204},"as",[153,236,237],{"class":208}," pd\n",[153,239,241],{"class":155,"line":240},4,[153,242,244],{"emptyLinePlaceholder":243},true,"\n",[153,246,248,251,254,257,261],{"class":155,"line":247},5,[153,249,250],{"class":208},"app ",[153,252,253],{"class":204},"=",[153,255,256],{"class":208}," Flask(",[153,258,260],{"class":259},"sP0c6","__name__",[153,262,263],{"class":208},")\n",[153,265,267,270,273,276,278,281,284,287,289,291],{"class":155,"line":266},6,[153,268,269],{"class":208},"app.config[",[153,271,272],{"class":163},"\"MAX_CONTENT_LENGTH\"",[153,274,275],{"class":208},"] ",[153,277,253],{"class":204},[153,279,280],{"class":259}," 16",[153,282,283],{"class":204}," *",[153,285,286],{"class":259}," 1024",[153,288,283],{"class":204},[153,290,286],{"class":259},[153,292,294],{"class":293},"s-wDw","      # 16 MB, enforced by Werkzeug\n",[153,296,298],{"class":155,"line":297},7,[153,299,244],{"emptyLinePlaceholder":243},[153,301,303,306,309,312,315,318,321],{"class":155,"line":302},8,[153,304,305],{"class":259},"ALLOWED",[153,307,308],{"class":204}," =",[153,310,311],{"class":208}," {",[153,313,314],{"class":163},"\".xlsx\"",[153,316,317],{"class":208},", ",[153,319,320],{"class":163},"\".xlsm\"",[153,322,323],{"class":208},"}\n",[153,325,327,330,332,335,338,342],{"class":155,"line":326},9,[153,328,329],{"class":259},"ZIP_MAGIC",[153,331,308],{"class":204},[153,333,334],{"class":204}," b",[153,336,337],{"class":163},"\"PK",[153,339,341],{"class":340},"sSjpA","\\x03\\x04",[153,343,344],{"class":163},"\"\n",[153,346,348],{"class":155,"line":347},10,[153,349,244],{"emptyLinePlaceholder":243},[153,351,353,357,360,363],{"class":155,"line":352},11,[153,354,356],{"class":355},"s_Opv","@app.post",[153,358,359],{"class":208},"(",[153,361,362],{"class":163},"\"\u002Fupload\"",[153,364,263],{"class":208},[153,366,368,371,374],{"class":155,"line":367},12,[153,369,370],{"class":204},"def",[153,372,373],{"class":355}," upload",[153,375,376],{"class":208},"():\n",[153,378,380,383,386,389,392],{"class":155,"line":379},13,[153,381,382],{"class":204},"    if",[153,384,385],{"class":163}," \"file\"",[153,387,388],{"class":204}," not",[153,390,391],{"class":204}," in",[153,393,394],{"class":208}," request.files:\n",[153,396,398,401,404,408,410,413,416],{"class":155,"line":397},14,[153,399,400],{"class":204},"        return",[153,402,403],{"class":208}," jsonify(",[153,405,407],{"class":406},"sa561","error",[153,409,253],{"class":204},[153,411,412],{"class":163},"\"no file part in the request\"",[153,414,415],{"class":208},"), ",[153,417,418],{"class":259},"400\n",[153,420,422,425,427,430,433],{"class":155,"line":421},15,[153,423,424],{"class":208},"    upload ",[153,426,253],{"class":204},[153,428,429],{"class":208}," request.files[",[153,431,432],{"class":163},"\"file\"",[153,434,435],{"class":208},"]\n",[153,437,439,441,443],{"class":155,"line":438},16,[153,440,382],{"class":204},[153,442,388],{"class":204},[153,444,445],{"class":208}," upload.filename:\n",[153,447,449,451,453,455,457,460,462],{"class":155,"line":448},17,[153,450,400],{"class":204},[153,452,403],{"class":208},[153,454,407],{"class":406},[153,456,253],{"class":204},[153,458,459],{"class":163},"\"no file selected\"",[153,461,415],{"class":208},[153,463,418],{"class":259},[153,465,467],{"class":155,"line":466},18,[153,468,244],{"emptyLinePlaceholder":243},[153,470,472,475,477,480,483,486,489,491,493,496,499,501,504,507,509,511,514,517],{"class":155,"line":471},19,[153,473,474],{"class":208},"    suffix ",[153,476,253],{"class":204},[153,478,479],{"class":163}," \".\"",[153,481,482],{"class":204}," +",[153,484,485],{"class":208}," upload.filename.rsplit(",[153,487,488],{"class":163},"\".\"",[153,490,317],{"class":208},[153,492,65],{"class":259},[153,494,495],{"class":208},")[",[153,497,498],{"class":204},"-",[153,500,65],{"class":259},[153,502,503],{"class":208},"].lower() ",[153,505,506],{"class":204},"if",[153,508,479],{"class":163},[153,510,391],{"class":204},[153,512,513],{"class":208}," upload.filename ",[153,515,516],{"class":204},"else",[153,518,519],{"class":163}," \"\"\n",[153,521,523,525,528,531,533,536],{"class":155,"line":522},20,[153,524,382],{"class":204},[153,526,527],{"class":208}," suffix ",[153,529,530],{"class":204},"not",[153,532,391],{"class":204},[153,534,535],{"class":259}," ALLOWED",[153,537,538],{"class":208},":\n",[153,540,542,544,546,548,550,553,556,559,562,565,568,571,573],{"class":155,"line":541},21,[153,543,400],{"class":204},[153,545,403],{"class":208},[153,547,407],{"class":406},[153,549,253],{"class":204},[153,551,552],{"class":204},"f",[153,554,555],{"class":163},"\"unsupported file type ",[153,557,558],{"class":340},"{",[153,560,561],{"class":208},"suffix",[153,563,564],{"class":204},"!r",[153,566,567],{"class":340},"}",[153,569,570],{"class":163},"; expected .xlsx or .xlsm\"",[153,572,415],{"class":208},[153,574,575],{"class":259},"415\n",[153,577,579],{"class":155,"line":578},22,[153,580,244],{"emptyLinePlaceholder":243},[153,582,584,587,589],{"class":155,"line":583},23,[153,585,586],{"class":208},"    payload ",[153,588,253],{"class":204},[153,590,591],{"class":208}," upload.read()\n",[153,593,595,597,599,602,604],{"class":155,"line":594},24,[153,596,382],{"class":204},[153,598,388],{"class":204},[153,600,601],{"class":208}," payload.startswith(",[153,603,329],{"class":259},[153,605,606],{"class":208},"):\n",[153,608,610,612,614,616,618,621,623],{"class":155,"line":609},25,[153,611,400],{"class":204},[153,613,403],{"class":208},[153,615,407],{"class":406},[153,617,253],{"class":204},[153,619,620],{"class":163},"\"that file is not a workbook\"",[153,622,415],{"class":208},[153,624,575],{"class":259},[153,626,628],{"class":155,"line":627},26,[153,629,244],{"emptyLinePlaceholder":243},[153,631,633,636,638,641,644,646,649],{"class":155,"line":632},27,[153,634,635],{"class":208},"    frame ",[153,637,253],{"class":204},[153,639,640],{"class":208}," pd.read_excel(io.BytesIO(payload), ",[153,642,643],{"class":406},"engine",[153,645,253],{"class":204},[153,647,648],{"class":163},"\"calamine\"",[153,650,263],{"class":208},[153,652,654,657,659,662,664,667,670,673,675,678],{"class":155,"line":653},28,[153,655,656],{"class":204},"    return",[153,658,403],{"class":208},[153,660,661],{"class":406},"rows",[153,663,253],{"class":204},[153,665,666],{"class":259},"len",[153,668,669],{"class":208},"(frame), ",[153,671,672],{"class":406},"columns",[153,674,253],{"class":204},[153,676,677],{"class":259},"list",[153,679,680],{"class":208},"(frame.columns))\n",[10,682,683,686,687,690],{},[150,684,685],{},"MAX_CONTENT_LENGTH"," is enforced by the server before your view runs, which is the important part —\nit rejects an oversized body rather than letting you discover the size after buffering it. The magic\nbyte check is the other cheap win: every ",[150,688,689],{},".xlsx"," is a zip archive, and anything that does not start\nwith the zip signature is not one, whatever it is called.",[138,692,694],{"id":693},"receive-it-in-fastapi","Receive it in FastAPI",[143,696,698],{"className":195,"code":697,"language":197,"meta":148,"style":148},"import io\nfrom fastapi import FastAPI, File, HTTPException, UploadFile\nimport pandas as pd\n\napi = FastAPI()\nMAX_BYTES = 16 * 1024 * 1024\n\n@api.post(\"\u002Fupload\")\nasync def upload(file: UploadFile = File(...)):\n    if not file.filename.lower().endswith((\".xlsx\", \".xlsm\")):\n        raise HTTPException(415, \"expected an .xlsx or .xlsm workbook\")\n\n    payload = await file.read()\n    if len(payload) > MAX_BYTES:\n        raise HTTPException(413, f\"file exceeds {MAX_BYTES \u002F\u002F 1024 \u002F\u002F 1024} MB\")\n    if not payload.startswith(b\"PK\\x03\\x04\"):\n        raise HTTPException(415, \"that file is not a workbook\")\n\n    frame = pd.read_excel(io.BytesIO(payload), engine=\"calamine\")\n    return {\"rows\": len(frame), \"columns\": list(frame.columns)}\n",[150,699,700,706,718,728,732,742,760,764,775,799,819,837,841,855,873,909,929,943,947,963],{"__ignoreMap":148},[153,701,702,704],{"class":155,"line":156},[153,703,205],{"class":204},[153,705,209],{"class":208},[153,707,708,710,713,715],{"class":155,"line":212},[153,709,215],{"class":204},[153,711,712],{"class":208}," fastapi ",[153,714,205],{"class":204},[153,716,717],{"class":208}," FastAPI, File, HTTPException, UploadFile\n",[153,719,720,722,724,726],{"class":155,"line":226},[153,721,205],{"class":204},[153,723,231],{"class":208},[153,725,234],{"class":204},[153,727,237],{"class":208},[153,729,730],{"class":155,"line":240},[153,731,244],{"emptyLinePlaceholder":243},[153,733,734,737,739],{"class":155,"line":247},[153,735,736],{"class":208},"api ",[153,738,253],{"class":204},[153,740,741],{"class":208}," FastAPI()\n",[153,743,744,747,749,751,753,755,757],{"class":155,"line":266},[153,745,746],{"class":259},"MAX_BYTES",[153,748,308],{"class":204},[153,750,280],{"class":259},[153,752,283],{"class":204},[153,754,286],{"class":259},[153,756,283],{"class":204},[153,758,759],{"class":259}," 1024\n",[153,761,762],{"class":155,"line":297},[153,763,244],{"emptyLinePlaceholder":243},[153,765,766,769,771,773],{"class":155,"line":302},[153,767,768],{"class":355},"@api.post",[153,770,359],{"class":208},[153,772,362],{"class":163},[153,774,263],{"class":208},[153,776,777,780,783,785,788,790,793,796],{"class":155,"line":326},[153,778,779],{"class":204},"async",[153,781,782],{"class":204}," def",[153,784,373],{"class":355},[153,786,787],{"class":208},"(file: UploadFile ",[153,789,253],{"class":204},[153,791,792],{"class":208}," File(",[153,794,795],{"class":259},"...",[153,797,798],{"class":208},")):\n",[153,800,801,803,805,808,811,813,815,817],{"class":155,"line":347},[153,802,382],{"class":204},[153,804,388],{"class":204},[153,806,807],{"class":406}," file",[153,809,810],{"class":208},".filename.lower().endswith((",[153,812,314],{"class":163},[153,814,317],{"class":208},[153,816,320],{"class":163},[153,818,798],{"class":208},[153,820,821,824,827,830,832,835],{"class":155,"line":352},[153,822,823],{"class":204},"        raise",[153,825,826],{"class":208}," HTTPException(",[153,828,829],{"class":259},"415",[153,831,317],{"class":208},[153,833,834],{"class":163},"\"expected an .xlsx or .xlsm workbook\"",[153,836,263],{"class":208},[153,838,839],{"class":155,"line":367},[153,840,244],{"emptyLinePlaceholder":243},[153,842,843,845,847,850,852],{"class":155,"line":379},[153,844,586],{"class":208},[153,846,253],{"class":204},[153,848,849],{"class":204}," await",[153,851,807],{"class":406},[153,853,854],{"class":208},".read()\n",[153,856,857,859,862,865,868,871],{"class":155,"line":397},[153,858,382],{"class":204},[153,860,861],{"class":259}," len",[153,863,864],{"class":208},"(payload) ",[153,866,867],{"class":204},">",[153,869,870],{"class":259}," MAX_BYTES",[153,872,538],{"class":208},[153,874,875,877,879,882,884,886,889,891,893,896,898,900,902,904,907],{"class":155,"line":421},[153,876,823],{"class":204},[153,878,826],{"class":208},[153,880,881],{"class":259},"413",[153,883,317],{"class":208},[153,885,552],{"class":204},[153,887,888],{"class":163},"\"file exceeds ",[153,890,558],{"class":340},[153,892,746],{"class":259},[153,894,895],{"class":204}," \u002F\u002F",[153,897,286],{"class":259},[153,899,895],{"class":204},[153,901,286],{"class":259},[153,903,567],{"class":340},[153,905,906],{"class":163}," MB\"",[153,908,263],{"class":208},[153,910,911,913,915,917,920,922,924,927],{"class":155,"line":438},[153,912,382],{"class":204},[153,914,388],{"class":204},[153,916,601],{"class":208},[153,918,919],{"class":204},"b",[153,921,337],{"class":163},[153,923,341],{"class":340},[153,925,926],{"class":163},"\"",[153,928,606],{"class":208},[153,930,931,933,935,937,939,941],{"class":155,"line":448},[153,932,823],{"class":204},[153,934,826],{"class":208},[153,936,829],{"class":259},[153,938,317],{"class":208},[153,940,620],{"class":163},[153,942,263],{"class":208},[153,944,945],{"class":155,"line":466},[153,946,244],{"emptyLinePlaceholder":243},[153,948,949,951,953,955,957,959,961],{"class":155,"line":471},[153,950,635],{"class":208},[153,952,253],{"class":204},[153,954,640],{"class":208},[153,956,643],{"class":406},[153,958,253],{"class":204},[153,960,648],{"class":163},[153,962,263],{"class":208},[153,964,965,967,969,972,975,977,979,982,984,986],{"class":155,"line":522},[153,966,656],{"class":204},[153,968,311],{"class":208},[153,970,971],{"class":163},"\"rows\"",[153,973,974],{"class":208},": ",[153,976,666],{"class":259},[153,978,669],{"class":208},[153,980,981],{"class":163},"\"columns\"",[153,983,974],{"class":208},[153,985,677],{"class":259},[153,987,988],{"class":208},"(frame.columns)}\n",[10,990,991,992,995],{},"FastAPI has no built-in body-size limit, so the check here is manual — and it happens after the body\nhas been buffered, which is why a limit in the reverse proxy (",[150,993,994],{},"client_max_body_size"," in nginx) is the\nreal defence. The application check is a clearer error message, not a protection.",[138,997,999],{"id":998},"validate-the-contents-not-just-the-file","Validate the contents, not just the file",[20,1001,29,1006,29,1009,29,1012,29,1015,29,1024,29,1030,29,1036,29,1041,29,1045,29,1049,29,1052,29,1057,29,1061,29,1064,29,1067,29,1070,29,1079,29,1084,29,1089,29,1093,29,1097,29,1100],{"viewBox":1002,"role":23,"ariaLabelledBy":1003,"xmlns":27,"style":28},"0 0 760 201",[1004,1005],"up-msg-t","up-msg-d",[31,1007,1008],{"id":1004},"What the response tells the person who uploaded",[35,1010,1011],{"id":1005},"A generic rejection gives the user nothing to act on, while a validation pass that reports every problem with sheet row numbers turns the feature into something usable.",[39,1013],{"x":41,"y":41,"width":42,"height":1014,"fill":44},"201",[39,1016],{"x":1017,"y":48,"width":1018,"height":1019,"rx":1020,"fill":1021,"stroke":1022,"style":1023},"20","270.0","139","14","#fee8f2","var(--accent,#d81b73)","stroke-width:2px",[61,1025,1029],{"x":1026,"y":1027,"style":1028},"155.0","54","font-size:13px;font-weight:700;fill:var(--accent,#d81b73);text-anchor:middle","upload failed",[155,1031],{"x1":1032,"y1":1033,"x2":1034,"y2":1033,"stroke":1022,"style":1035},"36","64","274.0","stroke-width:1px",[61,1037,1040],{"x":1026,"y":1038,"style":1039},"86","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","one line, no detail",[61,1042,1044],{"x":1026,"y":1043,"style":1039},"109","user retries blindly",[61,1046,1048],{"x":1026,"y":1047,"style":1039},"132","support ticket",[39,1050],{"x":1051,"y":48,"width":1018,"height":1019,"rx":1020,"fill":52,"stroke":53,"style":1023},"470.0",[61,1053,1056],{"x":1054,"y":1027,"style":1055},"605.0","font-size:13px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","three problems, listed",[155,1058],{"x1":1059,"y1":1033,"x2":1060,"y2":1033,"stroke":53,"style":1035},"486.0","724.0",[61,1062,1063],{"x":1054,"y":1038,"style":1039},"missing column named",[61,1065,1066],{"x":1054,"y":1043,"style":1039},"bad rows numbered",[61,1068,1069],{"x":1054,"y":1047,"style":1039},"fix and resubmit",[39,1071],{"x":1072,"y":1073,"width":1074,"height":1075,"rx":1076,"fill":1077,"stroke":1078},"316.0","78.5","128","38","19","#ebebfd","var(--brand,#5b5cf0)",[61,1080,1083],{"x":133,"y":1081,"style":1082},"102.5","font-size:12.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","422",[155,1085],{"x1":1086,"y1":1087,"x2":1088,"y2":1087,"stroke":1078,"style":1023},"295.0","97.5","309.0",[1090,1091],"polygon",{"points":1092,"fill":59},"309.0,97.5 300.0,92.5 300.0,102.5",[155,1094],{"x1":1095,"y1":1087,"x2":1096,"y2":1087,"stroke":1078,"style":1023},"449.0","463.0",[1090,1098],{"points":1099,"fill":59},"463.0,97.5 454.0,92.5 454.0,102.5",[61,1101,1103],{"x":133,"y":1102,"style":135},"187","row numbers must match the spreadsheet, not the frame",[10,1105,1106],{},"A file that parses is not a file you can use. The columns may be missing, renamed, or full of text\nwhere numbers belong — and reporting all of those at once is what makes the feature usable.",[143,1108,1110],{"className":195,"code":1109,"language":197,"meta":148,"style":148},"REQUIRED = {\"Order_ID\": \"int64\", \"Region\": \"object\", \"Revenue\": \"float64\"}\n\ndef validate(frame: pd.DataFrame) -> list[str]:\n    problems = []\n    missing = [name for name in REQUIRED if name not in frame.columns]\n    if missing:\n        problems.append(f\"missing column(s): {', '.join(missing)}; found {list(frame.columns)}\")\n        return problems                       # no point checking types yet\n\n    numeric = pd.to_numeric(frame[\"Revenue\"], errors=\"coerce\")\n    bad_rows = frame.index[numeric.isna() & frame[\"Revenue\"].notna()] + 2   # +2 for header and 1-base\n    if len(bad_rows):\n        problems.append(f\"non-numeric Revenue on row(s): {list(bad_rows[:20])}\")\n\n    empty_keys = frame.index[frame[\"Order_ID\"].isna()] + 2\n    if len(empty_keys):\n        problems.append(f\"missing Order_ID on row(s): {list(empty_keys[:20])}\")\n    return problems\n",[150,1111,1112,1151,1155,1171,1181,1215,1222,1258,1268,1272,1297,1327,1336,1363,1367,1387,1396,1422],{"__ignoreMap":148},[153,1113,1114,1117,1119,1121,1124,1126,1129,1131,1134,1136,1139,1141,1144,1146,1149],{"class":155,"line":156},[153,1115,1116],{"class":259},"REQUIRED",[153,1118,308],{"class":204},[153,1120,311],{"class":208},[153,1122,1123],{"class":163},"\"Order_ID\"",[153,1125,974],{"class":208},[153,1127,1128],{"class":163},"\"int64\"",[153,1130,317],{"class":208},[153,1132,1133],{"class":163},"\"Region\"",[153,1135,974],{"class":208},[153,1137,1138],{"class":163},"\"object\"",[153,1140,317],{"class":208},[153,1142,1143],{"class":163},"\"Revenue\"",[153,1145,974],{"class":208},[153,1147,1148],{"class":163},"\"float64\"",[153,1150,323],{"class":208},[153,1152,1153],{"class":155,"line":212},[153,1154,244],{"emptyLinePlaceholder":243},[153,1156,1157,1159,1162,1165,1168],{"class":155,"line":226},[153,1158,370],{"class":204},[153,1160,1161],{"class":355}," validate",[153,1163,1164],{"class":208},"(frame: pd.DataFrame) -> list[",[153,1166,1167],{"class":259},"str",[153,1169,1170],{"class":208},"]:\n",[153,1172,1173,1176,1178],{"class":155,"line":240},[153,1174,1175],{"class":208},"    problems ",[153,1177,253],{"class":204},[153,1179,1180],{"class":208}," []\n",[153,1182,1183,1186,1188,1191,1194,1197,1200,1203,1206,1208,1210,1212],{"class":155,"line":247},[153,1184,1185],{"class":208},"    missing ",[153,1187,253],{"class":204},[153,1189,1190],{"class":208}," [name ",[153,1192,1193],{"class":204},"for",[153,1195,1196],{"class":208}," name ",[153,1198,1199],{"class":204},"in",[153,1201,1202],{"class":259}," REQUIRED",[153,1204,1205],{"class":204}," if",[153,1207,1196],{"class":208},[153,1209,530],{"class":204},[153,1211,391],{"class":204},[153,1213,1214],{"class":208}," frame.columns]\n",[153,1216,1217,1219],{"class":155,"line":266},[153,1218,382],{"class":204},[153,1220,1221],{"class":208}," missing:\n",[153,1223,1224,1227,1229,1232,1234,1237,1240,1242,1245,1247,1249,1252,1254,1256],{"class":155,"line":297},[153,1225,1226],{"class":208},"        problems.append(",[153,1228,552],{"class":204},[153,1230,1231],{"class":163},"\"missing column(s): ",[153,1233,558],{"class":340},[153,1235,1236],{"class":163},"', '",[153,1238,1239],{"class":208},".join(missing)",[153,1241,567],{"class":340},[153,1243,1244],{"class":163},"; found ",[153,1246,558],{"class":340},[153,1248,677],{"class":259},[153,1250,1251],{"class":208},"(frame.columns)",[153,1253,567],{"class":340},[153,1255,926],{"class":163},[153,1257,263],{"class":208},[153,1259,1260,1262,1265],{"class":155,"line":302},[153,1261,400],{"class":204},[153,1263,1264],{"class":208}," problems                       ",[153,1266,1267],{"class":293},"# no point checking types yet\n",[153,1269,1270],{"class":155,"line":326},[153,1271,244],{"emptyLinePlaceholder":243},[153,1273,1274,1277,1279,1282,1284,1287,1290,1292,1295],{"class":155,"line":347},[153,1275,1276],{"class":208},"    numeric ",[153,1278,253],{"class":204},[153,1280,1281],{"class":208}," pd.to_numeric(frame[",[153,1283,1143],{"class":163},[153,1285,1286],{"class":208},"], ",[153,1288,1289],{"class":406},"errors",[153,1291,253],{"class":204},[153,1293,1294],{"class":163},"\"coerce\"",[153,1296,263],{"class":208},[153,1298,1299,1302,1304,1307,1310,1313,1315,1318,1321,1324],{"class":155,"line":352},[153,1300,1301],{"class":208},"    bad_rows ",[153,1303,253],{"class":204},[153,1305,1306],{"class":208}," frame.index[numeric.isna() ",[153,1308,1309],{"class":204},"&",[153,1311,1312],{"class":208}," frame[",[153,1314,1143],{"class":163},[153,1316,1317],{"class":208},"].notna()] ",[153,1319,1320],{"class":204},"+",[153,1322,1323],{"class":259}," 2",[153,1325,1326],{"class":293},"   # +2 for header and 1-base\n",[153,1328,1329,1331,1333],{"class":155,"line":367},[153,1330,382],{"class":204},[153,1332,861],{"class":259},[153,1334,1335],{"class":208},"(bad_rows):\n",[153,1337,1338,1340,1342,1345,1347,1349,1352,1354,1357,1359,1361],{"class":155,"line":379},[153,1339,1226],{"class":208},[153,1341,552],{"class":204},[153,1343,1344],{"class":163},"\"non-numeric Revenue on row(s): ",[153,1346,558],{"class":340},[153,1348,677],{"class":259},[153,1350,1351],{"class":208},"(bad_rows[:",[153,1353,1017],{"class":259},[153,1355,1356],{"class":208},"])",[153,1358,567],{"class":340},[153,1360,926],{"class":163},[153,1362,263],{"class":208},[153,1364,1365],{"class":155,"line":397},[153,1366,244],{"emptyLinePlaceholder":243},[153,1368,1369,1372,1374,1377,1379,1382,1384],{"class":155,"line":421},[153,1370,1371],{"class":208},"    empty_keys ",[153,1373,253],{"class":204},[153,1375,1376],{"class":208}," frame.index[frame[",[153,1378,1123],{"class":163},[153,1380,1381],{"class":208},"].isna()] ",[153,1383,1320],{"class":204},[153,1385,1386],{"class":259}," 2\n",[153,1388,1389,1391,1393],{"class":155,"line":438},[153,1390,382],{"class":204},[153,1392,861],{"class":259},[153,1394,1395],{"class":208},"(empty_keys):\n",[153,1397,1398,1400,1402,1405,1407,1409,1412,1414,1416,1418,1420],{"class":155,"line":448},[153,1399,1226],{"class":208},[153,1401,552],{"class":204},[153,1403,1404],{"class":163},"\"missing Order_ID on row(s): ",[153,1406,558],{"class":340},[153,1408,677],{"class":259},[153,1410,1411],{"class":208},"(empty_keys[:",[153,1413,1017],{"class":259},[153,1415,1356],{"class":208},[153,1417,567],{"class":340},[153,1419,926],{"class":163},[153,1421,263],{"class":208},[153,1423,1424,1426],{"class":155,"line":466},[153,1425,656],{"class":204},[153,1427,1428],{"class":208}," problems\n",[10,1430,1431],{},"Adding two to the index is the detail that makes the message useful: pandas rows are zero-based and\nthe header occupies row one, so row 0 of the frame is row 2 of the sheet. Reporting a row number the\nuser cannot find in their spreadsheet is worse than reporting none at all.",[138,1433,1435],{"id":1434},"return-the-errors-as-data","Return the errors as data",[143,1437,1439],{"className":195,"code":1438,"language":197,"meta":148,"style":148},"@app.post(\"\u002Fupload\")\ndef upload():\n    ...\n    frame = pd.read_excel(io.BytesIO(payload), engine=\"calamine\")\n    problems = validate(frame)\n    if problems:\n        return jsonify(accepted=False, problems=problems), 422\n    return jsonify(accepted=True, rows=len(frame))\n",[150,1440,1441,1451,1459,1464,1480,1489,1496,1523],{"__ignoreMap":148},[153,1442,1443,1445,1447,1449],{"class":155,"line":156},[153,1444,356],{"class":355},[153,1446,359],{"class":208},[153,1448,362],{"class":163},[153,1450,263],{"class":208},[153,1452,1453,1455,1457],{"class":155,"line":212},[153,1454,370],{"class":204},[153,1456,373],{"class":355},[153,1458,376],{"class":208},[153,1460,1461],{"class":155,"line":226},[153,1462,1463],{"class":259},"    ...\n",[153,1465,1466,1468,1470,1472,1474,1476,1478],{"class":155,"line":240},[153,1467,635],{"class":208},[153,1469,253],{"class":204},[153,1471,640],{"class":208},[153,1473,643],{"class":406},[153,1475,253],{"class":204},[153,1477,648],{"class":163},[153,1479,263],{"class":208},[153,1481,1482,1484,1486],{"class":155,"line":247},[153,1483,1175],{"class":208},[153,1485,253],{"class":204},[153,1487,1488],{"class":208}," validate(frame)\n",[153,1490,1491,1493],{"class":155,"line":266},[153,1492,382],{"class":204},[153,1494,1495],{"class":208}," problems:\n",[153,1497,1498,1500,1502,1505,1507,1510,1512,1515,1517,1520],{"class":155,"line":297},[153,1499,400],{"class":204},[153,1501,403],{"class":208},[153,1503,1504],{"class":406},"accepted",[153,1506,253],{"class":204},[153,1508,1509],{"class":259},"False",[153,1511,317],{"class":208},[153,1513,1514],{"class":406},"problems",[153,1516,253],{"class":204},[153,1518,1519],{"class":208},"problems), ",[153,1521,1522],{"class":259},"422\n",[153,1524,1525,1527,1529,1531,1533,1536,1538,1540,1542,1544],{"class":155,"line":302},[153,1526,656],{"class":204},[153,1528,403],{"class":208},[153,1530,1504],{"class":406},[153,1532,253],{"class":204},[153,1534,1535],{"class":259},"True",[153,1537,317],{"class":208},[153,1539,661],{"class":406},[153,1541,253],{"class":204},[153,1543,666],{"class":259},[153,1545,1546],{"class":208},"(frame))\n",[10,1548,1549,1550,1554],{},"422 is the right status for a well-formed request whose content fails validation, and returning the\nproblems as a list lets a front end render them beside the upload control. For a larger file it is\nworth going further and returning an annotated copy of the workbook with the failing cells\nhighlighted — the technique in\n",[14,1551,1553],{"href":1552},"\u002Fadvanced-data-transformation-and-cleaning\u002Fvalidating-excel-data-with-python\u002Fhighlight-invalid-cells-in-excel-with-python\u002F","Highlight Invalid Cells in Excel with Python",".",[138,1556,1558],{"id":1557},"handling-files-too-large-to-hold","Handling files too large to hold",[10,1560,1561],{},"For genuinely large uploads, streaming to a temporary file and parsing from there keeps the peak\nmemory to one copy rather than two.",[143,1563,1565],{"className":195,"code":1564,"language":197,"meta":148,"style":148},"import tempfile\nfrom pathlib import Path\n\n@api.post(\"\u002Fupload-large\")\nasync def upload_large(file: UploadFile = File(...)):\n    with tempfile.NamedTemporaryFile(suffix=\".xlsx\", delete=False) as handle:\n        while chunk := await file.read(1024 * 1024):\n            handle.write(chunk)\n        target = Path(handle.name)\n    try:\n        frame = pd.read_excel(target, engine=\"calamine\")\n        return {\"rows\": len(frame)}\n    finally:\n        target.unlink(missing_ok=True)\n",[150,1566,1567,1574,1586,1590,1601,1620,1651,1678,1683,1693,1700,1718,1733,1740],{"__ignoreMap":148},[153,1568,1569,1571],{"class":155,"line":156},[153,1570,205],{"class":204},[153,1572,1573],{"class":208}," tempfile\n",[153,1575,1576,1578,1581,1583],{"class":155,"line":212},[153,1577,215],{"class":204},[153,1579,1580],{"class":208}," pathlib ",[153,1582,205],{"class":204},[153,1584,1585],{"class":208}," Path\n",[153,1587,1588],{"class":155,"line":226},[153,1589,244],{"emptyLinePlaceholder":243},[153,1591,1592,1594,1596,1599],{"class":155,"line":240},[153,1593,768],{"class":355},[153,1595,359],{"class":208},[153,1597,1598],{"class":163},"\"\u002Fupload-large\"",[153,1600,263],{"class":208},[153,1602,1603,1605,1607,1610,1612,1614,1616,1618],{"class":155,"line":247},[153,1604,779],{"class":204},[153,1606,782],{"class":204},[153,1608,1609],{"class":355}," upload_large",[153,1611,787],{"class":208},[153,1613,253],{"class":204},[153,1615,792],{"class":208},[153,1617,795],{"class":259},[153,1619,798],{"class":208},[153,1621,1622,1625,1628,1630,1632,1634,1636,1639,1641,1643,1646,1648],{"class":155,"line":266},[153,1623,1624],{"class":204},"    with",[153,1626,1627],{"class":208}," tempfile.NamedTemporaryFile(",[153,1629,561],{"class":406},[153,1631,253],{"class":204},[153,1633,314],{"class":163},[153,1635,317],{"class":208},[153,1637,1638],{"class":406},"delete",[153,1640,253],{"class":204},[153,1642,1509],{"class":259},[153,1644,1645],{"class":208},") ",[153,1647,234],{"class":204},[153,1649,1650],{"class":208}," handle:\n",[153,1652,1653,1656,1659,1662,1664,1666,1669,1672,1674,1676],{"class":155,"line":297},[153,1654,1655],{"class":204},"        while",[153,1657,1658],{"class":208}," chunk ",[153,1660,1661],{"class":204},":=",[153,1663,849],{"class":204},[153,1665,807],{"class":406},[153,1667,1668],{"class":208},".read(",[153,1670,1671],{"class":259},"1024",[153,1673,283],{"class":204},[153,1675,286],{"class":259},[153,1677,606],{"class":208},[153,1679,1680],{"class":155,"line":302},[153,1681,1682],{"class":208},"            handle.write(chunk)\n",[153,1684,1685,1688,1690],{"class":155,"line":326},[153,1686,1687],{"class":208},"        target ",[153,1689,253],{"class":204},[153,1691,1692],{"class":208}," Path(handle.name)\n",[153,1694,1695,1698],{"class":155,"line":347},[153,1696,1697],{"class":204},"    try",[153,1699,538],{"class":208},[153,1701,1702,1705,1707,1710,1712,1714,1716],{"class":155,"line":352},[153,1703,1704],{"class":208},"        frame ",[153,1706,253],{"class":204},[153,1708,1709],{"class":208}," pd.read_excel(target, ",[153,1711,643],{"class":406},[153,1713,253],{"class":204},[153,1715,648],{"class":163},[153,1717,263],{"class":208},[153,1719,1720,1722,1724,1726,1728,1730],{"class":155,"line":367},[153,1721,400],{"class":204},[153,1723,311],{"class":208},[153,1725,971],{"class":163},[153,1727,974],{"class":208},[153,1729,666],{"class":259},[153,1731,1732],{"class":208},"(frame)}\n",[153,1734,1735,1738],{"class":155,"line":379},[153,1736,1737],{"class":204},"    finally",[153,1739,538],{"class":208},[153,1741,1742,1745,1748,1750,1752],{"class":155,"line":397},[153,1743,1744],{"class":208},"        target.unlink(",[153,1746,1747],{"class":406},"missing_ok",[153,1749,253],{"class":204},[153,1751,1535],{"class":259},[153,1753,263],{"class":208},[10,1755,1756,1757,1760,1761,1764],{},"The ",[150,1758,1759],{},"finally"," is not optional — an upload endpoint that leaves temporary files behind fills a disk\nover weeks, and the failure appears as something unrelated. ",[150,1762,1763],{},"missing_ok=True"," keeps the cleanup from\nraising when the parse failed before the file was complete.",[138,1766,1768],{"id":1767},"reading-only-the-sheet-you-expect","Reading only the sheet you expect",[10,1770,1771],{},"An uploaded workbook may contain a dozen tabs, and reading the first one is a guess. Where the format\nis specified — \"the data must be on a sheet named Orders\" — enforce it, and report the available\nnames when it is missing so the user can see what they actually sent.",[143,1773,1775],{"className":195,"code":1774,"language":197,"meta":148,"style":148},"import io\nimport pandas as pd\n\ndef read_named_sheet(payload: bytes, required: str) -> pd.DataFrame:\n    buffer = io.BytesIO(payload)\n    available = pd.ExcelFile(buffer, engine=\"calamine\").sheet_names\n    if required not in available:\n        raise ValueError(f\"no sheet named {required!r}; the file contains {available}\")\n    buffer.seek(0)\n    return pd.read_excel(buffer, sheet_name=required, engine=\"calamine\")\n",[150,1776,1777,1783,1793,1797,1818,1828,1847,1861,1898,1907],{"__ignoreMap":148},[153,1778,1779,1781],{"class":155,"line":156},[153,1780,205],{"class":204},[153,1782,209],{"class":208},[153,1784,1785,1787,1789,1791],{"class":155,"line":212},[153,1786,205],{"class":204},[153,1788,231],{"class":208},[153,1790,234],{"class":204},[153,1792,237],{"class":208},[153,1794,1795],{"class":155,"line":226},[153,1796,244],{"emptyLinePlaceholder":243},[153,1798,1799,1801,1804,1807,1810,1813,1815],{"class":155,"line":240},[153,1800,370],{"class":204},[153,1802,1803],{"class":355}," read_named_sheet",[153,1805,1806],{"class":208},"(payload: ",[153,1808,1809],{"class":259},"bytes",[153,1811,1812],{"class":208},", required: ",[153,1814,1167],{"class":259},[153,1816,1817],{"class":208},") -> pd.DataFrame:\n",[153,1819,1820,1823,1825],{"class":155,"line":247},[153,1821,1822],{"class":208},"    buffer ",[153,1824,253],{"class":204},[153,1826,1827],{"class":208}," io.BytesIO(payload)\n",[153,1829,1830,1833,1835,1838,1840,1842,1844],{"class":155,"line":266},[153,1831,1832],{"class":208},"    available ",[153,1834,253],{"class":204},[153,1836,1837],{"class":208}," pd.ExcelFile(buffer, ",[153,1839,643],{"class":406},[153,1841,253],{"class":204},[153,1843,648],{"class":163},[153,1845,1846],{"class":208},").sheet_names\n",[153,1848,1849,1851,1854,1856,1858],{"class":155,"line":297},[153,1850,382],{"class":204},[153,1852,1853],{"class":208}," required ",[153,1855,530],{"class":204},[153,1857,391],{"class":204},[153,1859,1860],{"class":208}," available:\n",[153,1862,1863,1865,1868,1870,1872,1875,1877,1880,1882,1884,1887,1889,1892,1894,1896],{"class":155,"line":302},[153,1864,823],{"class":204},[153,1866,1867],{"class":259}," ValueError",[153,1869,359],{"class":208},[153,1871,552],{"class":204},[153,1873,1874],{"class":163},"\"no sheet named ",[153,1876,558],{"class":340},[153,1878,1879],{"class":208},"required",[153,1881,564],{"class":204},[153,1883,567],{"class":340},[153,1885,1886],{"class":163},"; the file contains ",[153,1888,558],{"class":340},[153,1890,1891],{"class":208},"available",[153,1893,567],{"class":340},[153,1895,926],{"class":163},[153,1897,263],{"class":208},[153,1899,1900,1903,1905],{"class":155,"line":326},[153,1901,1902],{"class":208},"    buffer.seek(",[153,1904,41],{"class":259},[153,1906,263],{"class":208},[153,1908,1909,1911,1914,1917,1919,1922,1924,1926,1928],{"class":155,"line":347},[153,1910,656],{"class":204},[153,1912,1913],{"class":208}," pd.read_excel(buffer, ",[153,1915,1916],{"class":406},"sheet_name",[153,1918,253],{"class":204},[153,1920,1921],{"class":208},"required, ",[153,1923,643],{"class":406},[153,1925,253],{"class":204},[153,1927,648],{"class":163},[153,1929,263],{"class":208},[10,1931,1932,1935],{},[150,1933,1934],{},"buffer.seek(0)"," is the detail that makes this work — reading the sheet names consumes the buffer, so\nthe second read starts at the end and finds nothing. It is a two-character fix for an error message\nthat otherwise blames the file.",[10,1937,1938],{},"Listing the tabs the file does contain turns \"no sheet named Orders\" into something the user can act\non in one attempt, which for an upload feature is the whole difference between usable and not.",[138,1940,1942],{"id":1941},"protecting-against-a-hostile-workbook","Protecting against a hostile workbook",[10,1944,1945],{},"An uploaded spreadsheet is untrusted input in a stronger sense than most: the format supports macros,\nexternal data connections and links that resolve when opened. Three precautions cover the realistic\nrisks without becoming a security project.",[10,1947,1948,1949,1952],{},"Read with a parser that does not execute anything — calamine and openpyxl both read values and never\nrun VBA, so parsing is safe in a way that opening the file in Excel is not. Reject ",[150,1950,1951],{},".xlsm"," outright\nunless macros are genuinely part of the requirement, since accepting them means eventually storing\nand forwarding them. And never open an uploaded file with Excel automation on a server, which is the\none path where a macro could actually run.",[143,1954,1956],{"className":195,"code":1955,"language":197,"meta":148,"style":148},"if payload.startswith(b\"PK\\x03\\x04\") and filename.lower().endswith(\".xlsm\"):\n    raise HTTPException(415, \"macro-enabled workbooks are not accepted\")\n",[150,1957,1958,1984],{"__ignoreMap":148},[153,1959,1960,1962,1964,1966,1968,1970,1972,1974,1977,1980,1982],{"class":155,"line":156},[153,1961,506],{"class":204},[153,1963,601],{"class":208},[153,1965,919],{"class":204},[153,1967,337],{"class":163},[153,1969,341],{"class":340},[153,1971,926],{"class":163},[153,1973,1645],{"class":208},[153,1975,1976],{"class":204},"and",[153,1978,1979],{"class":208}," filename.lower().endswith(",[153,1981,320],{"class":163},[153,1983,606],{"class":208},[153,1985,1986,1989,1991,1993,1995,1998],{"class":155,"line":212},[153,1987,1988],{"class":204},"    raise",[153,1990,826],{"class":208},[153,1992,829],{"class":259},[153,1994,317],{"class":208},[153,1996,1997],{"class":163},"\"macro-enabled workbooks are not accepted\"",[153,1999,263],{"class":208},[10,2001,2002,2003,1554],{},"Storing the upload rather than acting on it directly also gives an audit trail if something later\nturns out to be wrong, which is the same reasoning behind the archive key above and behind the\nhandling advice in\n",[14,2004,2006],{"href":2005},"\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fwork-with-macro-enabled-xlsm-files-in-openpyxl\u002F","Work with Macro-Enabled .xlsm Files in openpyxl",[138,2008,2010],{"id":2009},"common-pitfalls","Common pitfalls",[2012,2013,2014,2030],"table",{},[2015,2016,2017],"thead",{},[2018,2019,2020,2024,2027],"tr",{},[2021,2022,2023],"th",{},"Symptom",[2021,2025,2026],{},"Cause",[2021,2028,2029],{},"Fix",[2031,2032,2033,2050,2065,2076,2087,2098],"tbody",{},[2018,2034,2035,2042,2047],{},[2036,2037,2038,2041],"td",{},[150,2039,2040],{},"RequestEntityTooLarge"," with an unhelpful page",[2036,2043,2044,2046],{},[150,2045,685],{}," hit, no error handler",[2036,2048,2049],{},"Register a 413 handler returning JSON",[2018,2051,2052,2057,2060],{},[2036,2053,2054,2055],{},"FastAPI raises about ",[150,2056,187],{},[2036,2058,2059],{},"The parser package is not installed",[2036,2061,2062],{},[150,2063,2064],{},"pip install python-multipart",[2018,2066,2067,2070,2073],{},[2036,2068,2069],{},"A renamed executable is accepted",[2036,2071,2072],{},"Extension checked, contents not",[2036,2074,2075],{},"Check the leading bytes for the zip signature",[2018,2077,2078,2081,2084],{},[2036,2079,2080],{},"Row numbers in errors do not match the sheet",[2036,2082,2083],{},"Zero-based index reported directly",[2036,2085,2086],{},"Add two for the header and one-based rows",[2018,2088,2089,2092,2095],{},[2036,2090,2091],{},"Memory grows with concurrent uploads",[2036,2093,2094],{},"Every request buffers the whole body",[2036,2096,2097],{},"Limit size at the proxy, and stream large files",[2018,2099,2100,2103,2106],{},[2036,2101,2102],{},"Temporary files accumulate",[2036,2104,2105],{},"Cleanup skipped when parsing failed",[2036,2107,2108,2109,2111,2112],{},"Delete in a ",[150,2110,1759],{}," with ",[150,2113,1763],{},[138,2115,2117],{"id":2116},"storing-what-was-uploaded","Storing what was uploaded",[10,2119,2120],{},"Most upload features eventually need to keep the file — for audit, for reprocessing, or because\nsomebody will ask what was submitted. Writing it to object storage under a key that records who and\nwhen is more useful than a folder of user-supplied names.",[143,2122,2124],{"className":195,"code":2123,"language":197,"meta":148,"style":148},"import hashlib\nfrom datetime import datetime, timezone\n\ndef archive_key(payload: bytes, user: str) -> str:\n    digest = hashlib.sha256(payload).hexdigest()[:16]\n    stamp = datetime.now(timezone.utc).strftime(\"%Y\u002F%m\u002F%d\u002F%H%M%S\")\n    return f\"uploads\u002F{user}\u002F{stamp}-{digest}.xlsx\"\n",[150,2125,2126,2133,2145,2149,2172,2187,2208],{"__ignoreMap":148},[153,2127,2128,2130],{"class":155,"line":156},[153,2129,205],{"class":204},[153,2131,2132],{"class":208}," hashlib\n",[153,2134,2135,2137,2140,2142],{"class":155,"line":212},[153,2136,215],{"class":204},[153,2138,2139],{"class":208}," datetime ",[153,2141,205],{"class":204},[153,2143,2144],{"class":208}," datetime, timezone\n",[153,2146,2147],{"class":155,"line":226},[153,2148,244],{"emptyLinePlaceholder":243},[153,2150,2151,2153,2156,2158,2160,2163,2165,2168,2170],{"class":155,"line":240},[153,2152,370],{"class":204},[153,2154,2155],{"class":355}," archive_key",[153,2157,1806],{"class":208},[153,2159,1809],{"class":259},[153,2161,2162],{"class":208},", user: ",[153,2164,1167],{"class":259},[153,2166,2167],{"class":208},") -> ",[153,2169,1167],{"class":259},[153,2171,538],{"class":208},[153,2173,2174,2177,2179,2182,2185],{"class":155,"line":247},[153,2175,2176],{"class":208},"    digest ",[153,2178,253],{"class":204},[153,2180,2181],{"class":208}," hashlib.sha256(payload).hexdigest()[:",[153,2183,2184],{"class":259},"16",[153,2186,435],{"class":208},[153,2188,2189,2192,2194,2197,2200,2203,2206],{"class":155,"line":266},[153,2190,2191],{"class":208},"    stamp ",[153,2193,253],{"class":204},[153,2195,2196],{"class":208}," datetime.now(timezone.utc).strftime(",[153,2198,2199],{"class":163},"\"%Y\u002F%m\u002F",[153,2201,2202],{"class":340},"%d",[153,2204,2205],{"class":163},"\u002F%H%M%S\"",[153,2207,263],{"class":208},[153,2209,2210,2212,2215,2218,2220,2223,2225,2228,2230,2233,2235,2237,2239,2242,2244],{"class":155,"line":297},[153,2211,656],{"class":204},[153,2213,2214],{"class":204}," f",[153,2216,2217],{"class":163},"\"uploads\u002F",[153,2219,558],{"class":340},[153,2221,2222],{"class":208},"user",[153,2224,567],{"class":340},[153,2226,2227],{"class":163},"\u002F",[153,2229,558],{"class":340},[153,2231,2232],{"class":208},"stamp",[153,2234,567],{"class":340},[153,2236,498],{"class":163},[153,2238,558],{"class":340},[153,2240,2241],{"class":208},"digest",[153,2243,567],{"class":340},[153,2245,2246],{"class":163},".xlsx\"\n",[10,2248,2249,2250,2253,2254,2257],{},"Including a content hash makes duplicate submissions obvious and gives every stored file a name that\ncannot collide, which a user-supplied filename certainly can. Never use the uploaded name as a path —\n",[150,2251,2252],{},"..\u002F..\u002Fetc\u002Fpasswd"," is a filename too, and ",[150,2255,2256],{},"werkzeug.utils.secure_filename"," exists precisely because\nthis mistake is so easy to make.",[138,2259,2261],{"id":2260},"performance-and-scale","Performance and scale",[20,2263,29,2269,29,2272,29,2275,29,2279,29,2284,29,2293,29,2299,29,2305,29,2309,29,2312,29,2318,29,2323,29,2327,29,2329,29,2332,29,2337,29,2341],{"viewBox":2264,"role":23,"ariaLabelledBy":2265,"xmlns":27,"style":2268},"0 0 720 196",[2266,2267],"up-async-t","up-async-d","width:100%;max-width:720px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[31,2270,2271],{"id":2266},"What a slow parse costs a synchronous endpoint",[35,2273,2274],{"id":2267},"Parsing inside the request holds a worker for the whole duration, while accepting the file, storing it and queueing the work returns in milliseconds and frees the worker immediately.",[39,2276],{"x":41,"y":41,"width":2277,"height":2278,"fill":44},"720","196",[61,2280,2283],{"x":1017,"y":2281,"style":2282},"56","font-size:12px;font-weight:600;fill:var(--text,#172033);text-anchor:start","parse in the request",[39,2285],{"x":2286,"y":2287,"width":2288,"height":2289,"rx":2290,"fill":2291,"stroke":2292},"200","40","378.5","26","6","#e7ebef","var(--line,#cdd5e6)",[39,2294],{"x":1014,"y":2295,"width":2296,"height":2297,"rx":2298,"fill":1021,"stroke":1022},"41","376.5","24","5",[61,2300,2304],{"x":2301,"y":2302,"style":2303},"590.5","58","font-size:12px;font-weight:700;fill:var(--accent,#d81b73);text-anchor:start","worker held",[61,2306,2308],{"x":1017,"y":2307,"style":2282},"100","parse with calamine",[39,2310],{"x":2286,"y":2311,"width":2288,"height":2289,"rx":2290,"fill":2291,"stroke":2292},"84",[39,2313],{"x":1014,"y":2314,"width":2315,"height":2297,"rx":2298,"fill":2316,"stroke":2317},"85","98.8","#fdefd8","var(--gold,#b4740a)",[61,2319,2322],{"x":2301,"y":2320,"style":2321},"102","font-size:12px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:start","still held",[61,2324,2326],{"x":1017,"y":2325,"style":2282},"144","store and enqueue",[39,2328],{"x":2286,"y":1074,"width":2288,"height":2289,"rx":2290,"fill":2291,"stroke":2292},[39,2330],{"x":1014,"y":2331,"width":48,"height":2297,"rx":2298,"fill":52,"stroke":53},"129",[61,2333,2336],{"x":2301,"y":2334,"style":2335},"146","font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:start","returns at once",[61,2338,2340],{"x":1017,"y":1017,"style":2339},"font-size:11.5px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:start","relative cost",[61,2342,2345],{"x":2343,"y":2344,"style":135},"360.0","186","the user polls a status URL instead of waiting on a connection",[10,2347,2348],{},"An upload endpoint's cost is dominated by parsing, not by the transfer, so the engine choice matters\nas much here as anywhere — and it matters more, because a slow parse holds a request thread. On a\nsynchronous framework a thirty-second parse is a worker occupied for thirty seconds.",[10,2350,2351],{},"For anything beyond a small file, the right shape is to accept the upload, store it, return\nimmediately, and process it in a background worker:",[143,2353,2355],{"className":195,"code":2354,"language":197,"meta":148,"style":148},"@api.post(\"\u002Fupload\")\nasync def upload(file: UploadFile = File(...)):\n    payload = await file.read()\n    key = archive_key(payload, user=\"ana\")\n    store(key, payload)\n    enqueue_processing(key)                  # Celery, RQ, a queue — anything\n    return {\"accepted\": True, \"key\": key, \"status_url\": f\"\u002Fuploads\u002F{key}\u002Fstatus\"}\n",[150,2356,2357,2367,2385,2397,2416,2421,2429],{"__ignoreMap":148},[153,2358,2359,2361,2363,2365],{"class":155,"line":156},[153,2360,768],{"class":355},[153,2362,359],{"class":208},[153,2364,362],{"class":163},[153,2366,263],{"class":208},[153,2368,2369,2371,2373,2375,2377,2379,2381,2383],{"class":155,"line":212},[153,2370,779],{"class":204},[153,2372,782],{"class":204},[153,2374,373],{"class":355},[153,2376,787],{"class":208},[153,2378,253],{"class":204},[153,2380,792],{"class":208},[153,2382,795],{"class":259},[153,2384,798],{"class":208},[153,2386,2387,2389,2391,2393,2395],{"class":155,"line":226},[153,2388,586],{"class":208},[153,2390,253],{"class":204},[153,2392,849],{"class":204},[153,2394,807],{"class":406},[153,2396,854],{"class":208},[153,2398,2399,2402,2404,2407,2409,2411,2414],{"class":155,"line":240},[153,2400,2401],{"class":208},"    key ",[153,2403,253],{"class":204},[153,2405,2406],{"class":208}," archive_key(payload, ",[153,2408,2222],{"class":406},[153,2410,253],{"class":204},[153,2412,2413],{"class":163},"\"ana\"",[153,2415,263],{"class":208},[153,2417,2418],{"class":155,"line":247},[153,2419,2420],{"class":208},"    store(key, payload)\n",[153,2422,2423,2426],{"class":155,"line":266},[153,2424,2425],{"class":208},"    enqueue_processing(key)                  ",[153,2427,2428],{"class":293},"# Celery, RQ, a queue — anything\n",[153,2430,2431,2433,2435,2438,2440,2442,2444,2447,2450,2453,2455,2457,2460,2462,2465,2467,2470],{"class":155,"line":297},[153,2432,656],{"class":204},[153,2434,311],{"class":208},[153,2436,2437],{"class":163},"\"accepted\"",[153,2439,974],{"class":208},[153,2441,1535],{"class":259},[153,2443,317],{"class":208},[153,2445,2446],{"class":163},"\"key\"",[153,2448,2449],{"class":208},": key, ",[153,2451,2452],{"class":163},"\"status_url\"",[153,2454,974],{"class":208},[153,2456,552],{"class":204},[153,2458,2459],{"class":163},"\"\u002Fuploads\u002F",[153,2461,558],{"class":340},[153,2463,2464],{"class":208},"key",[153,2466,567],{"class":340},[153,2468,2469],{"class":163},"\u002Fstatus\"",[153,2471,323],{"class":208},[10,2473,2474,2475,1554],{},"That turns a request that might take a minute into one that takes milliseconds, and it gives the user\nsomething to poll. It is the same reasoning that makes generated downloads asynchronous in\n",[14,2476,2478],{"href":2477},"\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",[138,2480,2482],{"id":2481},"conclusion","Conclusion",[10,2484,2485,2486,2489],{},"Limit the body size at the server, check the leading bytes rather than trusting the extension, parse\nfrom ",[150,2487,2488],{},"BytesIO"," for ordinary files and stream to a temporary file for large ones. Validate the columns\nand types in one pass and return every problem at once with sheet row numbers the user can find.\nStore the original under a hashed key rather than its submitted name, and move anything slow into a\nbackground worker so the request returns immediately.",[138,2491,2493],{"id":2492},"frequently-asked-questions","Frequently asked questions",[10,2495,2496,2500],{},[2497,2498,2499],"strong",{},"Should I save the upload to disk before reading it?","\nNot for a workbook that fits comfortably in memory — read the stream into BytesIO and parse that, which avoids a temp file and any cleanup question. Save to disk when the file is large, when you need to keep it, or when a virus scanner has to see it first.",[10,2502,2503,2506],{},[2497,2504,2505],{},"How do I stop someone uploading a 500 MB file?","\nSet a maximum content length at the framework level — MAX_CONTENT_LENGTH in Flask, a proxy limit in front of FastAPI — so the request is rejected before your code allocates anything. Checking the size after reading the body is too late.",[10,2508,2509,2512],{},[2497,2510,2511],{},"Is checking the extension enough validation?","\nNo. The extension is user-supplied text; check the file's leading bytes as well, since an .xlsx must start with the ZIP signature PK\\x03\\x04. That single check rejects renamed executables, HTML tables saved as .xls, and truncated uploads.",[10,2514,2515,2518],{},[2497,2516,2517],{},"What should the response tell the user?","\nWhich rows failed and why, not just that something failed. Returning the row numbers and the specific problem turns an upload feature into something people can actually use, and it costs one validation pass.",[138,2520,2522],{"id":2521},"related","Related",[2524,2525,2526,2533,2540,2545,2552],"ul",{},[2527,2528,2529,2530,2532],"li",{},"Up one level: ",[14,2531,17],{"href":16}," — the download side of the same application.",[2527,2534,2535,2539],{},[14,2536,2538],{"href":2537},"\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"," — sending a workbook back to the browser.",[2527,2541,2542,2544],{},[14,2543,2478],{"href":2477}," — the asynchronous download equivalent.",[2527,2546,2547,2551],{},[14,2548,2550],{"href":2549},"\u002Fadvanced-data-transformation-and-cleaning\u002Fvalidating-excel-data-with-python\u002Fvalidate-excel-columns-before-import-with-pandas\u002F","Validate Excel Columns Before Import with Pandas"," — the column contract this endpoint enforces.",[2527,2553,2554,2558],{},[14,2555,2557],{"href":2556},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-an-excel-workbook-in-memory-with-bytesio\u002F","Build an Excel Workbook in Memory with BytesIO"," — the same buffer, used in the other direction.",[2560,2561,2562],"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-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}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}",{"title":148,"searchDepth":212,"depth":212,"links":2564},[2565,2566,2567,2568,2569,2570,2571,2572,2573,2574,2575,2576,2577,2578],{"id":140,"depth":212,"text":141},{"id":191,"depth":212,"text":192},{"id":693,"depth":212,"text":694},{"id":998,"depth":212,"text":999},{"id":1434,"depth":212,"text":1435},{"id":1557,"depth":212,"text":1558},{"id":1767,"depth":212,"text":1768},{"id":1941,"depth":212,"text":1942},{"id":2009,"depth":212,"text":2010},{"id":2116,"depth":212,"text":2117},{"id":2260,"depth":212,"text":2261},{"id":2481,"depth":212,"text":2482},{"id":2492,"depth":212,"text":2493},{"id":2521,"depth":212,"text":2522},"2026-09-04","Receive a spreadsheet upload safely: body-size limits, magic-byte checks, BytesIO parsing, column validation with real sheet row numbers, and background processing for large files.","md",[2583,2585,2587,2589],{"q":2499,"a":2584},"Not for a workbook that fits comfortably in memory — read the stream into BytesIO and parse that, which avoids a temp file and any cleanup question. Save to disk when the file is large, when you need to keep it, or when a virus scanner has to see it first.",{"q":2505,"a":2586},"Set a maximum content length at the framework level — MAX_CONTENT_LENGTH in Flask, a proxy limit in front of FastAPI — so the request is rejected before your code allocates anything. Checking the size after reading the body is too late.",{"q":2511,"a":2588},"No. The extension is user-supplied text; check the file's leading bytes as well, since an .xlsx must start with the ZIP signature PK\\x03\\x04. That single check rejects renamed executables, HTML tables saved as .xls, and truncated uploads.",{"q":2517,"a":2590},"Which rows failed and why, not just that something failed. Returning the row numbers and the specific problem turns an upload feature into something people can actually use, and it costs one validation pass.",{"breadcrumb":2592},[2593,2595,2598],{"name":2594,"item":2227},"Home",{"name":2596,"item":2597},"Automating Reporting Workflows","\u002Fautomating-reporting-workflows\u002F",{"name":17,"item":16},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fhandle-an-uploaded-excel-file-in-flask-and-fastapi",{"title":5,"description":2601},"Accept .xlsx uploads in Flask and FastAPI — MAX_CONTENT_LENGTH, zip signature checks, BytesIO reads, 422 validation responses with sheet row numbers, and hashed archive keys.","handle-an-uploaded-excel-file-in-flask-and-fastapi","automating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fhandle-an-uploaded-excel-file-in-flask-and-fastapi\u002Findex","how-to","5tsZzCwYpaN2ilPAp6ausln1wrjB19pHR3eSZZ4C7ds",[2607,2611],{"title":2608,"path":2609,"stem":2610,"children":-1},"Export a Django Queryset to Excel","\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fexport-a-django-queryset-to-excel","automating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fexport-a-django-queryset-to-excel\u002Findex",{"title":2538,"path":2612,"stem":2613,"children":-1},"\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",1788710158963]