Skip to main content

Module 7 — File upload and downloads

Scoring one customer at a time is fine at the customer desk; the sales operations team wants to drop their monthly export of ten thousand accounts and get back a file. This module builds that pipeline end to end: file upload, schema validation, batch scoring, download of the results — all inside the running example, without leaving the dashboard.

The file uploader

st.file_uploader returns a file-like object with a familiar .read(), .name, .size and .type interface. It handles browser-side selection and streams the bytes over the websocket to the Python process.

import streamlit as st
import pandas as pd

uploaded = st.file_uploader(
"Upload a customer CSV",
type=["csv"],
accept_multiple_files=False,
help="Expected columns: customer_id, tenure, monthly_charge, contract, payment.",
)

if uploaded is not None:
st.caption(f"Received **{uploaded.name}**, {uploaded.size / 1024:.1f} KB.")

Three arguments matter. type=["csv"] filters the file dialog and rejects anything that does not match the extension on submit; type=["csv", "xlsx"] allows a small list. accept_multiple_files=True turns the return value into a list, which is the moment your code has to loop. help= renders as a tooltip on the small ? next to the label — write there what a well-formed file looks like.

Reading the file safely

pd.read_csv(uploaded) works, but it does not by itself protect you against the four things sales operations will do wrong: a European semicolon instead of a comma, a Latin-1 encoding, an extra unicode byte-order-mark, and a "Total" line at the bottom. Read defensively.

def read_customers(f) -> pd.DataFrame:
"""Read the CSV, tolerating common formatting quirks."""
df = pd.read_csv(f, sep=None, engine="python", encoding_errors="replace")
df.columns = [c.strip().lower().replace(" ", "_") for c in df.columns]
return df.dropna(how="all")

if uploaded is not None:
try:
customers = read_customers(uploaded)
except Exception as exc:
st.error(f"Could not parse the file: {exc}")
st.stop()
st.dataframe(customers.head(), use_container_width=True)

sep=None with engine="python" triggers Pandas's separator sniffer, which correctly handles comma, semicolon and tab. encoding_errors="replace" substitutes an unknown byte with the Unicode replacement character instead of blowing up on line 4 723. And .dropna(how="all") removes the totally empty rows sales operations tools leave at the end. Wrap the whole thing in a try/except and stop on failure: an error at the top of the pipeline should not let downstream code run on a partial DataFrame.

Validating the schema before scoring

The model needs a specific set of columns and a specific dtype for each of them. Validate that before you hit the model — a KeyError inside the prediction is a bad customer experience.

REQUIRED = {"customer_id", "tenure", "monthly_charge", "contract", "payment"}

def validate(df: pd.DataFrame) -> list[str]:
problems: list[str] = []
missing = REQUIRED - set(df.columns)
if missing:
problems.append(f"Missing columns: {', '.join(sorted(missing))}")

if "tenure" in df.columns and not pd.api.types.is_numeric_dtype(df["tenure"]):
problems.append("Column 'tenure' must be numeric.")

if "contract" in df.columns:
bad = set(df["contract"].dropna().unique()) - {"Month-to-month", "One year", "Two years"}
if bad:
problems.append(f"Unknown contract values: {', '.join(map(str, bad))}")

if df["customer_id"].duplicated().any():
problems.append("Duplicate customer_id values in the file.")

return problems

Report the problems as a bullet list rather than one at a time; sales operations would rather fix five columns in one round-trip than five files in five round-trips.

problems = validate(customers)
if problems:
st.error("The file cannot be scored yet:")
for p in problems:
st.markdown(f"- {p}")
st.stop()

st.stop() halts the current run without an error page, so the code below does not run on invalid input. It is the equivalent of an early return at the top level of the script.

Batch scoring with a progress bar

Once the file is clean, run the model. st.progress gives feedback on long loops without spamming the log:

progress = st.progress(0.0, text="Scoring...")
results: list[dict] = []

for i, row in enumerate(customers.itertuples(index=False), start=1):
row_features = {
"tenure": row.tenure,
"monthly_charge": row.monthly_charge,
"contract": row.contract,
"payment": row.payment,
}
probability = float(model.predict_proba([list(row_features.values())])[0, 1])
results.append({"customer_id": row.customer_id, "probability": probability})

