LEARN · MULTI-MODEL INFERENCE & SERVING
Why a model file is not a product
Training a machine learning model is only half the job. The other half is making it available reliably, safely, and predictably.
A common beginner workflow looks like this:
-
Train a model.
-
Save it with
jobliborpickle. -
Write a tiny web server.
-
Call
.predict().
It works on your laptop. Then real traffic arrives, latency spikes, memory usage climbs, or requests start timing out.
In this tutorial, you’ll build a production-friendly FastAPI service around a scikit-learn model using current, actively maintained APIs and patterns. You’ll learn how to:
-
Load a model exactly once during application startup.
-
Validate incoming requests with Pydantic.
-
Expose prediction and health endpoints.
-
Return consistent JSON responses.
-
Run a simple load test.
-
Avoid a deployment mistake that has caused real production outages.
By the end, you’ll have a runnable service that’s small enough to understand while following practices that scale well from local development to production deployment.
The architecture
A minimal Model Serving stack looks like this.
Client │ ▼ FastAPI │ ├── Request validation │ ▼ Loaded scikit-learn model │ ├── Prediction │ ▼ JSON response
Every request follows the same path:
-
JSON arrives.
-
FastAPI validates the payload.
-
The already-loaded model predicts.
-
The API returns JSON.
The important detail is that the model is loaded before the first request arrives. Every subsequent request reuses the same in-memory object.
Step 1: Train and save a model
We’ll use the classic Iris dataset with a Random Forest classifier.
from sklearn.datasets import load_iris from sklearn.ensemble import RandomForestClassifier import joblib X, y = load_iris(return_X_y=True) model = RandomForestClassifier( n_estimators=200, random_state=42, ) model.fit(X, y) joblib.dump(model, "model.joblib")
Why joblib instead of raw pickle?
-
Widely used throughout the scikit-learn ecosystem.
-
Optimized for NumPy-heavy objects.
-
Simple save/load API.
One important security rule never changes:
Never deserialize a
pickleorjoblibfile from an untrusted source. These formats can execute arbitrary Python code during loading.
Step 2: Create a virtual environment
Create an isolated environment before installing dependencies.
python -m venv .venv
Activate it.
macOS/Linux
source .venv/bin/activate
Windows
.venv\Scripts\activate
Install the latest compatible releases.
pip install fastapi uvicorn pydantic scikit-learn joblib
For tutorials, installing current releases is usually sufficient.
For production deployments, reproducibility comes from pinning exact versions (or using a lockfile generated by tools such as pip-tools, Poetry, or uv) rather than broad version ranges.
Step 3: Build the FastAPI server
Create app.py.
from contextlib import asynccontextmanager import joblib from fastapi import FastAPI from pydantic import BaseModel, Field MODEL = None @asynccontextmanager async def lifespan(app: FastAPI): global MODEL MODEL = joblib.load("model.joblib") yield app = FastAPI(lifespan=lifespan) class PredictionRequest(BaseModel): sepal_length: float = Field(ge=0) sepal_width: float = Field(ge=0) petal_length: float = Field(ge=0) petal_width: float = Field(ge=0) @app.get("/health") async def health(): return { "status": "ok", "model_loaded": MODEL is not None, } @app.post("/predict") async def predict(request: PredictionRequest): features = [[ request.sepal_length, request.sepal_width, request.petal_length, request.petal_width, ]] prediction = MODEL.predict(features)[0] return { "prediction": int(prediction), }
Notice the lifespan function.
FastAPI initializes the model once during application startup. Every request shares that already-loaded instance.
For most scikit-learn models, this single design decision has a larger impact on latency than micro-optimizing prediction code.
Step 4: Run the API
Start the development server.
uvicorn app:app --reload
You should see output similar to:
INFO: Application startup complete. INFO: Uvicorn running on http://127.0.0.1:8000
FastAPI automatically generates interactive documentation.
Open:
http://127.0.0.1:8000/docs
You can test requests directly from your browser without writing client code.
Step 5: Make your first prediction
Send a request with curl.
curl -X POST http://127.0.0.1:8000/predict \
-H "Content-Type: application/json" \
-d '{
"sepal_length": 5.1,
"sepal_width": 3.5,
"petal_length": 1.4,
"petal_width": 0.2
}'
Example response:
{
"prediction": 0
}
Everything is standard JSON, making the API easy to consume from web applications, mobile apps, backend services, or other machine learning systems.
Why input validation matters
Without validation, your model might receive:
-
Missing fields
-
Strings instead of numbers
-
Negative measurements
-
Invalid JSON
-
Unexpected extra data
For example:
{
"sepal_length": "banana"
}
FastAPI and Pydantic reject this request before your prediction code runs.
That means:
-
Cleaner endpoint logic
-
Better client error messages
-
Fewer runtime failures
-
More predictable deployments
Good validation protects both your application and your model.
Health checks are tiny—but invaluable
A simple endpoint like this:
@app.get("/health") async def health(): return { "status": "ok", "model_loaded": MODEL is not None, }
answers an important operational question:
Is this instance actually ready to serve traffic?
Deployment platforms, Kubernetes, container orchestrators, and load balancers all rely on health checks to avoid routing requests to unhealthy instances.
Adding one takes only a few lines of code and immediately improves reliability.
The mistake that quietly destroys performance
A surprisingly common implementation looks like this.
import joblib from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class PredictionRequest(BaseModel): sepal_length: float sepal_width: float petal_length: float petal_width: float @app.post("/predict") async def predict(request: PredictionRequest): model = joblib.load("model.joblib") features = [[ request.sepal_length, request.sepal_width, request.petal_length, request.petal_width, ]] prediction = model.predict(features)[0] return {"prediction": int(prediction)}
It works.
Unfortunately, every request now performs expensive work:
-
Reads the model from disk.
-
Deserializes Python objects.
-
Allocates additional memory.
-
Adds unnecessary latency.
As models become hundreds of megabytes—or even several gigabytes—deserialization dominates request time.
The fix is simple:
Load once. Predict many times.
Load-test your service
A service isn’t production-ready until you’ve observed it under concurrent traffic.
Install Locust.
pip install locust
Create locustfile.py.
from locust import HttpUser, between, task class PredictionUser(HttpUser): wait_time = between(1, 2) @task def predict(self): self.client.post( "/predict", json={ "sepal_length": 5.1, "sepal_width": 3.5, "petal_length": 1.4, "petal_width": 0.2, }, )
Run it.
locust
Open:
http://localhost:8089
Watch these metrics while increasing the number of simulated users:
-
Requests per second
-
Median latency
-
P95 response time
-
Error rate
Even lightweight load testing frequently uncovers problems long before users experience them.
Cherry on the cake: a real outage caused by loading models the wrong way
Loading a model for every request isn’t just an academic anti-pattern.
Engineers at multiple organizations have shared production incidents where inference services repeatedly loaded large machine learning models on each request—or each worker restart—causing severe latency spikes, memory exhaustion, and cascading failures. One widely discussed example came from the Hugging Face ecosystem, where users deploying large transformer models observed requests timing out because model initialization dominated request processing. The solution in each case was the same: initialize expensive model artifacts once during startup and reuse them for every request.
The lesson applies equally to scikit-learn, XGBoost, PyTorch, TensorFlow, and large language models:
-
Initialization belongs in application startup.
-
Prediction belongs in request handlers.
-
Mixing the two is a common cause of slow deployments.
If your model takes two seconds to deserialize and prediction itself takes five milliseconds, you’ve accidentally made your API roughly 400× slower than necessary.
Organizing a growing project
As your API grows, a common project structure looks like this.
project/ ├── app.py ├── model.joblib ├── locustfile.py ├── requirements.txt ├── routers/ ├── services/ ├── models/ ├── tests/ └── README.md
As complexity increases, separate responsibilities:
-
API routing
-
Prediction logic
-
Feature preprocessing
-
Configuration
-
Logging
-
Testing
Keeping concerns isolated makes future deployment changes significantly easier.
Common beginner mistakes
Avoid these early habits:
-
Loading the model inside the request handler.
-
Returning raw NumPy types instead of JSON-compatible values.
-
Skipping request validation.
-
Catching every exception with
except Exception. -
Hardcoding filesystem paths.
-
Forgetting health endpoints.
-
Never load-testing before deployment.
-
Loading untrusted
pickleorjoblibfiles.
None of these mistakes is difficult to fix early.
All of them become harder once clients depend on your API.
Where to go next
Once you’re comfortable serving a single model, the next improvements are natural:
-
Return probabilities with
predict_proba(). -
Bundle preprocessing into a scikit-learn
Pipeline. -
Serve multiple model versions.
-
Package the application with Docker.
-
Add structured logging.
-
Export Prometheus metrics.
-
Add request tracing with OpenTelemetry.
-
Introduce authentication and rate limiting.
-
Deploy behind a reverse proxy or Kubernetes.
-
Version your prediction endpoints.
Notice that none of these improvements requires changing the core prediction flow. That’s the advantage of starting with a clean Model Serving foundation.
Key takeaways
A production-ready machine learning API is less about sophisticated algorithms and more about dependable software engineering.
The habits worth building from day one are simple:
-
Load the model once during startup.
-
Validate every request.
-
Return predictable JSON.
-
Expose health checks.
-
Measure performance under concurrent load.
-
Treat serialized model files as trusted code, not data.
-
Keep deployment concerns separate from prediction logic.
Those practices will serve you well whether you’re deploying a tiny Iris classifier or a fleet of large production inference services.
Call to action
Build the API from this tutorial, run the Locust load test, and record the latency. Then deliberately move joblib.load() into the /predict endpoint and run the same benchmark again. Comparing the two results side by side is one of the fastest ways to understand why good Model Serving, thoughtful FastAPI design, and disciplined Deployment practices matter every bit as much as the machine learning model itself.