Machine Learning Essentials

Deployment with Streamlit & Flask


Your spam classifier gets 96% on held-out data. The product manager wants to try it on some real emails.

So you send her the notebook and the model file. She does not have Python. You spend a morning walking her through installing Anaconda; the environment resolves to a different scikit-learn version and the model fails to load. You give up and offer to run emails yourself, pasting results into a spreadsheet. Two days later the backend engineer asks how his service should call your model. You have no answer, because there is nothing to call.

The model was finished. The work was not. A trained model is a file that only helps people who can run Python, load the right dependencies, and shape their data into the exact format it expects — which is you, and possibly nobody else.

Deployment is the step that turns that file into something other people can use, and there are two fundamentally different destinations depending on who "other people" are.

Who is calling your modelStreamlit, for a person• Whole script rerunson every widget change• No front-end code to write• One session per browser tab• Wrong tool onceanother program must call itFlask, for a program• Serves JSON over an HTTP endpoint• Validate the payloadbefore the model sees it• Scales behind a normal web server• Nobody can click it without a client
The product manager wants to click something; the mobile app wants an endpoint — the audience picks the tool.

Two audiences, two answers

A human needs to try itA program needs to call it
What you buildA web app with inputs and a visible resultAn API returning JSON
InterfaceButtons, sliders, text boxesAn HTTP endpoint
ToolStreamlitFlask
Typical volumeTens of requests a dayThousands a minute
PurposeDemos, internal tools, explorationProduction integration

These are not competitors. Most projects eventually want both — an API carrying the production traffic, and a small app so that the people who requested the model can see it working.

Client and server, in plain terms

Both approaches rest on the same arrangement, and it is worth stating plainly because the vocabulary trips people up.

A server is a program that starts up, loads your model into memory, and waits. A client — a browser, a mobile app, another service — sends a request. The server does the work and sends back a response.

Text
CLIENT                              SERVER  |                                   | (started earlier, model in memory)  |--- POST /predict --------------->|  |    {"income": 52000, "age": 34}   |  validate input  |                                   |  model.predict_proba(...)  |<-- 200 OK -----------------------|  |    {"probability": 0.83}          |

Four pieces of vocabulary carry most of the weight:

  • Method — GET retrieves something, POST sends data for processing. Predictions use POST because the input is a body of data, not a name in a URL.
  • Path — /predict, /health. Each is a separate function on the server.
  • Body — the data, almost always JSON.
  • Status code — 200 succeeded, 400 the client sent something invalid, 422 the values failed validation, 500 the server broke. Getting these right is how the caller's code knows whether to retry.

Streamlit: an interface without writing any front-end code

Streamlit turns a Python script into a web app. There is no HTML, no JavaScript, no callbacks — you write top-to-bottom Python and Streamlit renders it.

The mental model that explains everything

Streamlit's execution model surprises everyone once, so learn it before writing anything: every time the user interacts with any widget, Streamlit reruns your entire script from the first line.

Not a callback. Not the affected section. The whole file, top to bottom, with the widget now returning its new value.

This is why the framework feels so simple — there is no event wiring, because state changes just rerun everything. It is also why the naive version is unusably slow:

Python
import streamlit as stimport joblibmodel = joblib.load("pipeline.joblib")   # WRONG: reloads on every keystroke

Move a slider and that 200 MB file is deserialised again. The fix is a decorator that tells Streamlit to compute something once and reuse it:

Python
@st.cache_resourcedef load_model():    return joblib.load("pipeline.joblib")model = load_model()      # loaded once per server process

Two decorators, used for different things:

DecoratorForBehaviour
@st.cache_resourceModels, database connectionsOne shared object across all sessions; not copied
@st.cache_dataDataFrames, computed resultsCached per argument value; returns a copy so callers cannot corrupt it

Getting these the wrong way round is the most common Streamlit bug. Caching a model with cache_data copies it on every access; caching a DataFrame with cache_resource lets one user's mutation leak into another user's session.

A complete app