if i % 100 == 0 or i == len(customers):
progress.progress(i / len(customers), text=f"Scored {i:,} / {len(customers):,}")

scored = pd.DataFrame(results).merge(customers, on="customer_id")
progress.empty()
st.success(f"Scored {len(scored):,} customers.")

In real code, vectorize the scoring — model.predict_proba(customers[FEATURES]) is orders of magnitude faster than a Python loop — and use the progress bar only to signal the two or three phases of the pipeline. A loop is kept above only to make the pattern explicit.

Offering the results as a download

st.download_button accepts either bytes or a string. Serialize the DataFrame yourself so you keep control of encoding and delimiter.

csv_bytes = scored.to_csv(index=False).encode("utf-8")

st.download_button(
label="Download scored file",
data=csv_bytes,
file_name=f"scored_{pd.Timestamp.today():%Y-%m-%d}.csv",
mime="text/csv",
type="primary",
)

For very large files, wrap the serialization in @st.cache_data so the bytes are not recomputed on every rerun — the download button re-renders on every interaction, and without a cache it would regenerate the CSV every time.

@st.cache_data(show_spinner=False)
def to_csv_bytes(df: pd.DataFrame) -> bytes:
return df.to_csv(index=False).encode("utf-8")

st.download_button("Download scored file", to_csv_bytes(scored),
file_name="scored.csv", mime="text/csv")

Upload size and quotas

By default Streamlit caps uploads at 200 MB per file. Sales operations rarely need more, but you can raise the limit in .streamlit/config.toml:

# .streamlit/config.toml
[server]
maxUploadSize = 500 # MB per file
maxMessageSize = 500 # MB per websocket frame

Two cautions. The uploader keeps the whole file in memory until the run completes, so a 500 MB CSV takes 500 MB of RAM plus what the parser uses. And the websocket ferries every byte through the browser — a slow client will feel the difference. For anything above a few hundred megabytes, prefer a direct upload to object storage (S3, GCS) and a Streamlit form that only takes the resulting URL.

The Batch pipeline tab of the running example

import streamlit as st
import pandas as pd

with tab_batch:
st.subheader("Score a CSV of customers")

uploaded = st.file_uploader("Upload the export", type=["csv"])
if uploaded is None:
st.info("Drop a CSV to start.")
st.stop()

try:
customers = read_customers(uploaded)
except Exception as exc:
st.error(f"Could not parse the file: {exc}")
st.stop()

problems = validate(customers)
if problems:
st.error("The file cannot be scored yet:")
for p in problems:
st.markdown(f"- {p}")
st.stop()

st.caption(f"Parsed {len(customers):,} rows, {len(customers.columns)} columns.")
if not st.button("Score the whole file", type="primary"):
st.stop()

with st.spinner("Scoring..."):
scored = score_batch(customers) # from module 5, cached

st.metric("Scored rows", f"{len(scored):,}")
st.dataframe(scored.head(50), use_container_width=True, hide_index=True)

st.download_button(
"Download scored file",
data=to_csv_bytes(scored),
file_name=f"scored_{pd.Timestamp.today():%Y-%m-%d}.csv",
mime="text/csv",
)

The st.stop() calls short-circuit the pipeline at every checkpoint — no file, invalid file, user has not clicked the button yet. Reading the tab is now linear: parse, validate, decide, score, download.

Uploaded files live only as long as the run

The uploader's return value is a stream, not a saved file. Read it once into a DataFrame and store the DataFrame in st.session_state if the user needs to trigger multiple actions on the same file. Reading it twice may return an empty buffer.

In summary

  • st.file_uploader returns a file-like object; read with pd.read_csv(f, sep=None, engine="python") to tolerate common quirks.
  • Validate the schema before hitting the model and report all problems in one message; use st.stop() at each checkpoint.
  • st.download_button ships bytes or strings; cache the serialization for large DataFrames.
  • Default upload cap is 200 MB; raise maxUploadSize in .streamlit/config.toml if you must, and beware the memory cost of large files.

Next module: calling the model from the application, locally and through a remote inference API.