Serve a Machine Learning Model with a Django API

AI Published Updated 6 mins read By Leon Wei
Serve a Machine Learning Model with a Django API cover image

Quick summary

Summarize this blog with AI

This tutorial turns a tiny sentiment classifier into a Django JSON API with a clear request contract. You will create three small Python files, run local request tests, and serve the API through Gunicorn. The supplied eight training texts are a fictional teaching fixture: successful API calls do not establish real sentiment accuracy.

Tested on Python 3.12.14, Django 5.2.18, scikit-learn 1.8.0 and Gunicorn 26.2.0 on macOS. Work in a new disposable directory. This application has no database, migrations, user accounts or uploaded model files. It does not reuse an existing Django project.

Create an isolated project and pin the tested dependencies

Create requirements.txt with these versions:

Django==5.2.18
scikit-learn==1.8.0
gunicorn==26.2.0
numpy==2.5.4
scipy==1.18.1
asgiref==3.12.1
sqlparse==0.6.0
joblib==1.6.0
threadpoolctl==3.7.0
cloudpickle==3.1.2
python3.12 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt

Use a supported Python 3.12 installation and keep dependencies reviewed for security updates. The pins record this tested fixture; they are not a promise that every future operating system or hosting build will use the same wheel. Use the virtual environment’s interpreter for every following command.

Train from owned data and return an explicit class label

Save this as sentiment.py. The pipeline is fitted once when a worker imports the module. There is no archive download, tar extraction, pickle or joblib load.

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import make_pipeline

MODEL_VERSION = "toy-sentiment-v1"
TRAINING = [
    ("good great excellent", "positive"),
    ("wonderful enjoyable fun", "positive"),
    ("great acting good story", "positive"),
    ("excellent enjoyable movie", "positive"),
    ("bad awful boring", "negative"),
    ("terrible dull poor", "negative"),
    ("bad acting awful story", "negative"),
    ("boring terrible movie", "negative"),
]
model = make_pipeline(CountVectorizer(), MultinomialNB())
model.fit([text for text, label in TRAINING], [label for text, label in TRAINING])

def predict_label(text):
    if not isinstance(text, str) or not 1 <= len(text) <= 500 or not text.strip():
        raise ValueError("text must contain 1 to 500 characters and not be blank")
    if model.named_steps["countvectorizer"].transform([text]).nnz == 0:
        raise ValueError("text contains no known training terms")
    return str(model.predict([text])[0])

The vectorizer learns its vocabulary from the supplied training texts and the classifier learns from their labels. We extract the single prediction by index and convert it to a string; we do not use the truth value of a prediction array to guess which class is positive. A text with no known vocabulary is rejected instead of receiving an arbitrary prior-driven label. Known words still do not guarantee understanding: negation, sarcasm and distribution shift remain unresolved.

Create a bounded, authenticated JSON endpoint

Save this as service.py. POST /predict/ accepts exactly one text field, at most 500 characters, in at most 4096 bytes of uncompressed JSON. A bearer token is required. Authentication is stateless: this example has no cookies or sessions. If you introduce cookie-based authentication, add the appropriate CSRF protection rather than copying this middleware list unchanged.

import hmac
import json
import os
import re
from django.conf import settings
from django.core.wsgi import get_wsgi_application
from django.http import JsonResponse
from django.urls import path
from sentiment import MODEL_VERSION, predict_label

mode = os.environ.get("DEPLOYMENT", "local")
if mode not in {"local", "heroku"}:
    raise ValueError("DEPLOYMENT must be local or heroku")
secret = os.environ["DJANGO_SECRET_KEY"]
token = os.environ["API_TOKEN"]
if len(secret) < 50 or len(token) < 32:
    raise ValueError("use separately generated long secrets")
