Guide
Automating Reporting WorkflowsDeep dive

Handle an Uploaded Excel File in Flask and FastAPI

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.

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.

Four checks before an upload is trusted 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. 1 Limit the body size at the server or proxy, before your code runs 2 Check the extension cheap, and entirely user-supplied 3 Check the leading bytes every .xlsx starts with PK\x03\x04 4 Validate the columns a file that parses is not a file you can use each check is cheaper than the one after it

Prerequisites

Bash
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

Python
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

Python
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

What the response tells the person who uploaded 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. upload failed one line, no detail user retries blindly support ticket three problems, listed missing column named bad rows numbered fix and resubmit 422 row numbers must match the spreadsheet, not the frame

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.

Python
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

Python
@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.

Python
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.

Python
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.

Python
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

SymptomCauseFix
RequestEntityTooLarge with an unhelpful pageMAX_CONTENT_LENGTH hit, no error handlerRegister a 413 handler returning JSON
FastAPI raises about python-multipartThe parser package is not installedpip install python-multipart
A renamed executable is acceptedExtension checked, contents notCheck the leading bytes for the zip signature
Row numbers in errors do not match the sheetZero-based index reported directlyAdd two for the header and one-based rows
Memory grows with concurrent uploadsEvery request buffers the whole bodyLimit size at the proxy, and stream large files
Temporary files accumulateCleanup skipped when parsing failedDelete 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.

Python
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

What a slow parse costs a synchronous endpoint 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. parse in the request worker held parse with calamine still held store and enqueue returns at once relative cost the user polls a status URL instead of waiting on a connection

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:

Python
@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.