Python
import streamlit as stimport pandas as pdimport joblibst.set_page_config(page_title="Churn risk", page_icon="📉")@st.cache_resourcedef load_model():    return joblib.load("pipeline.joblib")model = load_model()st.title("Customer churn risk")st.caption("Enter customer details to estimate the probability of churn.")col1, col2 = st.columns(2)with col1:    income = st.number_input("Annual income (£)", 0, 500_000, 45_000, step=1_000)    age = st.slider("Age", 18, 90, 38)    tenure = st.slider("Years as customer", 0.0, 30.0, 3.5, step=0.5)with col2:    products = st.selectbox("Products held", [1, 2, 3, 4, 5])    region = st.selectbox("Region", ["north", "south", "east", "west"])    contract = st.checkbox("On a contract", value=True)threshold = st.sidebar.slider("Decision threshold", 0.05, 0.95, 0.34, 0.01)if st.button("Predict", type="primary"):    row = pd.DataFrame([{        "income": income, "age": age, "tenure_years": tenure,        "num_products": products, "region": region,        "has_contract": int(contract),    }])    prob = float(model.predict_proba(row)[0, 1])    st.metric("Churn probability", f"{prob:.1%}")    st.progress(prob)    if prob >= threshold:        st.error(f"Flagged for retention outreach (threshold {threshold:.0%})")    else:        st.success("No action needed")    with st.expander("What the model received"):        st.dataframe(row)

Run it with streamlit run app.py. That is roughly fifty lines for a working internal tool, which is why Streamlit has become the default for demos.

Two details worth copying. The threshold is a sidebar control rather than a constant, so the person using the tool can see the trade-off between catching churners and generating false alarms rather than being handed a fixed answer. And the expander showing the exact input frame turns "the model gave a weird answer" into a debuggable report.

Where Streamlit stops

Streamlit is a UI framework, not a service platform. It has no clean way to be called by another program, its reruns make anything stateful awkward, and it holds one script execution per connected user, which limits concurrency sharply. Use it for people; use something else for machines.

Flask: an API other programs can call

Python
from flask import Flask, request, jsonifyimport pandas as pdimport joblibimport loggingapp = Flask(__name__)log = logging.getLogger(__name__)MODEL = joblib.load("pipeline.joblib")        # once, at importMODEL_VERSION = "2.3.0"FEATURES = ["income", "age", "tenure_years", "num_products",            "region", "has_contract"]@app.get("/health")def health():    return jsonify(status="ok", model_version=MODEL_VERSION)@app.post("/predict")def predict():    payload = request.get_json(silent=True)    if payload is None:        return jsonify(error="body must be valid JSON"), 400    missing = [f for f in FEATURES if f not in payload]    if missing:        return jsonify(error="missing fields", fields=missing), 422    try:        row = pd.DataFrame([{f: payload[f] for f in FEATURES}])        prob = float(MODEL.predict_proba(row)[0, 1])    except Exception:        log.exception("prediction failed")        return jsonify(error="prediction failed"), 500    return jsonify(        probability=round(prob, 4),        prediction=int(prob >= 0.34),        model_version=MODEL_VERSION,    )

Calling it:

Bash
curl -X POST http://localhost:5000/predict \  -H "Content-Type: application/json" \  -d '{"income": 52000, "age": 34, "tenure_years": 2.5,       "num_products": 2, "region": "north", "has_contract": 1}'# {"model_version":"2.3.0","prediction":1,"probability":0.8312}

Three things in that handler are not decoration.

The health endpoint is how load balancers and orchestrators decide whether this instance should receive traffic. Without one, a process whose model failed to load keeps receiving requests.

The model version in the response means every logged prediction can later be traced to the artefact that produced it. When results shift, that field answers "did the model change?" in seconds.

The catch-all except that logs and returns 500 ensures an unexpected input produces a clean error rather than a stack trace in the response body. Returning internal tracebacks to callers leaks file paths and library versions.

Validate before the model ever sees the data

A model cannot tell you its input was nonsense. Give it an age of 900 and it will return a confident probability, because 900 is a perfectly good float.