if mode == "heroku":
    host = os.environ["PUBLIC_HOST"]
    if len(host) > 253 or not re.fullmatch(r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", host):
        raise ValueError("PUBLIC_HOST must be an exact lowercase hostname")
    allowed_hosts = [host]
else:
    allowed_hosts = ["localhost", "127.0.0.1"]
settings.configure(
    SECRET_KEY=secret, DEBUG=False, ROOT_URLCONF=__name__,
    ALLOWED_HOSTS=allowed_hosts, INSTALLED_APPS=[],
    MIDDLEWARE=["django.middleware.security.SecurityMiddleware",
                "django.middleware.common.CommonMiddleware",
                "django.middleware.clickjacking.XFrameOptionsMiddleware"],
    SECURE_SSL_REDIRECT=(mode == "heroku"),
    SECURE_PROXY_SSL_HEADER=("HTTP_X_FORWARDED_PROTO", "https") if mode == "heroku" else None,
    SECURE_CONTENT_TYPE_NOSNIFF=True, X_FRAME_OPTIONS="DENY",
    SESSION_COOKIE_SECURE=True, CSRF_COOKIE_SECURE=True,
    DATA_UPLOAD_MAX_MEMORY_SIZE=4096,
)

def health(request):
    return JsonResponse({"status": "ready", "model_version": MODEL_VERSION})

def reject_duplicates(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError("duplicate JSON key")
        result[key] = value
    return result

def predict(request):
    if request.method != "POST":
        response = JsonResponse({"error": "POST required"}, status=405)
        response["Allow"] = "POST"
        return response
    authorization = request.headers.get("Authorization", "")
    expected = "Bearer " + token
    if not hmac.compare_digest(authorization.encode("utf-8"), expected.encode("utf-8")):
        response = JsonResponse({"error": "unauthorized"}, status=401)
        response["WWW-Authenticate"] = "Bearer"
        return response
    if request.content_type != "application/json" or request.headers.get("Content-Encoding", "identity") != "identity":
        return JsonResponse({"error": "uncompressed application/json required"}, status=415)
    raw = request.read(4097)
    if len(raw) > 4096:
        return JsonResponse({"error": "request too large"}, status=413)
    try:
        payload = json.loads(raw, object_pairs_hook=reject_duplicates)
    except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
        return JsonResponse({"error": "invalid JSON"}, status=400)
    if not isinstance(payload, dict) or set(payload) != {"text"}:
        return JsonResponse({"error": "expected exactly one text field"}, status=400)
    try:
        label = predict_label(payload["text"])
    except ValueError as error:
        return JsonResponse({"error": str(error)}, status=422)
    return JsonResponse({"label": label, "model_version": MODEL_VERSION})

urlpatterns = [path("health/", health), path("predict/", predict)]
application = get_wsgi_application()

Generate separate local secrets in the terminal environment, without placing them in source control. These commands set values without printing them:

export DJANGO_SECRET_KEY="$(.venv/bin/python -c 'import secrets; print(secrets.token_urlsafe(48))')"
export API_TOKEN="$(.venv/bin/python -c 'import secrets; print(secrets.token_urlsafe(32))')"

Only the local mode is used in this lesson; it permits localhost and 127.0.0.1. The Heroku mode is explained in the companion deployment guide. The size check limits bytes read by the view, not every buffer or connection at the front server. Production service limits, rate controls, token lifecycle, transport security and operational monitoring still need an explicit deployment design.

Run the API tests before starting a server

Save this as test_service.py, then run .venv/bin/python test_service.py. Django’s test client invokes the application directly and creates no database.

import json
import os
import service
from django.test import Client

client = Client(HTTP_HOST="localhost")
headers = {"HTTP_AUTHORIZATION": "Bearer " + os.environ["API_TOKEN"]}
def post(payload, **extra):
    return client.post("/predict/", data=json.dumps(payload),
                       content_type="application/json", **headers, **extra)

positive = post({"text": "great enjoyable"})
negative = post({"text": "awful boring"})
assert positive.status_code == 200 and positive.json()["label"] == "positive"
assert negative.status_code == 200 and negative.json()["label"] == "negative"
assert client.get("/health/").json()["model_version"] == "toy-sentiment-v1"
assert client.get("/predict/").status_code == 405
assert client.post("/predict/", data="{}", content_type="application/json").status_code == 401
assert post({}).status_code == 400
assert post({"text": "good", "extra": 1}).status_code == 400
assert post({"text": ""}).status_code == 422
assert post({"text": None}).status_code == 422
assert post({"text": "x" * 501}).status_code == 422
assert post({"text": "zzzzunknown"}).status_code == 422
assert client.post("/predict/", data='{"text":', content_type="application/json", **headers).status_code == 400
assert client.post("/predict/", data='{"text":"good","text":"bad"}', content_type="application/json", **headers).status_code == 400
assert client.post("/predict/", data="x" * 4097, content_type="application/json", **headers).status_code == 413
assert client.post("/predict/", data="good", content_type="text/plain", **headers).status_code == 415
assert post({"text": "good"}, HTTP_HOST="unexpected.example").status_code == 400
print("positive negative; 16 API checks passed")

Expected output:

positive negative; 16 API checks passed

The tests cover both labels, the health response, method/authentication errors, invalid JSON/schema, duplicate keys, blank/missing/oversized text, unknown terms, body size, content type and host validation. A real server test additionally checks the network entry point; the revision also exercised this fixture through a loopback Gunicorn process.

Serve on loopback and inspect one response

.venv/bin/python -m gunicorn service:application --bind 127.0.0.1:8000 --workers 1 --timeout 30

From a second terminal with the same API_TOKEN environment value, send a request. The access token belongs in the header, not the URL:

curl --fail-with-body --max-time 5 http://127.0.0.1:8000/predict/   -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json"   --data '{"text":"great enjoyable"}'

Expected output:

{"label": "positive", "model_version": "toy-sentiment-v1"}

Keep this listener on loopback. HTTP is appropriate only for this local test; use HTTPS before sending a real token across a network. Stop the server when finished. Retraining on startup is acceptable for this tiny fixture, but a real system should separately version and validate training data, model artifacts and dependencies. Pickle-based formats must never load untrusted models. Continue to the Heroku companion for deployment configuration and verification boundaries.

References and next steps

Django request/response API, Django deployment checklist, scikit-learn model persistence risks, Multinomial naive Bayes.

Interview Prep

Begin Your SQL, Python, and R Journey

Master 230 interview-style coding questions and build the data skills needed for analyst, scientist, and engineering roles.

Related Articles

All Articles
Flask vs django cover image
python Oct 17, 2026

Flask vs django

Introduction to Flask and Django Welcome to the fascinating world of Python web development, where Flask and Django shine as two of the most po…