Build a versioned scikit-learn model bundle, describe it with separate metadata, verify its checksum and compatibility during FastAPI's lifespan, and prove that four broken startup cases accept no prediction requests.
A model file can exist at the expected path and still be the wrong file. It may belong to an older feature set, come from another scikit-learn environment, or have changed while it was copied. If an API discovers the problem only after receiving a prediction request, the application has started too early.
To load a scikit-learn model during FastAPI startup, call the loader inside an asynccontextmanager and pass that context manager to FastAPI(lifespan=...). Before the lifespan function reaches yield, compare the model file with trusted metadata, load it once, run a small prediction, and store the verified bundle on app.state. If any check raises an exception, FastAPI does not begin serving requests.
This tutorial builds that boundary for a fictional bike-station model. The main result is not a model score. It is an executed startup contract that accepts one matching artifact and blocks four broken cases before their first request.
You need Python 3.11 or later and basic knowledge of functions, dictionaries, and pandas tables. You should know that a fitted model maps input features, or measured columns, to a prediction. Prior FastAPI experience is helpful but not required.
Create a virtual environment and install the lesson packages:
python -m venv .venv
source .venv/bin/activate
python -m pip install fastapi uvicorn httpx pandas scikit-learn joblib matplotlib
On Windows PowerShell, activate the environment with .venv\Scripts\Activate.ps1. HTTPX supports FastAPI’s in-process test client. Matplotlib is needed only to reproduce the startup-results figure.
The verified build used Python 3.13.2, FastAPI 0.136.1, scikit-learn 1.9.0, pandas 3.0.3, NumPy 2.5.1, joblib 1.5.3, Pydantic 2.13.4, HTTPX 0.28.1, Starlette 1.0.0, and Matplotlib 3.11.0.
Download synthetic_bike_station_intervals.csv and save it beside your training script. This original synthetic dataset is released under CC0 1.0. Its 1,800 station intervals, activity counts, and labels are fictional and do not describe a real transport system.
Load the CSV and inspect a few rows before selecting model columns:
import pandas as pd
station_intervals = pd.read_csv("synthetic_bike_station_intervals.csv")
print(station_intervals.shape)
print(station_intervals.head(3).to_string(index=False))
print(f"empty-soon rate: {station_intervals['empty_within_30m'].mean():.3f}")(1800, 8)
interval_id bikes_available open_docks departures_last_30m arrivals_last_30m hour is_weekend empty_within_30m
BI-0001 7 20 1 2 21 0 0
BI-0002 15 0 3 2 12 0 0
BI-0003 9 10 0 2 21 0 0
empty-soon rate: 0.160One row represents a fictional 30-minute station interval. The target, empty_within_30m, is 1 when the generated label says the station will become empty during the next 30 minutes. The 16% rate is a property of this teaching dataset, not an estimate for a real station network.
A saved model is an artifact: a file produced by training and used later for prediction. Loading that file is only one step in a safer sequence:
files exist
-> checksum matches
-> package version matches
-> model and metadata agree
-> smoke prediction works
-> application becomes readyA checksum is a fixed-length value calculated from a file’s bytes. The same bytes produce the same SHA-256 checksum. If one byte changes, the calculated value should differ. A checksum detects a change relative to trusted metadata; it does not prove who created the file.
The small JSON metadata file is the contract beside the model. It records the model version, feature order, training package version, checksum, and one smoke-test input. A smoke prediction is a small startup check that proves the loaded object can complete its basic prediction path.
FastAPI calls code before and after a lifespan function’s yield. Code before yield runs before requests are accepted, while code after it runs during shutdown. The current FastAPI lifespan guide recommends this pattern for shared resources such as a machine learning model.
This boundary also changes failure behavior. A checksum or schema problem becomes a failed process startup, which deployment tooling can see, instead of a successful startup followed by an unpredictable HTTP error.
The model predicts the generated empty-soon label from six numeric features. Training quality is not the focus, so we use an inspectable pipeline: StandardScaler standardizes each feature, and LogisticRegression produces a class probability.
Define the feature order once. Then split the data, fit the pipeline, and save the fitted pipeline together with its interface fields:
import joblib
import sklearn
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
FEATURE_COLUMNS = [
"bikes_available",
"open_docks",
"departures_last_30m",
"arrivals_last_30m",
"hour",
"is_weekend",
]
MODEL_VERSION = "bike-empty-risk-2026-07-22"
X_train, X_test, y_train, y_test = train_test_split(
station_intervals[FEATURE_COLUMNS],
station_intervals["empty_within_30m"],
test_size=0.20,
stratify=station_intervals["empty_within_30m"],
random_state=260722,
)
pipeline = Pipeline([
("scale", StandardScaler()),
("classify", LogisticRegression(
class_weight="balanced",
max_iter=500,
random_state=260722,
)),
])
pipeline.fit(X_train, y_train)
model_bundle = {
"pipeline": pipeline,
"feature_names": FEATURE_COLUMNS,
"model_version": MODEL_VERSION,
}
joblib.dump(model_bundle, "bike_empty_risk.joblib")
print(X_train.shape, X_test.shape)(1440, 6) (360, 6)The bundle keeps preprocessing and classification together. It also carries the exact feature order and model version used by the API. That duplicate information is intentional: startup can compare the bundle with an independently deployed metadata file.
Joblib uses Python’s pickle mechanism. The scikit-learn model-persistence guide warns that pickle-based files can execute code when loaded and that loading across scikit-learn versions is unsupported. Load this file only when it comes from your trusted build and artifact store. A hash next to an untrusted model does not make that model safe.
Python’s standard hashlib.file_digest() can calculate SHA-256 while reading the model as binary data. The file must be closed after hashing, so use a with block:
import hashlib
import json
def sha256_file(path):
with open(path, "rb") as file_object:
return hashlib.file_digest(file_object, "sha256").hexdigest()
metadata = {
"model_filename": "bike_empty_risk.joblib",
"model_version": MODEL_VERSION,
"sha256": sha256_file("bike_empty_risk.joblib"),
"feature_names": FEATURE_COLUMNS,
"scikit_learn_version": sklearn.__version__,
"training_rows": len(X_train),
"target": "empty_within_30m",
"smoke_input": {
"bikes_available": 3,
"open_docks": 17,
"departures_last_30m": 8,
"arrivals_last_30m": 2,
"hour": 8,
"is_weekend": 0,
},
}
with open("model_metadata.json", "w", encoding="utf-8") as file_object:
json.dump(metadata, file_object, indent=2)The executed model had SHA-256 prefix 658f7e172e39. The exact digest covers the exact joblib bytes, so rebuilding with another library version may produce a different value even when the teaching code looks unchanged. Deploy the newly generated model and its matching metadata together.
model_version identifies the behavior clients receive. The checksum identifies one exact file. They answer different questions, so keep both.
The loader checks cheap, non-executing conditions before joblib.load(). It first requires both files, calculates the model checksum, and compares the package version. Only then does it deserialize the trusted artifact.
hmac.compare_digest() compares the two digest strings while reducing timing information about where they differ. That property is not essential for a local startup file, but the function also makes the digest comparison explicit:
import hmac
from pathlib import Path
def load_verified_bundle(model_path: Path, metadata_path: Path):
if not metadata_path.is_file():
raise RuntimeError("model metadata is missing")
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
if not model_path.is_file():
raise RuntimeError("model artifact is missing")
actual_digest = sha256_file(model_path)
if not hmac.compare_digest(actual_digest, metadata["sha256"]):
raise RuntimeError("model checksum does not match metadata")
if metadata["scikit_learn_version"] != sklearn.__version__:
raise RuntimeError("scikit-learn version does not match training")
bundle = joblib.load(model_path)
if bundle.get("model_version") != metadata["model_version"]:
raise RuntimeError("model version does not match metadata")
if bundle.get("feature_names") != metadata["feature_names"]:
raise RuntimeError("model features do not match metadata")
smoke_frame = pd.DataFrame(
[metadata["smoke_input"]]
)[metadata["feature_names"]]
smoke_probability = float(
bundle["pipeline"].predict_proba(smoke_frame)[0, 1]
)
if not 0.0 <= smoke_probability <= 1.0:
raise RuntimeError("model smoke prediction is invalid")
return bundle, metadataThe feature comparison includes list order. Two lists with the same names in a different order do not match. This matters because many estimators consume columns by position even when your application thinks about them by name.
The smoke check is deliberately small. It proves that this object has the expected method and returns a valid probability for one known-shape row. It does not prove model accuracy, data freshness, or production performance.
Now place the loader before yield. app.state is storage attached to this application instance. Endpoints can read the verified bundle without opening the joblib file again:
from contextlib import asynccontextmanager
from fastapi import FastAPI
MODEL_PATH = Path("bike_empty_risk.joblib")
METADATA_PATH = Path("model_metadata.json")
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.model_load_count = (
getattr(app.state, "model_load_count", 0) + 1
)
bundle, metadata = load_verified_bundle(
MODEL_PATH,
METADATA_PATH,
)
app.state.model_bundle = bundle
app.state.model_metadata = metadata
yield
app.state.model_bundle = None
app.state.model_metadata = None
app = FastAPI(
title="Bike station empty-risk API",
lifespan=lifespan,
)The counter records how many times this application instance enters the load path. The model is loaded once per application process, not once for an entire cluster. If Uvicorn starts four worker processes, each process runs its own lifespan and holds its own model copy. Include that memory cost when choosing a worker count.
Clearing the state after yield releases the application’s references during shutdown. CPU-only scikit-learn objects usually need no special close call, but a model using a GPU runtime or external client may require additional cleanup there.
A readiness endpoint reports whether this process is prepared to receive normal traffic. Because startup cannot reach yield until verification succeeds, /ready is available only after the model passes its gates.
First define a small Pydantic request model. Its field rules keep the example usable, while the separate FastAPI ML input-validation tutorial covers strict API schemas in depth.
from pydantic import BaseModel, ConfigDict, Field
class StationSnapshot(BaseModel):
model_config = ConfigDict(extra="forbid")
station_id: str = Field(min_length=1, max_length=30)
bikes_available: int = Field(ge=0, le=60)
open_docks: int = Field(ge=0, le=60)
departures_last_30m: int = Field(ge=0, le=100)
arrivals_last_30m: int = Field(ge=0, le=100)
hour: int = Field(ge=0, le=23)
is_weekend: int = Field(ge=0, le=1)The endpoints read state through the current request. The prediction path constructs a one-row DataFrame, then selects the stored feature list so station_id cannot reach the estimator:
from fastapi import Request
@app.get("/ready")
def ready(request: Request):
metadata = request.app.state.model_metadata
return {
"status": "ready",
"model_version": metadata["model_version"],
"features": len(metadata["feature_names"]),
}
@app.post("/empty-risk")
def empty_risk(snapshot: StationSnapshot, request: Request):
bundle = request.app.state.model_bundle
feature_row = pd.DataFrame(
[snapshot.model_dump()]
)[bundle["feature_names"]]
probability = float(
bundle["pipeline"].predict_proba(feature_row)[0, 1]
)
return {
"station_id": snapshot.station_id,
"empty_risk": round(probability, 3),
"model_version": bundle["model_version"],
}The executed test request returned:
{'station_id': 'STATION-17',
'empty_risk': 0.943,
'model_version': 'bike-empty-risk-2026-07-22'}The 0.943 value is this fitted model’s probability for one fictional row. It is not a measured chance for a real bike station and does not define when an operator should act. A real service must evaluate representative data, probability calibration, error costs, and an action threshold separately.
FastAPI’s TestClient runs the application without opening a network port. Use it as a context manager so it executes lifespan startup and shutdown. In the healthy case, send several requests and confirm that the load counter remains at one:
from fastapi.testclient import TestClient
with TestClient(app) as client:
ready_response = client.get("/ready")
assert ready_response.status_code == 200
for _ in range(3):
prediction_response = client.post(
"/empty-risk",
json={
"station_id": "STATION-17",
"bikes_available": 3,
"open_docks": 17,
"departures_last_30m": 8,
"arrivals_last_30m": 2,
"hour": 8,
"is_weekend": 0,
},
)
assert prediction_response.status_code == 200
assert client.app.state.model_load_count == 1The complete build also created four controlled failures: a missing model, a changed model file, metadata that specifies a different scikit-learn version, and metadata with a different feature list. Each startup had to raise its expected error before a client could send an application request.
valid_artifacts ready requests accepted
missing_model model artifact is missing blocked
checksum_mismatch model checksum does not match metadata blocked
package_version_mismatch scikit-learn version does not match training blocked
feature_schema_mismatch model features do not match metadata blockedYou can inspect the executed startup result table. Expected and observed results matched in all five cases.
Read the chart as a startup decision, not a performance benchmark. Green means the verified application reached ready state. Red means startup stopped; it does not mean an endpoint returned an HTTP error, because no endpoint became available in those cases.
The model loads inside the endpoint. Every request repeats disk access and deserialization. Move acquisition into lifespan and keep the verified object in application state.
A test never runs startup. Constructing TestClient(app) without a context manager does not give the same lifespan boundary. Use with TestClient(app) as client: when the behavior under test depends on startup or shutdown.
The checksum changes after a rebuild. Confirm that the model and metadata came from the same build. Serialization can also change across library versions. Do not edit the stored hash by hand to silence the failure.
A joblib file came from an upload or unknown URL. Do not load it. The checksum in this tutorial detects a change relative to metadata that you already trust; it does not turn untrusted pickle content into trusted code. Consider a safer persistence format and isolation based on your threat model.
Your development environment uses a different scikit-learn version. Recreate the training environment from pinned dependencies, then rebuild both artifacts. Do not treat a load that happens to succeed across versions as supported compatibility.
Feature names match but predictions are still wrong. Check units, category meanings, missing-value handling, and preprocessing—not only names. A startup schema cannot prove that a client sends kilometers instead of miles or current values instead of stale ones.
Readiness returns 200 before the model works. Keep readiness after the same startup gates used by prediction. A static {"status": "ok"} only proves that Python can return JSON.
Memory grows with more workers. Each process loads a separate model. Measure one worker’s memory and latency before multiplying worker count. For expensive inference, limit concurrent model calls with FastAPI addresses the separate problem of backpressure.
The process restarts repeatedly. Keep startup errors short and specific, but record detailed diagnostics in controlled logs. Never continue with a partly verified artifact only to stop the restart cycle.
The practical boundary is now clear: training creates a versioned bundle and separate metadata; startup checks file presence, checksum, package version, model version, feature order, and one smoke prediction; only then can readiness and prediction routes serve traffic.
In the executed lesson, one verified bundle loaded once and served three prediction requests. Four incompatible cases stopped before requests were accepted. That is the durable outcome to carry forward: if the model artifact cannot prove that it matches the application’s expected interface, the process should fail startup instead of asking the first caller to discover the mismatch.
For the model-building side of this boundary, continue with saving and reloading a complete scikit-learn pipeline. Keep training, artifact creation, startup verification, request validation, and runtime monitoring as separate checks with clear responsibilities.