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
- Core Syntax
- Common Operations
- Practical Examples
- Common Patterns
- Best Practices
- Common Mistakes
- Troubleshooting
- Related Cheat Sheets
- Official Documentation
Installation / Setup¶
# 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/422errors instead of letting the framework 500. - Return typed status codes:
201created,404unknown route,422bad payload,429rate-limited. - Always
jsonify()responses; returning raw dicts is deprecated in modern Flask. - Set
Content-Type: application/jsonon 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
timeoutto 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 |
Related Cheat Sheets¶
- 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¶
- Flask docs — https://flask.palletsprojects.com
- Flask API reference — https://flask.palletsprojects.com/api/
- Deploying with Gunicorn — https://flask.palletsprojects.com/deploying/
Last Updated¶
2026-09-06 — Initial version.