Handle an Uploaded Excel File in Flask and FastAPI
Letting people upload a spreadsheet is the other half of serving one, and it is the half with the security and validation problems. The file arrives as an untrusted stream with a user-supplied name, and everything downstream depends on it being what it claims. This guide, part of Serving Excel Files from Python Web Apps, covers receiving the upload in Flask and FastAPI, verifying it, and returning errors somebody can act on.
Prerequisites
pip install flask fastapi "uvicorn[standard]" python-multipart pandas python-calamine
python-multipart is what lets FastAPI parse a form upload; without it the endpoint raises at
import time with a message naming the package.
Receive it in Flask
import io
from flask import Flask, jsonify, request
import pandas as pd
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 # 16 MB, enforced by Werkzeug
ALLOWED = {".xlsx", ".xlsm"}
ZIP_MAGIC = b"PK\x03\x04"
@app.post("/upload")
def upload():
if "file" not in request.files:
return jsonify(error="no file part in the request"), 400
upload = request.files["file"]
if not upload.filename:
return jsonify(error="no file selected"), 400
suffix = "." + upload.filename.rsplit(".", 1)[-1].lower() if "." in upload.filename else ""
if suffix not in ALLOWED:
return jsonify(error=f"unsupported file type {suffix!r}; expected .xlsx or .xlsm"), 415
payload = upload.read()
if not payload.startswith(ZIP_MAGIC):
return jsonify(error="that file is not a workbook"), 415
frame = pd.read_excel(io.BytesIO(payload), engine="calamine")
return jsonify(rows=len(frame), columns=list(frame.columns))
MAX_CONTENT_LENGTH is enforced by the server before your view runs, which is the important part —
it rejects an oversized body rather than letting you discover the size after buffering it. The magic
byte check is the other cheap win: every .xlsx is a zip archive, and anything that does not start
with the zip signature is not one, whatever it is called.
Receive it in FastAPI
import io
from fastapi import FastAPI, File, HTTPException, UploadFile
import pandas as pd
api = FastAPI()
MAX_BYTES = 16 * 1024 * 1024
@api.post("/upload")
async def upload(file: UploadFile = File(...)):
if not file.filename.lower().endswith((".xlsx", ".xlsm")):
raise HTTPException(415, "expected an .xlsx or .xlsm workbook")
payload = await file.read()
if len(payload) > MAX_BYTES:
raise HTTPException(413, f"file exceeds {MAX_BYTES // 1024 // 1024} MB")
if not payload.startswith(b"PK\x03\x04"):
raise HTTPException(415, "that file is not a workbook")
frame = pd.read_excel(io.BytesIO(payload), engine="calamine")
return {"rows": len(frame), "columns": list(frame.columns)}
FastAPI has no built-in body-size limit, so the check here is manual — and it happens after the body
has been buffered, which is why a limit in the reverse proxy (client_max_body_size in nginx) is the
real defence. The application check is a clearer error message, not a protection.
Validate the contents, not just the file
A file that parses is not a file you can use. The columns may be missing, renamed, or full of text where numbers belong — and reporting all of those at once is what makes the feature usable.
REQUIRED = {"Order_ID": "int64", "Region": "object", "Revenue": "float64"}
def validate(frame: pd.DataFrame) -> list[str]:
problems = []
missing = [name for name in REQUIRED if name not in frame.columns]
if missing:
problems.append(f"missing column(s): {', '.join(missing)}; found {list(frame.columns)}")
return problems # no point checking types yet
numeric = pd.to_numeric(frame["Revenue"], errors="coerce")
bad_rows = frame.index[numeric.isna() & frame["Revenue"].notna()] + 2 # +2 for header and 1-base
if len(bad_rows):
problems.append(f"non-numeric Revenue on row(s): {list(bad_rows[:20])}")
empty_keys = frame.index[frame["Order_ID"].isna()] + 2
if len(empty_keys):
problems.append(f"missing Order_ID on row(s): {list(empty_keys[:20])}")
return problems
Adding two to the index is the detail that makes the message useful: pandas rows are zero-based and the header occupies row one, so row 0 of the frame is row 2 of the sheet. Reporting a row number the user cannot find in their spreadsheet is worse than reporting none at all.
Return the errors as data
@app.post("/upload")
def upload():
...
frame = pd.read_excel(io.BytesIO(payload), engine="calamine")
problems = validate(frame)
if problems:
return jsonify(accepted=False, problems=problems), 422
return jsonify(accepted=True, rows=len(frame))
422 is the right status for a well-formed request whose content fails validation, and returning the problems as a list lets a front end render them beside the upload control. For a larger file it is worth going further and returning an annotated copy of the workbook with the failing cells highlighted — the technique in Highlight Invalid Cells in Excel with Python.
Handling files too large to hold
For genuinely large uploads, streaming to a temporary file and parsing from there keeps the peak memory to one copy rather than two.
import tempfile
from pathlib import Path
@api.post("/upload-large")
async def upload_large(file: UploadFile = File(...)):
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as handle:
while chunk := await file.read(1024 * 1024):
handle.write(chunk)
target = Path(handle.name)
try:
frame = pd.read_excel(target, engine="calamine")
return {"rows": len(frame)}
finally:
target.unlink(missing_ok=True)
The finally is not optional — an upload endpoint that leaves temporary files behind fills a disk
over weeks, and the failure appears as something unrelated. missing_ok=True keeps the cleanup from
raising when the parse failed before the file was complete.
Reading only the sheet you expect
An uploaded workbook may contain a dozen tabs, and reading the first one is a guess. Where the format is specified — "the data must be on a sheet named Orders" — enforce it, and report the available names when it is missing so the user can see what they actually sent.
import io
import pandas as pd
def read_named_sheet(payload: bytes, required: str) -> pd.DataFrame:
buffer = io.BytesIO(payload)
available = pd.ExcelFile(buffer, engine="calamine").sheet_names
if required not in available:
raise ValueError(f"no sheet named {required!r}; the file contains {available}")
buffer.seek(0)
return pd.read_excel(buffer, sheet_name=required, engine="calamine")
buffer.seek(0) is the detail that makes this work — reading the sheet names consumes the buffer, so
the second read starts at the end and finds nothing. It is a two-character fix for an error message
that otherwise blames the file.
Listing the tabs the file does contain turns "no sheet named Orders" into something the user can act on in one attempt, which for an upload feature is the whole difference between usable and not.
Protecting against a hostile workbook
An uploaded spreadsheet is untrusted input in a stronger sense than most: the format supports macros, external data connections and links that resolve when opened. Three precautions cover the realistic risks without becoming a security project.
Read with a parser that does not execute anything — calamine and openpyxl both read values and never
run VBA, so parsing is safe in a way that opening the file in Excel is not. Reject .xlsm outright
unless macros are genuinely part of the requirement, since accepting them means eventually storing
and forwarding them. And never open an uploaded file with Excel automation on a server, which is the
one path where a macro could actually run.
if payload.startswith(b"PK\x03\x04") and filename.lower().endswith(".xlsm"):
raise HTTPException(415, "macro-enabled workbooks are not accepted")
Storing the upload rather than acting on it directly also gives an audit trail if something later turns out to be wrong, which is the same reasoning behind the archive key above and behind the handling advice in Work with Macro-Enabled .xlsm Files in openpyxl.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
RequestEntityTooLarge with an unhelpful page | MAX_CONTENT_LENGTH hit, no error handler | Register a 413 handler returning JSON |
FastAPI raises about python-multipart | The parser package is not installed | pip install python-multipart |
| A renamed executable is accepted | Extension checked, contents not | Check the leading bytes for the zip signature |
| Row numbers in errors do not match the sheet | Zero-based index reported directly | Add two for the header and one-based rows |
| Memory grows with concurrent uploads | Every request buffers the whole body | Limit size at the proxy, and stream large files |
| Temporary files accumulate | Cleanup skipped when parsing failed | Delete in a finally with missing_ok=True |
Storing what was uploaded
Most upload features eventually need to keep the file — for audit, for reprocessing, or because somebody will ask what was submitted. Writing it to object storage under a key that records who and when is more useful than a folder of user-supplied names.
import hashlib
from datetime import datetime, timezone
def archive_key(payload: bytes, user: str) -> str:
digest = hashlib.sha256(payload).hexdigest()[:16]
stamp = datetime.now(timezone.utc).strftime("%Y/%m/%d/%H%M%S")
return f"uploads/{user}/{stamp}-{digest}.xlsx"
Including a content hash makes duplicate submissions obvious and gives every stored file a name that
cannot collide, which a user-supplied filename certainly can. Never use the uploaded name as a path —
../../etc/passwd is a filename too, and werkzeug.utils.secure_filename exists precisely because
this mistake is so easy to make.
Performance and scale
An upload endpoint's cost is dominated by parsing, not by the transfer, so the engine choice matters as much here as anywhere — and it matters more, because a slow parse holds a request thread. On a synchronous framework a thirty-second parse is a worker occupied for thirty seconds.
For anything beyond a small file, the right shape is to accept the upload, store it, return immediately, and process it in a background worker:
@api.post("/upload")
async def upload(file: UploadFile = File(...)):
payload = await file.read()
key = archive_key(payload, user="ana")
store(key, payload)
enqueue_processing(key) # Celery, RQ, a queue — anything
return {"accepted": True, "key": key, "status_url": f"/uploads/{key}/status"}
That turns a request that might take a minute into one that takes milliseconds, and it gives the user something to poll. It is the same reasoning that makes generated downloads asynchronous in Stream an Excel File from a FastAPI Endpoint.
Conclusion
Limit the body size at the server, check the leading bytes rather than trusting the extension, parse
from BytesIO for ordinary files and stream to a temporary file for large ones. Validate the columns
and types in one pass and return every problem at once with sheet row numbers the user can find.
Store the original under a hashed key rather than its submitted name, and move anything slow into a
background worker so the request returns immediately.
Frequently asked questions
Should I save the upload to disk before reading it? 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.
How do I stop someone uploading a 500 MB file? 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.
Is checking the extension enough validation? 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.
What should the response tell the user? 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.
Related
- Up one level: Serving Excel Files from Python Web Apps — the download side of the same application.
- Return an Excel File from a Flask Download Endpoint — sending a workbook back to the browser.
- Stream an Excel File from a FastAPI Endpoint — the asynchronous download equivalent.
- Validate Excel Columns Before Import with Pandas — the column contract this endpoint enforces.
- Build an Excel Workbook in Memory with BytesIO — the same buffer, used in the other direction.