Skip to content

Flask

The minimal, battle-tested Python web framework. Flask is ideal when you want a single file serving an ML model with zero magic — no async, no auto-docs, just routes and functions.

Table of Contents

Installation / Setup

pip install flask        # or: uv add flask
# app.py
from flask import Flask, jsonify, request

app = Flask(__name__)

@app.route("/health")
def health():
    return jsonify({"status": "ok"})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)
python app.py                    # dev server (do NOT use in production)
flask --app app run --debug      # modern CLI form; --debug auto-reloads
  • In production run with a WSGI server: gunicorn -w 4 -b 0.0.0.0:8000 app:app.
  • Flask's built-in server is single-process and not hardened — never expose it directly.

Core Syntax

Concept Code Notes
Route + function @app.route("/predict", methods=["POST"]) Default method is GET
Request JSON body request.get_json() Returns None if body isn't JSON
Path parameter @app.route("/items/<int:item_id>") Converters: int, float, string, path
Query parameter request.args.get("top_k", 5, type=int) Always strings unless type=
JSON response jsonify({"pred": 0.9}) Sets Content-Type: application/json
Plain response return "ok", 200 Second value = status code
Headers resp.headers["X-Rate-Limit"] = "10" Mutate the response object
Error response abort(404, description="No such model") Or return jsonify(...), 400
Blueprint app.register_blueprint(api, url_prefix="/api") Organize larger apps

Common Operations

Reading request data

from flask import request

# JSON body
data = request.get_json(silent=True) or {}        # never crash on bad body

# path + query parameters
user_id = request.view_args["user_id"]            # from <int:user_id>
top_k = request.args.get("top_k", 5, type=int)    # typed query param

# form data (HTML <form> POST)
text = request.form.get("text", "")

# raw body (e.g. someone POSTs pre-serialized bytes)
raw = request.get_data()

Building responses

resp = jsonify({"model": "rf_v1", "pred": "churn", "prob": 0.87})
resp.status_code = 201
resp.headers["X-Model-Version"] = "v1"
return resp

Practical Examples

Serve a scikit-learn model

import joblib
from flask import Flask, jsonify, request

app = Flask(__name__)
model = joblib.load("models/churn_rf_v1.joblib")   # load ONCE at startup
FEATURES = ["tenure", "monthly_charges", "contract", "support_calls"]

@app.route("/predict", methods=["POST"])
def predict():
    data = request.get_json(silent=True)
    if not data:
        return jsonify({"error": "JSON body required"}), 400

    missing = [f for f in FEATURES if f not in data]
    if missing:
        return jsonify({"error": f"missing fields: {missing}"}), 422

    row = [[data[f] for f in FEATURES]]
    prob = float(model.predict_proba(row)[0, 1])
    return jsonify({"prob_churn": prob, "label": int(prob >= 0.5)})

Batch prediction

@app.route("/predict_batch", methods=["POST"])
def predict_batch():
    records = request.get_json(silent=True)
    if not isinstance(records, list) or not records:
        return jsonify({"error": "expected a non-empty JSON array"}), 400

    X = [[r[f] for f in FEATURES] for r in records]
    probs = model.predict_proba(X)[:, 1].tolist()
    return jsonify({"predictions": probs, "count": len(probs)})

Wrap a Hugging Face pipeline

from transformers import pipeline

classifier = pipeline("sentiment-analysis")       # load once, reuse

@app.route("/classify", methods=["POST"])
def classify():
    text = (request.get_json(silent=True) or {}).get("text", "")
    if not text.strip():
        return jsonify({"error": "field 'text' is required"}), 422
    result = classifier(text[:1000])[0]           # cap length!
    return jsonify({"label": result["label"], "score": round(result["score"], 4)})

Common Patterns

Load expensive models once (module scope)

# top of file, outside any function — runs once per process
model = joblib.load("models/model.joblib")

Gunicorn with multiple workers loads the model once per worker. Keep the model small or pre-warm workers (see below).

Central error handler

@app.errorhandler(404)
def not_found(e):
    return jsonify({"error": "not found"}), 404

@app.errorhandler(500)
def server_error(e):
    # log the real traceback, return a generic message
    app.logger.exception("internal error")
    return jsonify({"error": "internal server error"}), 500

Model registry / multiple versions

MODELS = {
    "v1": joblib.load("models/rf_v1.joblib"),
    "v2": joblib.load("models/rf_v2.joblib"),
}

@app.route("/models/<version>/predict", methods=["POST"])
def predict_version(version):
    model = MODELS.get(version)
    if model is None:
        return jsonify({"error": f"unknown model '{version}'"}), 404
    # ... predict with model

Factory + blueprints (scalable layout)

# app.py — create_app factory keeps tests simple
def create_app():
    app = Flask(__name__)
    from api.routes import bp
    app.register_blueprint(bp, url_prefix="/api")
    return app

Best Practices

  • Load models once at import time, never inside the request handler.
  • Validate input — return explicit 400/422 errors instead of letting the framework 500.
  • Return typed status codes: 201 created, 404 unknown route, 422 bad payload, 429 rate-limited.
  • Always jsonify() responses; returning raw dicts is deprecated in modern Flask.
  • Set Content-Type: application/json on every API response (jsonify does this).
  • Put an app-level exception handler so stack traces never leak to clients.
  • Cap request sizes and input lengths (request.get_data(cache=False) / truncate text).
  • Use gunicorn + multiple workers behind a reverse proxy for production.
  • Add timeout to model calls so a slow inference doesn't stall workers.

Common Mistakes

Mistake Fix
Training the model inside the request handler Load once at module scope
Running app.run() in production Use gunicorn -w N app:app
Returning raw dict without jsonify return jsonify(data)
request.get_json() crashing on HTML/non-JSON bodies get_json(silent=True) or {} then validate keys
Debug mode (debug=True) exposed publicly Never enable debug on a public server
Mutating a global list shared across requests Thread-local storage or a lock
Using Flask's dev server for async/websocket workloads Use a WSGI/ASGI server or FastAPI

Troubleshooting

Symptom Cause / Fix
GET /predict 405 Method Not Allowed Route declared without methods=["POST"]
request.get_json() returns None Client sent wrong Content-Type; or call silent=True
Request hangs on model inference Model slow — cap concurrency or offload to queue
Flask not starting: Address already in use Kill the old process or change port=
Errors in threads during prediction Some models aren't thread-safe — use a threading.Lock per model
Reload loop in debug triggers model reload each save Load model lazily or in a separate module
  • FastAPI — the async, typed alternative to Flask
  • REST API — routes, methods, status codes design
  • HTTP — methods, headers, CORS
  • JSON — payload shape and serialization
  • python-requests — calling your Flask API from Python
  • curl — testing Flask endpoints from the terminal
  • Docker — containerize a Flask model server

Official Documentation

Last Updated

2026-09-06 — Initial version.