CheckCatchesResponse
Field presentTypos, schema drift in the caller422 with the missing field names
Correct type"thirty-four" where a number is expected422
Plausible rangeage 900, income −5,000422
Category is knownregion: "atlantis"422, or rely on handle_unknown="ignore"
Inside training rangeincome of £40m when training topped out at £500k200, but flag the response as low-confidence
Not all defaultsA caller sending an empty template422
Python
RANGES = {"income": (0, 1_000_000), "age": (18, 100),          "tenure_years": (0, 60), "num_products": (1, 10)}CATEGORIES = {"region": {"north", "south", "east", "west"}}def validate(payload):    errors = []    for field, (lo, hi) in RANGES.items():        value = payload.get(field)        if not isinstance(value, (int, float)) or isinstance(value, bool):            errors.append(f"{field} must be a number")        elif not (lo <= value <= hi):            errors.append(f"{field} must be between {lo} and {hi}, got {value}")    for field, allowed in CATEGORIES.items():        if payload.get(field) not in allowed:            errors.append(f"{field} must be one of {sorted(allowed)}")    return errors

The isinstance(value, bool) exclusion is not pedantry: in Python True is an instance of int, so a caller sending "age": true would otherwise sail through as the number 1.

An unvalidated API does not fail when it receives bad data. It returns a confident number, and someone acts on it.

Choosing between them

StreamlitFlask
AudiencePeoplePrograms
Lines for a working version~30~40, plus a client
Front-end code requiredNoneNone — but there is no UI either
Callable from another serviceNot practicallyYes, that is the point
ConcurrencyPoor — a script run per userGood, with a proper WSGI server
Input validationConstrained by the widgetsYou must write it
AuthenticationAwkwardStandard middleware
Best forDemos, internal tools, explorationProduction integration

A common and sensible arrangement is to build the Flask API first and then write the Streamlit app as a client of it. The app calls POST /predict over HTTP rather than holding its own copy of the model. One model, one validation path, two front doors.

Getting it off your laptop

When Flask starts it prints a warning that people ignore:

Text
WARNING: This is a development server. Do not use it in a production deployment.

It means it. The built-in server handles one request at a time, has no process management, and no protection against slow clients. In production, run the app under a proper WSGI server:

Bash
gunicorn --workers 4 --bind 0.0.0.0:8000 --timeout 60 app:app

Each worker is a separate process with its own copy of the model in memory — worth remembering when the model is 200 MB and you asked for eight workers. A reasonable starting point is 2 × cores + 1, adjusted downwards if memory is the constraint.

Then make the environment reproducible with a container:

Bash
# DockerfileFROM python:3.12-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY app.py pipeline.joblib ./EXPOSE 8000CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:8000", "app:app"]

with exact pins in requirements.txt:

Text
flask==3.1.3gunicorn==26.2.0scikit-learn==1.9.1pandas==3.0.6joblib==1.6.0

The versions above were current when this was written; pin whatever you trained with. Those pins are the whole point. A model file saved under scikit-learn 1.9.1 and loaded under a different version may fail, or may load and behave slightly differently. Pinning turns "works on my machine" into "works wherever this image runs".

Finally, keep configuration out of the code. Model paths, thresholds, and log levels belong in environment variables so that staging and production can differ without a code change:

Python
import osMODEL_PATH = os.environ.get("MODEL_PATH", "pipeline.joblib")THRESHOLD = float(os.environ.get("DECISION_THRESHOLD", "0.34"))

Log what you predicted

One habit separates a service you can improve from one you can only restart. Log every prediction — inputs, output, model version, timestamp.

Python
log.info("prediction", extra={    "model_version": MODEL_VERSION,    "features": {f: payload[f] for f in FEATURES},    "probability": prob,})

Those logs are what let you detect that the average incoming income has shifted 30% since training, or compare predictions against outcomes once they arrive, or reproduce exactly what happened when a customer complains. Without them a deployed model is a black box that you can only observe by its silence.

What this means when you build something

Decide who is calling before you choose a tool. If the answer is "a colleague who wants to click something", write the Streamlit app — it will take an afternoon and the conversation it enables is worth more than another point of accuracy. If the answer is "our backend, on every checkout", write the API, and accept that validation, logging, health checks, and pinned dependencies are part of the job rather than polish to add later.

Whichever you build, load the model exactly once when the process starts, keep every preprocessing step inside the saved pipeline so the serving code never reimplements it, and validate every field before it reaches predict. Those three things prevent the great majority of production incidents involving machine learning models, and none of them takes more than twenty lines.