MLOps · Intermediate
Serve a model with FastAPI, and test it
Why it matters
MLOps appeared in 58% of AI/ML Engineer postings we read, and "deploy models to production" is in most of them. Interviewers ask how you would get a model from a notebook to a service that other teams call, and what you would check before trusting it. This lesson builds that service in about 80 lines.
The concept
A minimal, production-shaped model service has:
A saved pipeline, preprocessing and model together, so serving applies exactly the training transformations.
Metadata next to it: version, training date, feature list, evaluation score.
Input validation at the edge, rejecting impossible values before they reach the model.
Endpoints:
/healthfor load balancers and/scorefor predictions, both reporting the model version.Tests that run in CI: health, a known input, and rejection of bad input.
Then package it in a container (see the Docker shelf) and deploy it behind a load balancer.
Worked example: the term deposit model
Train on the UCI Bank Marketing data (CC BY 4.0), using only information known before a call:
"""Train the subscription model and save it with its metadata."""
import json
from datetime import date
import joblib
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder
FEATURES = ["age", "job", "marital", "education", "default", "balance", "housing",
"loan", "contact", "campaign", "pdays", "previous", "poutcome"]
CATEGORICAL = ["job", "marital", "education", "default", "housing", "loan", "contact", "poutcome"]
bank = pd.read_csv("bank-full.csv", sep=";")
X, y = bank[FEATURES], (bank["y"] == "yes").astype(int)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
model = Pipeline([
("prep", ColumnTransformer([
("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), CATEGORICAL),
], remainder="passthrough")),
("gbm", HistGradientBoostingClassifier(random_state=42)),
]).fit(X_train, y_train)
auc = roc_auc_score(y_test, model.predict_proba(X_test)[:, 1])
joblib.dump(model, "model.joblib")
json.dump({"version": "2026.09.1", "trained_on": date.today().isoformat(), "features": FEATURES,
"test_roc_auc": round(auc, 3), "rows": len(X_train)}, open("model.json", "w"), indent=1)
print("saved model, test ROC AUC", round(auc, 3))It prints a test ROC AUC of about 0.77. Now the service:
"""Serve subscription scores over HTTP."""
import json
from typing import Literal
import joblib
import pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel, Field
model = joblib.load("model.joblib")
meta = json.load(open("model.json"))
app = FastAPI(title="Term deposit propensity", version=meta["version"])
class Customer(BaseModel):
age: int = Field(ge=18, le=100)
job: str
marital: Literal["married", "single", "divorced"]
education: Literal["primary", "secondary", "tertiary", "unknown"]
default: Literal["yes", "no"]
balance: int
housing: Literal["yes", "no"]
loan: Literal["yes", "no"]
contact: Literal["cellular", "telephone", "unknown"]
campaign: int = Field(ge=1)
pdays: int
previous: int = Field(ge=0)
poutcome: Literal["success", "failure", "other", "unknown"]
@app.get("/health")
def health() -> dict:
return {"status": "ok", "model_version": meta["version"]}
@app.post("/score")
def score(customers: list[Customer]) -> dict:
frame = pd.DataFrame([c.model_dump() for c in customers])[meta["features"]]
probabilities = model.predict_proba(frame)[:, 1]
return {"model_version": meta["version"], "scores": [round(float(p), 4) for p in probabilities]}And the tests:
from fastapi.testclient import TestClient
from app import app
client = TestClient(app)
customer = {"age": 35, "job": "management", "marital": "married", "education": "tertiary",
"default": "no", "balance": 1500, "housing": "no", "loan": "no", "contact": "cellular",
"campaign": 1, "pdays": 90, "previous": 2, "poutcome": "success"}
def test_health():
assert client.get("/health").json()["status"] == "ok"
def test_score_returns_probability():
body = client.post("/score", json=[customer]).json()
assert 0 <= body["scores"][0] <= 1
def test_rejects_invalid_input():
assert client.post("/score", json=[{**customer, "age": 7}]).status_code == 422pip install fastapi uvicorn scikit-learn pandas joblib pytest httpx
python train.py && pytest -q # 3 passed
uvicorn app:app --port 8000 # then open http://localhost:8000/docsFor the customer above the service returns a score of about 0.71: a previous campaign success is a strong signal. The same customer with no previous contact scores about 0.05.
Common mistakes
Saving only the model, not the pipeline. Serving then re-implements preprocessing, and the two drift apart.
No input validation. A missing field or an age of 7 produces a confident nonsense score instead of an error.
Unversioned models. Every response should say which model produced it, so you can trace complaints.
Loading the model per request. Load once at startup; per-request loading is slow and wasteful.
Interview angle
"How would you deploy this model?" Walk through: pipeline plus metadata artefact, a validated API, tests, a container, a health check, gradual rollout (shadow or canary), and monitoring. Mention the feature-availability rule: only features known at prediction time.
Practice task
Add a
/modelendpoint that returns the metadata.Write a Dockerfile for the service and run it locally.
Log each request's model version, latency and score (not personal data) as one JSON line.