[{"data":1,"prerenderedAt":2187},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-a-streamlit-app-to-upload-and-download-excel":3,"surround:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-a-streamlit-app-to-upload-and-download-excel":2157,"ancestors:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-a-streamlit-app-to-upload-and-download-excel":2166,"children:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-a-streamlit-app-to-upload-and-download-excel":2171,"related:\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-a-streamlit-app-to-upload-and-download-excel":2172},{"id":4,"title":5,"body":6,"dateModified":2125,"datePublished":2125,"description":2126,"extension":2127,"faq":2128,"keyword":2137,"kind":2138,"meta":2139,"navigation":283,"path":2147,"seo":2148,"slug":2151,"stem":2152,"topics":2153,"type":2155,"__hash__":2156},"docs\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-a-streamlit-app-to-upload-and-download-excel\u002Findex.md","Build a Streamlit App to Upload and Download Excel Files",{"type":7,"value":8,"toc":2112},"minimark",[9,28,198,203,236,247,251,254,579,596,600,607,1027,1034,1132,1136,1139,1217,1234,1238,1241,1271,1290,1294,1300,1720,1726,1730,1737,1965,1974,1978,2033,2037,2049,2053,2059,2065,2071,2077,2081,2108],[10,11,12,13,17,18,23,24,27],"p",{},"Many Excel automation scripts end up used by people who do not run Python: a colleague who wants the monthly summary without asking you for it, a team that needs one cleaning step applied to a file. A small web app solves that, and Streamlit turns a script into one without HTML or JavaScript. The app below uploads a workbook, lets the user pick a sheet and columns, shows a summary and offers it as a new ",[14,15,16],"code",{},".xlsx",". This guide, part of ",[19,20,22],"a",{"href":21},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002F","Serving Excel Files from Python Web Apps",", also covers what makes such apps feel slow and how to test them. Every behaviour was checked with Streamlit's ",[14,25,26],{},"AppTest"," harness.",[29,30,38,39,38,43,38,47,38,55,38,65,38,76,38,83,38,88,38,94,38,99,38,102,38,108,38,112,38,115,38,119,38,122,38,127,38,134,38,140,38,144,38,148,38,152,38,155,38,158,38,164,38,168,38,171,38,175,38,178,38,183,38,190,38,195],"svg",{"viewBox":31,"role":32,"ariaLabelledBy":33,"xmlns":36,"style":37},"4 0 752 142","img",[34,35],"st-flow-t","st-flow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[40,41,42],"title",{"id":34},"One pass through the app",[44,45,46],"desc",{"id":35},"The user uploads a workbook. The app reads it once and caches the result by the file's bytes, then the user picks a sheet, a column to group by and a column to sum. The summary appears on the page, and the download button builds the Excel file only when it is clicked.",[48,49],"rect",{"x":50,"y":51,"width":52,"height":53,"fill":54},"4","0","752","142","#ffffff",[48,56],{"x":57,"y":58,"width":59,"height":60,"rx":61,"fill":62,"stroke":63,"style":64},"20.0","16.0","115.2","110.5","12","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[66,67,69],"g",{"transform":68},"translate(65.6,37.8) scale(1.000)",[70,71],"path",{"d":72,"fill":73,"stroke":74,"style":75},"M6 3h9l4 4v14H6z M15 3v4h4","none","var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[77,78,82],"text",{"x":79,"y":80,"style":81},"77.6","84.2","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Upload",[77,84,87],{"x":79,"y":85,"style":86},"103.8","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","st.file_uploader",[89,90],"line",{"x1":91,"y1":92,"x2":93,"y2":92,"stroke":63,"style":64},"140.2","71.2","162.2",[95,96],"polygon",{"points":97,"fill":98},"166.2,71.2 157.2,76.2 157.2,66.2","#5b5cf0",[48,100],{"x":101,"y":58,"width":59,"height":60,"rx":61,"fill":62,"stroke":63,"style":64},"171.2",[66,103,105],{"transform":104},"translate(216.8,37.8) scale(1.000)",[70,106],{"d":107,"fill":73,"stroke":74,"style":75},"M3 5h18v14H3z M3 10h18 M3 15h18 M9 5v14",[77,109,111],{"x":110,"y":80,"style":81},"228.8","Read once",[77,113,114],{"x":110,"y":85,"style":86},"st.cache_data",[89,116],{"x1":117,"y1":92,"x2":118,"y2":92,"stroke":63,"style":64},"291.4","313.4",[95,120],{"points":121,"fill":98},"317.4,71.2 308.4,76.2 308.4,66.2",[48,123],{"x":124,"y":58,"width":59,"height":60,"rx":61,"fill":125,"stroke":126,"style":64},"322.4","#fdefd8","var(--gold,#b4740a)",[66,128,130],{"transform":129},"translate(368.0,29.0) scale(1.000)",[70,131],{"d":132,"fill":73,"stroke":133,"style":75},"M3 5h18l-7 8v6l-4 2v-8z","var(--gold-ink,#7a4e06)",[77,135,139],{"x":136,"y":137,"style":138},"380.0","75.5","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Choose",[77,141,143],{"x":136,"y":142,"style":86},"95.0","sheet, group,",[77,145,147],{"x":136,"y":146,"style":86},"112.5","sum",[89,149],{"x1":150,"y1":92,"x2":151,"y2":92,"stroke":63,"style":64},"442.6","464.6",[95,153],{"points":154,"fill":98},"468.6,71.2 459.6,76.2 459.6,66.2",[48,156],{"x":157,"y":58,"width":59,"height":60,"rx":61,"fill":62,"stroke":63,"style":64},"473.6",[66,159,161],{"transform":160},"translate(519.2,37.8) scale(1.000)",[70,162],{"d":163,"fill":73,"stroke":74,"style":75},"M12 3c-4 0-4 1.6-4 3v2h4.2v1H6c-2 0-3 1.4-3 4s1 4 3 4h2v-2.6c0-1.6 1.3-2.9 2.9-2.9h4.2c1.3 0 2.4-1.1 2.4-2.4V6c0-1.6-1.8-3-5.5-3z M10 5.5h.01 M12 21c4 0 4-1.6 4-3v-2h-4.2v-1H18c2 0 3-1.4 3-4s-1-4-3-4h-2v2.6c0 1.6-1.3 2.9-2.9 2.9H8.9c-1.3 0-2.4 1.1-2.4 2.4V18c0 1.6 1.8 3 5.5 3z M14 18.5h.01",[77,165,167],{"x":166,"y":80,"style":81},"531.2","Summarise",[77,169,170],{"x":166,"y":85,"style":86},"pandas groupby",[89,172],{"x1":173,"y1":92,"x2":174,"y2":92,"stroke":63,"style":64},"593.8","615.8",[95,176],{"points":177,"fill":98},"619.8,71.2 610.8,76.2 610.8,66.2",[48,179],{"x":180,"y":58,"width":59,"height":60,"rx":61,"fill":181,"stroke":182,"style":64},"624.8","#d9f4f1","var(--teal,#0f9488)",[66,184,186],{"transform":185},"translate(670.4,37.8) scale(1.000)",[70,187],{"d":188,"fill":73,"stroke":189,"style":75},"M6 3h9l4 4v14H6z M15 3v4h4 M9.5 11.5l4.5 6 M14 11.5l-4.5 6","var(--teal-ink,#0b6157)",[77,191,194],{"x":192,"y":80,"style":193},"682.4","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Download",[77,196,197],{"x":192,"y":85,"style":86},"built on click",[199,200,202],"h2",{"id":201},"prerequisites","Prerequisites",[204,205,210],"pre",{"className":206,"code":207,"language":208,"meta":209,"style":209},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install streamlit pandas openpyxl pytest\n","bash","",[14,211,212],{"__ignoreMap":209},[213,214,216,220,224,227,230,233],"span",{"class":89,"line":215},1,[213,217,219],{"class":218},"sMTad","pip",[213,221,223],{"class":222},"srMev"," install",[213,225,226],{"class":222}," streamlit",[213,228,229],{"class":222}," pandas",[213,231,232],{"class":222}," openpyxl",[213,234,235],{"class":222}," pytest\n",[10,237,238,239,242,243,246],{},"Run the app with ",[14,240,241],{},"streamlit run app.py","; it opens in a browser at ",[14,244,245],{},"http:\u002F\u002Flocalhost:8501",".",[199,248,250],{"id":249},"step-1-put-the-excel-logic-in-plain-functions","Step 1 — Put the Excel logic in plain functions",[10,252,253],{},"Keep reading, processing and writing in ordinary functions that know nothing about the page. They are easier to test, and the same functions can serve a command-line script:",[204,255,259],{"className":256,"code":257,"language":258,"meta":209,"style":209},"language-python shiki shiki-themes github-light github-dark-high-contrast","# app.py\nimport io\n\nimport pandas as pd\nimport streamlit as st\n\nXLSX = \"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet\"\n\n@st.cache_data(show_spinner=False)\ndef read_workbook(data: bytes) -> dict[str, pd.DataFrame]:\n    return pd.read_excel(io.BytesIO(data), sheet_name=None)\n\ndef summarise(frame: pd.DataFrame, by: str, value: str) -> pd.DataFrame:\n    return (frame.groupby(by, as_index=False)[value].sum()\n                 .sort_values(value, ascending=False, ignore_index=True))\n\ndef to_xlsx(sheets: dict[str, pd.DataFrame]) -> bytes:\n    buffer = io.BytesIO()\n    with pd.ExcelWriter(buffer, engine=\"openpyxl\") as writer:\n        for name, frame in sheets.items():\n            frame.to_excel(writer, sheet_name=name, index=False)\n    return buffer.getvalue()\n","python",[14,260,261,267,278,285,299,312,317,330,335,358,382,401,406,427,445,472,477,498,509,534,549,571],{"__ignoreMap":209},[213,262,263],{"class":89,"line":215},[213,264,266],{"class":265},"s-wDw","# app.py\n",[213,268,270,274],{"class":89,"line":269},2,[213,271,273],{"class":272},"s-kum","import",[213,275,277],{"class":276},"skGVy"," io\n",[213,279,281],{"class":89,"line":280},3,[213,282,284],{"emptyLinePlaceholder":283},true,"\n",[213,286,288,290,293,296],{"class":89,"line":287},4,[213,289,273],{"class":272},[213,291,292],{"class":276}," pandas ",[213,294,295],{"class":272},"as",[213,297,298],{"class":276}," pd\n",[213,300,302,304,307,309],{"class":89,"line":301},5,[213,303,273],{"class":272},[213,305,306],{"class":276}," streamlit ",[213,308,295],{"class":272},[213,310,311],{"class":276}," st\n",[213,313,315],{"class":89,"line":314},6,[213,316,284],{"emptyLinePlaceholder":283},[213,318,320,324,327],{"class":89,"line":319},7,[213,321,323],{"class":322},"sP0c6","XLSX",[213,325,326],{"class":272}," =",[213,328,329],{"class":222}," \"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet\"\n",[213,331,333],{"class":89,"line":332},8,[213,334,284],{"emptyLinePlaceholder":283},[213,336,338,342,345,349,352,355],{"class":89,"line":337},9,[213,339,341],{"class":340},"s_Opv","@st.cache_data",[213,343,344],{"class":276},"(",[213,346,348],{"class":347},"sa561","show_spinner",[213,350,351],{"class":272},"=",[213,353,354],{"class":322},"False",[213,356,357],{"class":276},")\n",[213,359,361,364,367,370,373,376,379],{"class":89,"line":360},10,[213,362,363],{"class":272},"def",[213,365,366],{"class":340}," read_workbook",[213,368,369],{"class":276},"(data: ",[213,371,372],{"class":322},"bytes",[213,374,375],{"class":276},") -> dict[",[213,377,378],{"class":322},"str",[213,380,381],{"class":276},", pd.DataFrame]:\n",[213,383,385,388,391,394,396,399],{"class":89,"line":384},11,[213,386,387],{"class":272},"    return",[213,389,390],{"class":276}," pd.read_excel(io.BytesIO(data), ",[213,392,393],{"class":347},"sheet_name",[213,395,351],{"class":272},[213,397,398],{"class":322},"None",[213,400,357],{"class":276},[213,402,404],{"class":89,"line":403},12,[213,405,284],{"emptyLinePlaceholder":283},[213,407,409,411,414,417,419,422,424],{"class":89,"line":408},13,[213,410,363],{"class":272},[213,412,413],{"class":340}," summarise",[213,415,416],{"class":276},"(frame: pd.DataFrame, by: ",[213,418,378],{"class":322},[213,420,421],{"class":276},", value: ",[213,423,378],{"class":322},[213,425,426],{"class":276},") -> pd.DataFrame:\n",[213,428,430,432,435,438,440,442],{"class":89,"line":429},14,[213,431,387],{"class":272},[213,433,434],{"class":276}," (frame.groupby(by, ",[213,436,437],{"class":347},"as_index",[213,439,351],{"class":272},[213,441,354],{"class":322},[213,443,444],{"class":276},")[value].sum()\n",[213,446,448,451,454,456,458,461,464,466,469],{"class":89,"line":447},15,[213,449,450],{"class":276},"                 .sort_values(value, ",[213,452,453],{"class":347},"ascending",[213,455,351],{"class":272},[213,457,354],{"class":322},[213,459,460],{"class":276},", ",[213,462,463],{"class":347},"ignore_index",[213,465,351],{"class":272},[213,467,468],{"class":322},"True",[213,470,471],{"class":276},"))\n",[213,473,475],{"class":89,"line":474},16,[213,476,284],{"emptyLinePlaceholder":283},[213,478,480,482,485,488,490,493,495],{"class":89,"line":479},17,[213,481,363],{"class":272},[213,483,484],{"class":340}," to_xlsx",[213,486,487],{"class":276},"(sheets: dict[",[213,489,378],{"class":322},[213,491,492],{"class":276},", pd.DataFrame]) -> ",[213,494,372],{"class":322},[213,496,497],{"class":276},":\n",[213,499,501,504,506],{"class":89,"line":500},18,[213,502,503],{"class":276},"    buffer ",[213,505,351],{"class":272},[213,507,508],{"class":276}," io.BytesIO()\n",[213,510,512,515,518,521,523,526,529,531],{"class":89,"line":511},19,[213,513,514],{"class":272},"    with",[213,516,517],{"class":276}," pd.ExcelWriter(buffer, ",[213,519,520],{"class":347},"engine",[213,522,351],{"class":272},[213,524,525],{"class":222},"\"openpyxl\"",[213,527,528],{"class":276},") ",[213,530,295],{"class":272},[213,532,533],{"class":276}," writer:\n",[213,535,537,540,543,546],{"class":89,"line":536},20,[213,538,539],{"class":272},"        for",[213,541,542],{"class":276}," name, frame ",[213,544,545],{"class":272},"in",[213,547,548],{"class":276}," sheets.items():\n",[213,550,552,555,557,559,562,565,567,569],{"class":89,"line":551},21,[213,553,554],{"class":276},"            frame.to_excel(writer, ",[213,556,393],{"class":347},[213,558,351],{"class":272},[213,560,561],{"class":276},"name, ",[213,563,564],{"class":347},"index",[213,566,351],{"class":272},[213,568,354],{"class":322},[213,570,357],{"class":276},[213,572,574,576],{"class":89,"line":573},22,[213,575,387],{"class":272},[213,577,578],{"class":276}," buffer.getvalue()\n",[10,580,581,584,585,588,589,591,592,595],{},[14,582,583],{},"read_workbook"," takes the file's ",[586,587,372],"em",{}," rather than the upload object. ",[14,590,114],{}," keys its cache on the arguments, so the same file uploaded again — or the same file during the many reruns described below — is read only once. ",[14,593,594],{},"sheet_name=None"," reads every sheet into a dictionary, which lets the user choose between them.",[199,597,599],{"id":598},"step-2-build-the-page","Step 2 — Build the page",[10,601,602,603,606],{},"A Streamlit script runs from top to bottom on every interaction. ",[14,604,605],{},"st.stop()"," ends a run early, which keeps the \"nothing uploaded yet\" and \"unreadable file\" paths short and readable:",[204,608,610],{"className":256,"code":609,"language":258,"meta":209,"style":209},"st.title(\"Sales summariser\")\nuploaded = st.file_uploader(\"Upload a sales workbook\", type=[\"xlsx\"])\nif uploaded is None:\n    st.info(\"Upload an .xlsx file to begin.\")\n    st.stop()\n\ntry:\n    sheets = read_workbook(uploaded.getvalue())\nexcept Exception as error:\n    st.error(f\"Could not read {uploaded.name} as an Excel workbook: {error}\")\n    st.stop()\n\nsheet = st.selectbox(\"Sheet\", list(sheets))\nframe = sheets[sheet]\nst.dataframe(frame.head(50))\n\nnumeric = frame.select_dtypes(\"number\").columns.tolist()\ntext = frame.select_dtypes(exclude=\"number\").columns.tolist()\nif not numeric or not text:\n    st.warning(\"The sheet needs at least one text column and one number column.\")\n    st.stop()\nby = st.selectbox(\"Group by\", text)\nvalue = st.selectbox(\"Sum of\", numeric)\nsummary = summarise(frame, by, value)\nst.dataframe(summary)\n\nst.download_button(\n    \"Download summary.xlsx\",\n    data=lambda: to_xlsx({\"Summary\": summary, \"Data\": frame}),   # built on click\n    on_click=\"ignore\",\n    file_name=f\"{uploaded.name.removesuffix('.xlsx')}_summary.xlsx\",\n    mime=XLSX,\n)\n",[14,611,612,622,651,667,677,682,686,693,703,717,753,757,761,782,792,802,806,822,840,858,868,872,887,903,914,920,925,931,940,967,980,1010,1022],{"__ignoreMap":209},[213,613,614,617,620],{"class":89,"line":215},[213,615,616],{"class":276},"st.title(",[213,618,619],{"class":222},"\"Sales summariser\"",[213,621,357],{"class":276},[213,623,624,627,629,632,635,637,640,642,645,648],{"class":89,"line":269},[213,625,626],{"class":276},"uploaded ",[213,628,351],{"class":272},[213,630,631],{"class":276}," st.file_uploader(",[213,633,634],{"class":222},"\"Upload a sales workbook\"",[213,636,460],{"class":276},[213,638,639],{"class":347},"type",[213,641,351],{"class":272},[213,643,644],{"class":276},"[",[213,646,647],{"class":222},"\"xlsx\"",[213,649,650],{"class":276},"])\n",[213,652,653,656,659,662,665],{"class":89,"line":280},[213,654,655],{"class":272},"if",[213,657,658],{"class":276}," uploaded ",[213,660,661],{"class":272},"is",[213,663,664],{"class":322}," None",[213,666,497],{"class":276},[213,668,669,672,675],{"class":89,"line":287},[213,670,671],{"class":276},"    st.info(",[213,673,674],{"class":222},"\"Upload an .xlsx file to begin.\"",[213,676,357],{"class":276},[213,678,679],{"class":89,"line":301},[213,680,681],{"class":276},"    st.stop()\n",[213,683,684],{"class":89,"line":314},[213,685,284],{"emptyLinePlaceholder":283},[213,687,688,691],{"class":89,"line":319},[213,689,690],{"class":272},"try",[213,692,497],{"class":276},[213,694,695,698,700],{"class":89,"line":332},[213,696,697],{"class":276},"    sheets ",[213,699,351],{"class":272},[213,701,702],{"class":276}," read_workbook(uploaded.getvalue())\n",[213,704,705,708,711,714],{"class":89,"line":337},[213,706,707],{"class":272},"except",[213,709,710],{"class":322}," Exception",[213,712,713],{"class":272}," as",[213,715,716],{"class":276}," error:\n",[213,718,719,722,725,728,732,735,738,741,743,746,748,751],{"class":89,"line":360},[213,720,721],{"class":276},"    st.error(",[213,723,724],{"class":272},"f",[213,726,727],{"class":222},"\"Could not read ",[213,729,731],{"class":730},"sSjpA","{",[213,733,734],{"class":276},"uploaded.name",[213,736,737],{"class":730},"}",[213,739,740],{"class":222}," as an Excel workbook: ",[213,742,731],{"class":730},[213,744,745],{"class":276},"error",[213,747,737],{"class":730},[213,749,750],{"class":222},"\"",[213,752,357],{"class":276},[213,754,755],{"class":89,"line":384},[213,756,681],{"class":276},[213,758,759],{"class":89,"line":403},[213,760,284],{"emptyLinePlaceholder":283},[213,762,763,766,768,771,774,776,779],{"class":89,"line":408},[213,764,765],{"class":276},"sheet ",[213,767,351],{"class":272},[213,769,770],{"class":276}," st.selectbox(",[213,772,773],{"class":222},"\"Sheet\"",[213,775,460],{"class":276},[213,777,778],{"class":322},"list",[213,780,781],{"class":276},"(sheets))\n",[213,783,784,787,789],{"class":89,"line":429},[213,785,786],{"class":276},"frame ",[213,788,351],{"class":272},[213,790,791],{"class":276}," sheets[sheet]\n",[213,793,794,797,800],{"class":89,"line":447},[213,795,796],{"class":276},"st.dataframe(frame.head(",[213,798,799],{"class":322},"50",[213,801,471],{"class":276},[213,803,804],{"class":89,"line":474},[213,805,284],{"emptyLinePlaceholder":283},[213,807,808,811,813,816,819],{"class":89,"line":479},[213,809,810],{"class":276},"numeric ",[213,812,351],{"class":272},[213,814,815],{"class":276}," frame.select_dtypes(",[213,817,818],{"class":222},"\"number\"",[213,820,821],{"class":276},").columns.tolist()\n",[213,823,824,827,829,831,834,836,838],{"class":89,"line":500},[213,825,826],{"class":276},"text ",[213,828,351],{"class":272},[213,830,815],{"class":276},[213,832,833],{"class":347},"exclude",[213,835,351],{"class":272},[213,837,818],{"class":222},[213,839,821],{"class":276},[213,841,842,844,847,850,853,855],{"class":89,"line":511},[213,843,655],{"class":272},[213,845,846],{"class":272}," not",[213,848,849],{"class":276}," numeric ",[213,851,852],{"class":272},"or",[213,854,846],{"class":272},[213,856,857],{"class":276}," text:\n",[213,859,860,863,866],{"class":89,"line":536},[213,861,862],{"class":276},"    st.warning(",[213,864,865],{"class":222},"\"The sheet needs at least one text column and one number column.\"",[213,867,357],{"class":276},[213,869,870],{"class":89,"line":551},[213,871,681],{"class":276},[213,873,874,877,879,881,884],{"class":89,"line":573},[213,875,876],{"class":276},"by ",[213,878,351],{"class":272},[213,880,770],{"class":276},[213,882,883],{"class":222},"\"Group by\"",[213,885,886],{"class":276},", text)\n",[213,888,890,893,895,897,900],{"class":89,"line":889},23,[213,891,892],{"class":276},"value ",[213,894,351],{"class":272},[213,896,770],{"class":276},[213,898,899],{"class":222},"\"Sum of\"",[213,901,902],{"class":276},", numeric)\n",[213,904,906,909,911],{"class":89,"line":905},24,[213,907,908],{"class":276},"summary ",[213,910,351],{"class":272},[213,912,913],{"class":276}," summarise(frame, by, value)\n",[213,915,917],{"class":89,"line":916},25,[213,918,919],{"class":276},"st.dataframe(summary)\n",[213,921,923],{"class":89,"line":922},26,[213,924,284],{"emptyLinePlaceholder":283},[213,926,928],{"class":89,"line":927},27,[213,929,930],{"class":276},"st.download_button(\n",[213,932,934,937],{"class":89,"line":933},28,[213,935,936],{"class":222},"    \"Download summary.xlsx\"",[213,938,939],{"class":276},",\n",[213,941,943,946,949,952,955,958,961,964],{"class":89,"line":942},29,[213,944,945],{"class":347},"    data",[213,947,948],{"class":272},"=lambda",[213,950,951],{"class":276},": to_xlsx({",[213,953,954],{"class":222},"\"Summary\"",[213,956,957],{"class":276},": summary, ",[213,959,960],{"class":222},"\"Data\"",[213,962,963],{"class":276},": frame}),   ",[213,965,966],{"class":265},"# built on click\n",[213,968,970,973,975,978],{"class":89,"line":969},30,[213,971,972],{"class":347},"    on_click",[213,974,351],{"class":272},[213,976,977],{"class":222},"\"ignore\"",[213,979,939],{"class":276},[213,981,983,986,988,990,992,994,997,1000,1003,1005,1008],{"class":89,"line":982},31,[213,984,985],{"class":347},"    file_name",[213,987,351],{"class":272},[213,989,724],{"class":272},[213,991,750],{"class":222},[213,993,731],{"class":730},[213,995,996],{"class":276},"uploaded.name.removesuffix(",[213,998,999],{"class":222},"'.xlsx'",[213,1001,1002],{"class":276},")",[213,1004,737],{"class":730},[213,1006,1007],{"class":222},"_summary.xlsx\"",[213,1009,939],{"class":276},[213,1011,1013,1016,1018,1020],{"class":89,"line":1012},32,[213,1014,1015],{"class":347},"    mime",[213,1017,351],{"class":272},[213,1019,323],{"class":322},[213,1021,939],{"class":276},[213,1023,1025],{"class":89,"line":1024},33,[213,1026,357],{"class":276},[10,1028,1029,1030,1033],{},"The column choices come from the sheet's own types, so the app adapts to whatever workbook is uploaded, and a sheet without suitable columns gets a warning instead of a traceback. The upload is read from memory — ",[14,1031,1032],{},"uploaded.getvalue()"," returns its bytes — so nothing is written to the server's disk.",[29,1035,38,1041,38,1044,38,1047,38,1052,38,1057,38,1061,38,1064,38,1067,38,1070,38,1076,38,1080,38,1084,38,1087,38,1092,38,1096,38,1100,38,1103,38,1108,38,1112,38,1116,38,1119,38,1124,38,1128],{"viewBox":1036,"role":32,"ariaLabelledBy":1037,"xmlns":36,"style":1040},"84 0 592 537",[1038,1039],"st-layout-t","st-layout-d","width:100%;max-width:592px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[40,1042,1043],{"id":1038},"What the user sees, top to bottom",[44,1045,1046],{"id":1039},"The page has a title, the upload box, a sheet selector and a preview of the first 50 rows, then two selectors for the grouping and summed columns, the summary table, and a download button for the result as an Excel workbook with Summary and Data sheets.",[48,1048],{"x":1049,"y":51,"width":1050,"height":1051,"fill":54},"84","592","537",[48,1053],{"x":1054,"y":58,"width":1055,"height":1056,"rx":61,"fill":62,"stroke":63,"style":64},"100.0","560.0","93.0",[66,1058,1059],{"transform":129},[70,1060],{"d":72,"fill":73,"stroke":74,"style":75},[77,1062,1063],{"x":136,"y":137,"style":81},"Upload a sales workbook",[77,1065,1066],{"x":136,"y":142,"style":86},".xlsx only",[48,1068],{"x":1054,"y":1069,"width":1055,"height":1056,"rx":61,"fill":62,"stroke":63,"style":64},"119.0",[66,1071,1073],{"transform":1072},"translate(368.0,132.0) scale(1.000)",[70,1074],{"d":1075,"fill":73,"stroke":74,"style":75},"M4 4h16v16H4z M4 9h16 M4 14h16 M10 4v16",[77,1077,1079],{"x":136,"y":1078,"style":81},"178.5","Sheet + preview",[77,1081,1083],{"x":136,"y":1082,"style":86},"198.0","first 50 rows",[48,1085],{"x":1054,"y":1086,"width":1055,"height":1056,"rx":61,"fill":125,"stroke":126,"style":64},"222.0",[66,1088,1090],{"transform":1089},"translate(368.0,235.0) scale(1.000)",[70,1091],{"d":132,"fill":73,"stroke":133,"style":75},[77,1093,1095],{"x":136,"y":1094,"style":138},"281.5","Group by \u002F Sum of",[77,1097,1099],{"x":136,"y":1098,"style":86},"301.0","from the sheet's column types",[48,1101],{"x":1054,"y":1102,"width":1055,"height":1056,"rx":61,"fill":62,"stroke":63,"style":64},"325.0",[66,1104,1106],{"transform":1105},"translate(368.0,338.0) scale(1.000)",[70,1107],{"d":107,"fill":73,"stroke":74,"style":75},[77,1109,1111],{"x":136,"y":1110,"style":81},"384.5","Summary table",[77,1113,1115],{"x":136,"y":1114,"style":86},"404.0","sorted, largest first",[48,1117],{"x":1054,"y":1118,"width":1055,"height":1056,"rx":61,"fill":181,"stroke":182,"style":64},"428.0",[66,1120,1122],{"transform":1121},"translate(368.0,441.0) scale(1.000)",[70,1123],{"d":188,"fill":73,"stroke":189,"style":75},[77,1125,1127],{"x":136,"y":1126,"style":193},"487.5","Download summary.xlsx",[77,1129,1131],{"x":136,"y":1130,"style":86},"507.0","Summary and Data sheets",[199,1133,1135],{"id":1134},"step-3-keep-reruns-fast","Step 3 — Keep reruns fast",[10,1137,1138],{},"Because every widget change reruns the script, anything expensive in it happens again and again. Two things dominate an Excel app: parsing the upload and building the download. With a 50,000-row workbook, changing one selector took:",[29,1140,38,1146,38,1149,38,1152,38,1156,38,1164,38,1170,38,1177,38,1182,38,1185,38,1189,38,1192,38,1197,38,1200,38,1204,38,1207,38,1212],{"viewBox":1141,"role":32,"ariaLabelledBy":1142,"xmlns":36,"style":1145},"4 0 743 166",[1143,1144],"st-rerun-t","st-rerun-d","width:100%;max-width:743px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[40,1147,1148],{"id":1143},"Time to rerun after changing one selector, 50,000-row upload",[44,1150,1151],{"id":1144},"Measured with Streamlit AppTest. Building the download file eagerly on every run made each rerun take 1.33 seconds. Without caching the workbook read, a rerun took 1.01 seconds. With the read cached and the download built only when clicked, a rerun took 0.01 seconds.",[48,1153],{"x":50,"y":51,"width":1154,"height":1155,"fill":54},"743","166",[48,1157],{"x":57,"y":58,"width":1158,"height":1159,"rx":1160,"fill":1161,"stroke":1162,"style":1163},"230.7","28.0","7","#e7ebef","var(--line,#cdd5e6)","stroke-width:1px",[77,1165,1169],{"x":1166,"y":1167,"style":1168},"135.4","34.5","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","download built every run",[48,1171],{"x":1172,"y":58,"width":1173,"height":1159,"rx":1160,"fill":1174,"stroke":1175,"style":1176},"260.7","426.6","#fce9e9","var(--danger,#dc2626)","stroke-width:1.5px",[77,1178,1181],{"x":1179,"y":1167,"style":1180},"695.3","font-size:12px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:start","1.33 s",[48,1183],{"x":57,"y":1184,"width":1158,"height":1159,"rx":1160,"fill":1161,"stroke":1162,"style":1163},"56.0",[77,1186,1188],{"x":1166,"y":1187,"style":1168},"74.5","read not cached",[48,1190],{"x":1172,"y":1184,"width":1191,"height":1159,"rx":1160,"fill":125,"stroke":126,"style":1176},"324.0",[77,1193,1196],{"x":1194,"y":1187,"style":1195},"592.7","font-size:12px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:start","1.01 s",[48,1198],{"x":57,"y":1199,"width":1158,"height":1159,"rx":1160,"fill":1161,"stroke":1162,"style":1163},"96.0",[77,1201,1203],{"x":1166,"y":1202,"style":1168},"114.5","cached read, download on click",[48,1205],{"x":1172,"y":1199,"width":1206,"height":1159,"rx":1160,"fill":181,"stroke":182,"style":1176},"6.0",[77,1208,1211],{"x":1209,"y":1202,"style":1210},"274.7","font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:start","0.01 s",[77,1213,1216],{"x":136,"y":1214,"style":1215},"146.0","font-size:12.5px;font-weight:400;fill:var(--muted,#5b6780);text-anchor:middle","parse once, build the file only when someone asks for it",[10,1218,1219,1221,1222,1225,1226,1229,1230,1233],{},[14,1220,114],{}," handles the parsing. For the download, recent Streamlit versions accept a function as ",[14,1223,1224],{},"data","; it runs only when the button is clicked, which the ",[14,1227,1228],{},"lambda"," in Step 2 uses. ",[14,1231,1232],{},"on_click=\"ignore\""," stops the click from triggering yet another rerun. On older versions, cache the conversion function instead, so it runs once per distinct result.",[199,1235,1237],{"id":1236},"step-4-set-limits-and-handle-bad-files","Step 4 — Set limits and handle bad files",[10,1239,1240],{},"Streamlit accepts uploads up to 200 MB by default. A workbook that size can take minutes and gigabytes of memory to parse, so set the limit to what the app handles comfortably:",[204,1242,1246],{"className":1243,"code":1244,"language":1245,"meta":209,"style":209},"language-toml shiki shiki-themes github-light github-dark-high-contrast","# .streamlit\u002Fconfig.toml\n[server]\nmaxUploadSize = 25\n","toml",[14,1247,1248,1253,1263],{"__ignoreMap":209},[213,1249,1250],{"class":89,"line":215},[213,1251,1252],{"class":265},"# .streamlit\u002Fconfig.toml\n",[213,1254,1255,1257,1260],{"class":89,"line":269},[213,1256,644],{"class":276},[213,1258,1259],{"class":218},"server",[213,1261,1262],{"class":276},"]\n",[213,1264,1265,1268],{"class":89,"line":280},[213,1266,1267],{"class":276},"maxUploadSize = ",[213,1269,1270],{"class":322},"25\n",[10,1272,1273,1276,1277,1279,1280,1282,1283,1285,1286,246],{},[14,1274,1275],{},"type=[\"xlsx\"]"," filters the file picker, but it checks the extension, not the contents. A text file renamed to ",[14,1278,16],{}," still arrives, which is why the read is wrapped in ",[14,1281,690],{},"\u002F",[14,1284,707],{},": the user sees \"Could not read notes.xlsx as an Excel workbook\" instead of a stack trace. Deeper checks — expected columns, types, row limits — are covered in ",[19,1287,1289],{"href":1288},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fvalidate-an-uploaded-excel-file-in-a-web-app\u002F","Validate an Uploaded Excel File Before Processing It in a Web App",[199,1291,1293],{"id":1292},"step-5-test-the-app-without-a-browser","Step 5 — Test the app without a browser",[10,1295,1296,1299],{},[14,1297,1298],{},"streamlit.testing.v1.AppTest"," runs the script headlessly, lets tests set widget values — including uploads — and exposes what the page would show:",[204,1301,1303],{"className":256,"code":1302,"language":258,"meta":209,"style":209},"# test_app.py — run with pytest\nimport io\n\nimport pandas as pd\nfrom streamlit.testing.v1 import AppTest\n\nXLSX = \"application\u002Fvnd.openxmlformats-officedocument.spreadsheetml.sheet\"\n\ndef workbook_bytes():\n    buffer = io.BytesIO()\n    pd.DataFrame({\"region\": [\"North\", \"South\", \"North\", \"East\"],\n                  \"amount\": [100.0, 40.0, 60.0, 25.0]}).to_excel(buffer, index=False, sheet_name=\"Orders\")\n    return buffer.getvalue()\n\ndef test_asks_for_a_file():\n    at = AppTest.from_file(\"app.py\").run()\n    assert at.info[0].value == \"Upload an .xlsx file to begin.\"\n\ndef test_summarises_upload():\n    at = AppTest.from_file(\"app.py\").run()\n    at.file_uploader[0].set_value((\"sales.xlsx\", workbook_bytes(), XLSX)).run()\n    assert not at.exception\n    summary = at.dataframe[1].value\n    assert summary[\"region\"].tolist() == [\"North\", \"South\", \"East\"]\n    assert summary[\"amount\"].tolist() == [160, 40, 25]\n    assert at.download_button[0].label == \"Download summary.xlsx\"\n\ndef test_rejects_a_non_workbook():\n    at = AppTest.from_file(\"app.py\").run()\n    at.file_uploader[0].set_value((\"notes.xlsx\", b\"not a workbook\", XLSX)).run()\n    assert at.error[0].value.startswith(\"Could not read notes.xlsx\")\n",[14,1304,1305,1310,1316,1320,1330,1343,1347,1355,1359,1369,1377,1408,1452,1458,1462,1471,1487,1506,1510,1519,1531,1552,1561,1577,1606,1636,1653,1657,1666,1678,1703],{"__ignoreMap":209},[213,1306,1307],{"class":89,"line":215},[213,1308,1309],{"class":265},"# test_app.py — run with pytest\n",[213,1311,1312,1314],{"class":89,"line":269},[213,1313,273],{"class":272},[213,1315,277],{"class":276},[213,1317,1318],{"class":89,"line":280},[213,1319,284],{"emptyLinePlaceholder":283},[213,1321,1322,1324,1326,1328],{"class":89,"line":287},[213,1323,273],{"class":272},[213,1325,292],{"class":276},[213,1327,295],{"class":272},[213,1329,298],{"class":276},[213,1331,1332,1335,1338,1340],{"class":89,"line":301},[213,1333,1334],{"class":272},"from",[213,1336,1337],{"class":276}," streamlit.testing.v1 ",[213,1339,273],{"class":272},[213,1341,1342],{"class":276}," AppTest\n",[213,1344,1345],{"class":89,"line":314},[213,1346,284],{"emptyLinePlaceholder":283},[213,1348,1349,1351,1353],{"class":89,"line":319},[213,1350,323],{"class":322},[213,1352,326],{"class":272},[213,1354,329],{"class":222},[213,1356,1357],{"class":89,"line":332},[213,1358,284],{"emptyLinePlaceholder":283},[213,1360,1361,1363,1366],{"class":89,"line":337},[213,1362,363],{"class":272},[213,1364,1365],{"class":340}," workbook_bytes",[213,1367,1368],{"class":276},"():\n",[213,1370,1371,1373,1375],{"class":89,"line":360},[213,1372,503],{"class":276},[213,1374,351],{"class":272},[213,1376,508],{"class":276},[213,1378,1379,1382,1385,1388,1391,1393,1396,1398,1400,1402,1405],{"class":89,"line":384},[213,1380,1381],{"class":276},"    pd.DataFrame({",[213,1383,1384],{"class":222},"\"region\"",[213,1386,1387],{"class":276},": [",[213,1389,1390],{"class":222},"\"North\"",[213,1392,460],{"class":276},[213,1394,1395],{"class":222},"\"South\"",[213,1397,460],{"class":276},[213,1399,1390],{"class":222},[213,1401,460],{"class":276},[213,1403,1404],{"class":222},"\"East\"",[213,1406,1407],{"class":276},"],\n",[213,1409,1410,1413,1415,1417,1419,1422,1424,1427,1429,1432,1435,1437,1439,1441,1443,1445,1447,1450],{"class":89,"line":403},[213,1411,1412],{"class":222},"                  \"amount\"",[213,1414,1387],{"class":276},[213,1416,1054],{"class":322},[213,1418,460],{"class":276},[213,1420,1421],{"class":322},"40.0",[213,1423,460],{"class":276},[213,1425,1426],{"class":322},"60.0",[213,1428,460],{"class":276},[213,1430,1431],{"class":322},"25.0",[213,1433,1434],{"class":276},"]}).to_excel(buffer, ",[213,1436,564],{"class":347},[213,1438,351],{"class":272},[213,1440,354],{"class":322},[213,1442,460],{"class":276},[213,1444,393],{"class":347},[213,1446,351],{"class":272},[213,1448,1449],{"class":222},"\"Orders\"",[213,1451,357],{"class":276},[213,1453,1454,1456],{"class":89,"line":408},[213,1455,387],{"class":272},[213,1457,578],{"class":276},[213,1459,1460],{"class":89,"line":429},[213,1461,284],{"emptyLinePlaceholder":283},[213,1463,1464,1466,1469],{"class":89,"line":447},[213,1465,363],{"class":272},[213,1467,1468],{"class":340}," test_asks_for_a_file",[213,1470,1368],{"class":276},[213,1472,1473,1476,1478,1481,1484],{"class":89,"line":474},[213,1474,1475],{"class":276},"    at ",[213,1477,351],{"class":272},[213,1479,1480],{"class":276}," AppTest.from_file(",[213,1482,1483],{"class":222},"\"app.py\"",[213,1485,1486],{"class":276},").run()\n",[213,1488,1489,1492,1495,1497,1500,1503],{"class":89,"line":479},[213,1490,1491],{"class":272},"    assert",[213,1493,1494],{"class":276}," at.info[",[213,1496,51],{"class":322},[213,1498,1499],{"class":276},"].value ",[213,1501,1502],{"class":272},"==",[213,1504,1505],{"class":222}," \"Upload an .xlsx file to begin.\"\n",[213,1507,1508],{"class":89,"line":500},[213,1509,284],{"emptyLinePlaceholder":283},[213,1511,1512,1514,1517],{"class":89,"line":511},[213,1513,363],{"class":272},[213,1515,1516],{"class":340}," test_summarises_upload",[213,1518,1368],{"class":276},[213,1520,1521,1523,1525,1527,1529],{"class":89,"line":536},[213,1522,1475],{"class":276},[213,1524,351],{"class":272},[213,1526,1480],{"class":276},[213,1528,1483],{"class":222},[213,1530,1486],{"class":276},[213,1532,1533,1536,1538,1541,1544,1547,1549],{"class":89,"line":551},[213,1534,1535],{"class":276},"    at.file_uploader[",[213,1537,51],{"class":322},[213,1539,1540],{"class":276},"].set_value((",[213,1542,1543],{"class":222},"\"sales.xlsx\"",[213,1545,1546],{"class":276},", workbook_bytes(), ",[213,1548,323],{"class":322},[213,1550,1551],{"class":276},")).run()\n",[213,1553,1554,1556,1558],{"class":89,"line":573},[213,1555,1491],{"class":272},[213,1557,846],{"class":272},[213,1559,1560],{"class":276}," at.exception\n",[213,1562,1563,1566,1568,1571,1574],{"class":89,"line":889},[213,1564,1565],{"class":276},"    summary ",[213,1567,351],{"class":272},[213,1569,1570],{"class":276}," at.dataframe[",[213,1572,1573],{"class":322},"1",[213,1575,1576],{"class":276},"].value\n",[213,1578,1579,1581,1584,1586,1589,1591,1594,1596,1598,1600,1602,1604],{"class":89,"line":905},[213,1580,1491],{"class":272},[213,1582,1583],{"class":276}," summary[",[213,1585,1384],{"class":222},[213,1587,1588],{"class":276},"].tolist() ",[213,1590,1502],{"class":272},[213,1592,1593],{"class":276}," [",[213,1595,1390],{"class":222},[213,1597,460],{"class":276},[213,1599,1395],{"class":222},[213,1601,460],{"class":276},[213,1603,1404],{"class":222},[213,1605,1262],{"class":276},[213,1607,1608,1610,1612,1615,1617,1619,1621,1624,1626,1629,1631,1634],{"class":89,"line":916},[213,1609,1491],{"class":272},[213,1611,1583],{"class":276},[213,1613,1614],{"class":222},"\"amount\"",[213,1616,1588],{"class":276},[213,1618,1502],{"class":272},[213,1620,1593],{"class":276},[213,1622,1623],{"class":322},"160",[213,1625,460],{"class":276},[213,1627,1628],{"class":322},"40",[213,1630,460],{"class":276},[213,1632,1633],{"class":322},"25",[213,1635,1262],{"class":276},[213,1637,1638,1640,1643,1645,1648,1650],{"class":89,"line":922},[213,1639,1491],{"class":272},[213,1641,1642],{"class":276}," at.download_button[",[213,1644,51],{"class":322},[213,1646,1647],{"class":276},"].label ",[213,1649,1502],{"class":272},[213,1651,1652],{"class":222}," \"Download summary.xlsx\"\n",[213,1654,1655],{"class":89,"line":927},[213,1656,284],{"emptyLinePlaceholder":283},[213,1658,1659,1661,1664],{"class":89,"line":933},[213,1660,363],{"class":272},[213,1662,1663],{"class":340}," test_rejects_a_non_workbook",[213,1665,1368],{"class":276},[213,1667,1668,1670,1672,1674,1676],{"class":89,"line":942},[213,1669,1475],{"class":276},[213,1671,351],{"class":272},[213,1673,1480],{"class":276},[213,1675,1483],{"class":222},[213,1677,1486],{"class":276},[213,1679,1680,1682,1684,1686,1689,1691,1694,1697,1699,1701],{"class":89,"line":969},[213,1681,1535],{"class":276},[213,1683,51],{"class":322},[213,1685,1540],{"class":276},[213,1687,1688],{"class":222},"\"notes.xlsx\"",[213,1690,460],{"class":276},[213,1692,1693],{"class":272},"b",[213,1695,1696],{"class":222},"\"not a workbook\"",[213,1698,460],{"class":276},[213,1700,323],{"class":322},[213,1702,1551],{"class":276},[213,1704,1705,1707,1710,1712,1715,1718],{"class":89,"line":982},[213,1706,1491],{"class":272},[213,1708,1709],{"class":276}," at.error[",[213,1711,51],{"class":322},[213,1713,1714],{"class":276},"].value.startswith(",[213,1716,1717],{"class":222},"\"Could not read notes.xlsx\"",[213,1719,357],{"class":276},[10,1721,1722,1725],{},[14,1723,1724],{},"pytest"," ran all three in about 1.3 seconds. Tests like these catch the common breakages: a renamed column, a widget that moved, an error path that raises instead of showing a message.",[199,1727,1729],{"id":1728},"step-6-accept-several-workbooks-at-once","Step 6 — Accept several workbooks at once",[10,1731,1732,1733,1736],{},"A common next request is \"can I drop in all the regional files together?\". ",[14,1734,1735],{},"accept_multiple_files=True"," turns the upload into a list, and each file can be read through the same cached function and tagged with its name before combining:",[204,1738,1740],{"className":256,"code":1739,"language":258,"meta":209,"style":209},"import io\n\nimport pandas as pd\nimport streamlit as st\n\n@st.cache_data(show_spinner=False)\ndef read_first_sheet(data: bytes) -> pd.DataFrame:\n    return pd.read_excel(io.BytesIO(data))\n\nfiles = st.file_uploader(\"Upload regional workbooks\", type=[\"xlsx\"], accept_multiple_files=True)\nif not files:\n    st.stop()\ncombined = pd.concat(\n    [read_first_sheet(f.getvalue()).assign(source=f.name) for f in files],\n    ignore_index=True,\n)\nst.caption(f\"{len(files)} files, {len(combined):,} rows\")\nst.dataframe(combined.groupby(\"source\")[\"amount\"].sum())\n",[14,1741,1742,1748,1752,1762,1772,1776,1790,1803,1810,1814,1848,1857,1861,1871,1895,1906,1910,1949],{"__ignoreMap":209},[213,1743,1744,1746],{"class":89,"line":215},[213,1745,273],{"class":272},[213,1747,277],{"class":276},[213,1749,1750],{"class":89,"line":269},[213,1751,284],{"emptyLinePlaceholder":283},[213,1753,1754,1756,1758,1760],{"class":89,"line":280},[213,1755,273],{"class":272},[213,1757,292],{"class":276},[213,1759,295],{"class":272},[213,1761,298],{"class":276},[213,1763,1764,1766,1768,1770],{"class":89,"line":287},[213,1765,273],{"class":272},[213,1767,306],{"class":276},[213,1769,295],{"class":272},[213,1771,311],{"class":276},[213,1773,1774],{"class":89,"line":301},[213,1775,284],{"emptyLinePlaceholder":283},[213,1777,1778,1780,1782,1784,1786,1788],{"class":89,"line":314},[213,1779,341],{"class":340},[213,1781,344],{"class":276},[213,1783,348],{"class":347},[213,1785,351],{"class":272},[213,1787,354],{"class":322},[213,1789,357],{"class":276},[213,1791,1792,1794,1797,1799,1801],{"class":89,"line":319},[213,1793,363],{"class":272},[213,1795,1796],{"class":340}," read_first_sheet",[213,1798,369],{"class":276},[213,1800,372],{"class":322},[213,1802,426],{"class":276},[213,1804,1805,1807],{"class":89,"line":332},[213,1806,387],{"class":272},[213,1808,1809],{"class":276}," pd.read_excel(io.BytesIO(data))\n",[213,1811,1812],{"class":89,"line":337},[213,1813,284],{"emptyLinePlaceholder":283},[213,1815,1816,1819,1821,1823,1826,1828,1830,1832,1834,1836,1839,1842,1844,1846],{"class":89,"line":360},[213,1817,1818],{"class":276},"files ",[213,1820,351],{"class":272},[213,1822,631],{"class":276},[213,1824,1825],{"class":222},"\"Upload regional workbooks\"",[213,1827,460],{"class":276},[213,1829,639],{"class":347},[213,1831,351],{"class":272},[213,1833,644],{"class":276},[213,1835,647],{"class":222},[213,1837,1838],{"class":276},"], ",[213,1840,1841],{"class":347},"accept_multiple_files",[213,1843,351],{"class":272},[213,1845,468],{"class":322},[213,1847,357],{"class":276},[213,1849,1850,1852,1854],{"class":89,"line":384},[213,1851,655],{"class":272},[213,1853,846],{"class":272},[213,1855,1856],{"class":276}," files:\n",[213,1858,1859],{"class":89,"line":403},[213,1860,681],{"class":276},[213,1862,1863,1866,1868],{"class":89,"line":408},[213,1864,1865],{"class":276},"combined ",[213,1867,351],{"class":272},[213,1869,1870],{"class":276}," pd.concat(\n",[213,1872,1873,1876,1879,1881,1884,1887,1890,1892],{"class":89,"line":429},[213,1874,1875],{"class":276},"    [read_first_sheet(f.getvalue()).assign(",[213,1877,1878],{"class":347},"source",[213,1880,351],{"class":272},[213,1882,1883],{"class":276},"f.name) ",[213,1885,1886],{"class":272},"for",[213,1888,1889],{"class":276}," f ",[213,1891,545],{"class":272},[213,1893,1894],{"class":276}," files],\n",[213,1896,1897,1900,1902,1904],{"class":89,"line":447},[213,1898,1899],{"class":347},"    ignore_index",[213,1901,351],{"class":272},[213,1903,468],{"class":322},[213,1905,939],{"class":276},[213,1907,1908],{"class":89,"line":474},[213,1909,357],{"class":276},[213,1911,1912,1915,1917,1919,1921,1924,1927,1929,1932,1934,1936,1939,1942,1944,1947],{"class":89,"line":479},[213,1913,1914],{"class":276},"st.caption(",[213,1916,724],{"class":272},[213,1918,750],{"class":222},[213,1920,731],{"class":730},[213,1922,1923],{"class":322},"len",[213,1925,1926],{"class":276},"(files)",[213,1928,737],{"class":730},[213,1930,1931],{"class":222}," files, ",[213,1933,731],{"class":730},[213,1935,1923],{"class":322},[213,1937,1938],{"class":276},"(combined)",[213,1940,1941],{"class":272},":,",[213,1943,737],{"class":730},[213,1945,1946],{"class":222}," rows\"",[213,1948,357],{"class":276},[213,1950,1951,1954,1957,1960,1962],{"class":89,"line":500},[213,1952,1953],{"class":276},"st.dataframe(combined.groupby(",[213,1955,1956],{"class":222},"\"source\"",[213,1958,1959],{"class":276},")[",[213,1961,1614],{"class":222},[213,1963,1964],{"class":276},"].sum())\n",[10,1966,1967,1968,1970,1971,1973],{},"In an ",[14,1969,26],{}," run with two uploads of three and five rows, the caption read \"2 files, 8 rows\" and the totals were 30 and 50, one per source file. The ",[14,1972,1878],{}," column matters more than it looks: when a total is wrong, it tells the user which file to open. Because the cache is keyed per file, adding a third file to the list reads only that file; the first two come from the cache. Files that do not share the same columns will combine with gaps, so apply the checks from Step 4 to each file before concatenating, and name the offending file in the message.",[199,1975,1977],{"id":1976},"common-pitfalls","Common pitfalls",[1979,1980,1981,2001,2014,2024],"ul",{},[1982,1983,1984,1988,1989,1992,1993,1996,1997,2000],"li",{},[1985,1986,1987],"strong",{},"Reading the upload twice."," An ",[14,1990,1991],{},"UploadedFile"," is a stream; after one ",[14,1994,1995],{},"read()"," the next returns nothing. Use ",[14,1998,1999],{},"getvalue()",", which always returns all the bytes.",[1982,2002,2003,2006,2007,2010,2011,246],{},[1985,2004,2005],{},"Global state between users."," Every browser session reruns the script separately, but module-level variables and ",[14,2008,2009],{},"st.cache_resource"," objects are shared by all users. Keep per-user data in local variables or ",[14,2012,2013],{},"st.session_state",[1982,2015,2016,2019,2020,2023],{},[1985,2017,2018],{},"Formatting lost on the way out."," ",[14,2021,2022],{},"to_excel"," writes plain cells. When the download should look like a report, style it with openpyxl or XlsxWriter before returning the bytes.",[1982,2025,2026,2019,2029,2032],{},[1985,2027,2028],{},"No access control.",[14,2030,2031],{},"streamlit run"," serves anyone who can reach the port. Put the app behind your organisation's sign-in, or use a hosting option that provides it, before real data goes near it.",[199,2034,2036],{"id":2035},"conclusion","Conclusion",[10,2038,2039,2040,2042,2043,2045,2046,2048],{},"Keep the Excel work in plain functions, read uploads from memory with ",[14,2041,1999],{},", cache the read on the file's bytes with ",[14,2044,114],{},", build the download only when it is clicked, stop early with clear messages on bad input, cap the upload size, and test the whole page with ",[14,2047,26],{}," — uploads included.",[199,2050,2052],{"id":2051},"frequently-asked-questions","Frequently asked questions",[10,2054,2055,2058],{},[1985,2056,2057],{},"How do I read an uploaded Excel file in Streamlit?","\nst.file_uploader returns an UploadedFile, a file-like object in memory. Pass its bytes to pandas with pd.read_excel(io.BytesIO(uploaded.getvalue())), or pass the object itself. Nothing is written to disk.",[10,2060,2061,2064],{},[1985,2062,2063],{},"How do I let users download a DataFrame as an Excel file in Streamlit?","\nWrite the DataFrame to an io.BytesIO buffer with pandas.ExcelWriter, and pass the bytes to st.download_button with the xlsx MIME type and a file name. In recent Streamlit versions you can pass a function instead, so the file is built only when the button is clicked.",[10,2066,2067,2070],{},[1985,2068,2069],{},"Why is my Streamlit Excel app slow every time I change a setting?","\nStreamlit reruns the whole script on every interaction, so it re-reads the workbook and rebuilds the download each time. Cache the reading function with st.cache_data and build the download lazily; in a test with 50,000 rows, a rerun fell from 1.33 seconds to 0.01 seconds.",[10,2072,2073,2076],{},[1985,2074,2075],{},"What is the largest file Streamlit accepts?","\n200 MB by default. Change it with server.maxUploadSize in .streamlit\u002Fconfig.toml. Large Excel files also take a long time to parse, so lower the limit to what the app can process comfortably.",[199,2078,2080],{"id":2079},"related","Related",[1979,2082,2083,2089,2094,2101],{},[1982,2084,2085,2086,2088],{},"Up one level: ",[19,2087,22],{"href":21}," — Excel in and out of web applications.",[1982,2090,2091,2093],{},[19,2092,1289],{"href":1288}," — checks to run before trusting an upload.",[1982,2095,2096,2100],{},[19,2097,2099],{"href":2098},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fhandle-an-uploaded-excel-file-in-flask-and-fastapi\u002F","Handle an Uploaded Excel File in Flask and FastAPI"," — the same upload in a conventional web framework.",[1982,2102,2103,2107],{},[19,2104,2106],{"href":2105},"\u002Fautomating-reporting-workflows\u002Ftesting-and-packaging-excel-automation-scripts\u002Fcreate-excel-test-fixtures-in-memory\u002F","Create Excel Test Fixtures in Memory for pytest"," — workbooks for tests like the ones above.",[2109,2110,2111],"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-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}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 .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":209,"searchDepth":269,"depth":269,"links":2113},[2114,2115,2116,2117,2118,2119,2120,2121,2122,2123,2124],{"id":201,"depth":269,"text":202},{"id":249,"depth":269,"text":250},{"id":598,"depth":269,"text":599},{"id":1134,"depth":269,"text":1135},{"id":1236,"depth":269,"text":1237},{"id":1292,"depth":269,"text":1293},{"id":1728,"depth":269,"text":1729},{"id":1976,"depth":269,"text":1977},{"id":2035,"depth":269,"text":2036},{"id":2051,"depth":269,"text":2052},{"id":2079,"depth":269,"text":2080},"2026-09-23","Turn an Excel-processing script into a small web app with Streamlit — upload a workbook, pick a sheet and columns, see the result and download it as a new .xlsx. Covers reading uploads in memory, caching so every click does not re-read the file, building the download only when it is clicked, friendly errors, upload limits and tests with AppTest.","md",[2129,2131,2133,2135],{"q":2057,"a":2130},"st.file_uploader returns an UploadedFile, a file-like object in memory. Pass its bytes to pandas with pd.read_excel(io.BytesIO(uploaded.getvalue())), or pass the object itself. Nothing is written to disk.",{"q":2063,"a":2132},"Write the DataFrame to an io.BytesIO buffer with pandas.ExcelWriter, and pass the bytes to st.download_button with the xlsx MIME type and a file name. In recent Streamlit versions you can pass a function instead, so the file is built only when the button is clicked.",{"q":2069,"a":2134},"Streamlit reruns the whole script on every interaction, so it re-reads the workbook and rebuilds the download each time. Cache the reading function with st.cache_data and build the download lazily; in a test with 50,000 rows, a rerun fell from 1.33 seconds to 0.01 seconds.",{"q":2075,"a":2136},"200 MB by default. Change it with server.maxUploadSize in .streamlit\u002Fconfig.toml. Large Excel files also take a long time to parse, so lower the limit to what the app can process comfortably.","build a streamlit app to upload and download excel files",null,{"breadcrumb":2140},[2141,2143,2146],{"name":2142,"item":1282},"Home",{"name":2144,"item":2145},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":22,"item":21},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-a-streamlit-app-to-upload-and-download-excel",{"title":2149,"description":2150},"Streamlit Excel Upload and Download App in Python","Build a Streamlit app that uploads an xlsx file, summarises it with pandas and offers the result with st.download_button — st.file_uploader, st.cache_data on the file bytes, deferred download data, measured rerun times, maxUploadSize, error handling and AppTest tests that upload a workbook.","build-a-streamlit-app-to-upload-and-download-excel","automating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-a-streamlit-app-to-upload-and-download-excel\u002Findex",[2154],"web-apps","how-to","fI70TIqd98BDj4g2_Xrnx2qsiDHZgPCszeJiovBQFN4",[2158,2162],{"title":2159,"path":2160,"stem":2161,"children":-1},"Build a Gradio App That Cleans an Uploaded Excel File","\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-a-gradio-app-that-cleans-an-uploaded-excel-file","automating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-a-gradio-app-that-cleans-an-uploaded-excel-file\u002Findex",{"title":2163,"path":2164,"stem":2165,"children":-1},"Build an Excel Workbook in Memory with BytesIO","\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-an-excel-workbook-in-memory-with-bytesio","automating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fbuild-an-excel-workbook-in-memory-with-bytesio\u002Findex",[2167,2169],{"path":2168,"title":2144},"\u002Fautomating-reporting-workflows",{"path":2170,"title":22},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps",[],[2173,2176,2179,2182,2183,2184],{"path":2174,"title":2175,"kind":2155},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-excel-reports-with-celery-beat","Schedule Excel Reports with Celery Beat",{"path":2177,"title":2178,"kind":2155},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Ftrigger-an-excel-report-from-a-webhook","Trigger an Excel Report from a Webhook",{"path":2180,"title":2181,"kind":2155},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fadd-an-export-to-excel-action-in-django-admin","Add an Export to Excel Action in Django Admin",{"path":2160,"title":2159,"kind":2155},{"path":2164,"title":2163,"kind":2155},{"path":2185,"title":2186,"kind":2155},"\u002Fautomating-reporting-workflows\u002Fserving-excel-files-from-python-web-apps\u002Fcache-generated-excel-files-in-a-web-app","Cache Generated Excel Files in a Web App",1790972140330]