How-toBeginner
How to Deploy a Machine Learning Model as an API with FastAPI: A Beginner's Guide (Food Delivery ML Series, Part 4)
The short answer
A model sitting in a notebook helps nobody. In this guide you turn Part 3's saved pipeline into a web API with FastAPI: any app can send it a raw order and get back a predicted delivery time in about 16 milliseconds.
Quick answer: To deploy a machine learning model as an API in Python: (1) save the trained pipeline with joblib, (2) install FastAPI and Uvicorn, (3) load the pipeline once when the app starts, (4) describe the expected input with a Pydantic model, (5) write a/predictendpoint that turns the input into a one-row DataFrame and callspipeline.predict(), (6) run it withuvicorn app:appand test it at/docs, and (7) package it with Docker so it runs the same anywhere.
What you will build
A small web service, the Delivery Time API, with two addresses:
| Address | What it does | Who uses it |
GET /health | Answers "I'm alive" and reports the model version | Monitoring systems checking the service is up |
POST /predict | Takes one raw order and returns the predicted minutes | The delivery app, every time a customer places an order |
Send it Part 3's messiest order, with the zone typed " old TOWN", distance in metres and no prep time, and it replies:
{"predicted_minutes": 63.8, "model_version": "part3-linear-v1"}
That's the same 63.8 minutes Part 3's notebook gave, now available to any app in any programming language. Along the way you will:
- learn what an API is, and how requests, responses and JSON work;
- write the API in about 50 lines, with a line-by-line explanation;
- try it in your browser with FastAPI's automatic, interactive documentation;
- make it reject bad input clearly instead of returning nonsense predictions;
- write automatic tests so you know it still works after every change;
- package it with Docker so it runs the same on your laptop and on a server;
- learn how to judge a model once it's live: speed, consistency, errors and drift.
How this part connects to the series
This is Part 4 of the Delivery ML series, and it's where the series' earlier work pays off. In Part 2, the saved model needed a feature step run by hand. Part 3 fixed that by saving one pipeline that takes raw orders, typos and all. That design is what makes this part simple: the API just passes each order to the pipeline. Parts 5 and 6 will tune and explain the model; this API is where those improvements will reach users.
Prerequisites
You need Part 3's two saved files, comfort with Python functions, and about an hour. Docker is optional: everything before Step 7 works without it.
From earlier in the series
| Part | What you need from it | Must have done it? |
| Part 3: Clean Messy, Real-World Delivery Data | The files delivery_features.py and delivery_pipeline.joblib, and the idea that a pipeline takes raw orders | Yes. This part serves that exact pipeline. |
| Part 2: Predict the Exact Delivery Time | What a prediction in minutes means; MAE | Recommended |
| Part 1: Your First Machine Learning Model | .fit() and .predict() | Recommended |
Don't have Part 3's files? Run Part 3's complete script once; it creates delivery_pipeline.joblib in about a second.
Knowledge
| Topic | Level needed | Quick self-check |
| Python functions | Required | Can you write a function that takes an argument and returns a dictionary? |
| Python classes | Helpful | Have you seen class Something: before? You only need to fill in a template. |
| Dictionaries | Required | Do you know that {"distance_km": 6.0} maps a name to a value? |
| Terminal | Required | Can you open a terminal, change folder with cd and run a command? |
| Web development | Not required | No HTML, JavaScript or networking knowledge needed |
Software
| Item | Version | Notes |
| Python | 3.10 or newer | Tested with 3.13 |
| scikit-learn, pandas, NumPy, joblib | The same versions you used in Part 3 | The saved pipeline must be loaded with the scikit-learn version that saved it. Tested with scikit-learn 1.9.1. |
| FastAPI | Recent | The web framework. Tested with 0.162 |
| Uvicorn | Recent | The server that runs FastAPI. Tested with 0.54 |
| requests | Recent | To call the API from Python |
| pytest and httpx | Recent | For automatic tests (Step 6) |
| Docker Desktop | Optional | Only for Step 7 |
A note on Colab: Google Colab is great for training models, but it isn't designed to run a web server that you can open in your browser. For this part, use your own computer.
Hardware, time and cost
- Computer: any laptop. The API uses very little memory or CPU.
- Time: about 60 minutes, plus 15 for Docker if you do Step 7.
- Cost: free. Everything runs on your own machine.
An API (application programming interface) is a way for one program to ask another for something. The app sends a request with an order; our API sends back a response with the predicted minutes. Neither needs to know how the other works inside.
Think of a restaurant. You (the app) don't walk into the kitchen (the model). You give your order to a waiter (the API) in a standard format, from the menu. The waiter takes it to the kitchen and brings back your food. The kitchen could change chefs or recipes, and as long as the menu stays the same, you'd never notice.
This is the API you'll build. A valid order flows left to right and the answer comes back over the top. An invalid one is turned away with a clear 422 before it can produce a misleading prediction (Step 5).
Requests and responses
Every conversation with a web API is one request and one response:
| Part | What it is | In our API |
| URL | The address of the service | http://127.0.0.1:8000 (your own computer, port 8000) |
| Endpoint | A specific address within it, for one job | /predict or /health |
| Method | The kind of request | GET = "give me information"; POST = "here's some data, do something with it" |
| Request body | The data sent with a POST | The order, as JSON |
| Status code | A number saying how it went | 200 = OK; 422 = your input was invalid; 500 = the server broke |
| Response body | The data sent back | The predicted minutes, as JSON |
JSON: the common language
Apps written in JavaScript, Swift, Kotlin or Java can't read a pandas DataFrame. So APIs exchange data as JSON (JavaScript Object Notation), a simple text format that every language understands. It looks almost exactly like a Python dictionary:
{
"order_time": "2026-10-03 20:15",
"city_zone": " old TOWN",
"distance_km": 6000,
"prep_time_min": null
}
The main differences from Python: JSON uses null instead of None, true/false instead of True/False, and always double quotes. FastAPI converts between JSON and Python for you automatically.
Why put a model behind an API?
- Any app can use it. The iPhone app, the Android app, the website and the operations dashboard all call the same address. Nobody needs Python or scikit-learn installed.
- One place to update. Retrain a better model, swap the file on the server, and every app gets better predictions instantly, with no app-store update.
- Separation of jobs. App developers work on screens and buttons; the ML team works on the model. The API is the agreement between them.
- It's how almost all ML reaches users. Recommendations, fraud checks, translation and even ChatGPT are served to apps through APIs.
Why FastAPI?
FastAPI is a popular Python library for building APIs. It suits ML beginners because it:
- uses ordinary Python functions for endpoints;
- checks incoming data automatically against a description you write (using a library called Pydantic);
- creates interactive documentation you can try in your browser, with no extra code;
- is fast enough for most real-world use.
Uvicorn is the program that actually runs your FastAPI app and listens for requests. FastAPI defines what to do; Uvicorn handles the network.
Step 1: Set up the project folder
Create a new folder, copy in Part 3's two files, and install four small libraries. Your API project will have seven files when it's finished.
Create the folder and copy Part 3's files
cd first-ml-model
source venv/bin/activate # Windows: venv\Scripts\activate
mkdir part4-api
cd part4-api
# Copy the pipeline and the feature code from Part 3
cp ../part3-clean-data/delivery_features.py .
cp ../part3-clean-data/delivery_pipeline.joblib .
On Windows, use copy ..\part3-clean-data\delivery_features.py . (and the same for the .joblib file), or copy the files in File Explorer.
Install the API libraries
Use the same virtual environment as Part 3, so scikit-learn stays at the version that saved the pipeline:
pip install fastapi "uvicorn[standard]" requests pytest httpx
| Library | Role |
fastapi | Defines the API: endpoints, input checks, documentation |
uvicorn[standard] | Runs the API and listens for requests (the [standard] part adds faster optional components) |
requests | Lets Python code call the API, like an app would |
pytest | Runs automatic tests |
httpx | Needed by FastAPI's test tools |
The finished project
| File | What it holds | Created in |
delivery_features.py | Part 3's clean_orders function | Copied from Part 3 |
delivery_pipeline.joblib | Part 3's trained pipeline | Copied from Part 3 |
app.py | The API | Step 2 |
client.py | A small program that calls the API | Step 4 |
test_app.py | Automatic tests | Step 6 |
requirements.txt and Dockerfile | Instructions for packaging | Step 7 |
This is a realistic, minimal ML service layout: the model, the feature code it depends on, the API, its tests and its packaging.
Step 2: Write the API
The whole API is about 40 lines: load the pipeline once, describe what an order looks like, and write one function that turns an order into a prediction.
Save this as app.py in your part4-api folder. (In Step 5 you'll add safety checks; this is the first working version.)
"""Delivery ML Series, Part 4: serve the delivery-time pipeline as a web API."""
import joblib
import pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel, Field
import delivery_features # noqa: F401 -- joblib needs this module to rebuild the pipeline
MODEL_VERSION = "part3-linear-v1"
# Load the pipeline ONCE, when the server starts
pipeline = joblib.load("delivery_pipeline.joblib")
app = FastAPI(title="Delivery Time API", version="1.0")
class Order(BaseModel):
"""One raw order, exactly as the delivery app sends it."""
order_time: str = Field(examples=["2026-10-03 20:15"])
city_zone: str = Field(examples=[" old TOWN"])
cuisine: str = Field(examples=["Biryani"])
distance_km: float = Field(gt=0, examples=[6000])
prep_time_min: float | None = Field(default=None, ge=0, examples=[None])
weather: str = Field(examples=["RAINY"])
rider_experience_months: float | None = Field(default=None, ge=0, examples=[6])
class Prediction(BaseModel):
predicted_minutes: float
model_version: str
@app.get("/health")
def health():
return {"status": "ok", "model_version": MODEL_VERSION}
@app.post("/predict", response_model=Prediction)
def predict(order: Order):
row = pd.DataFrame([order.model_dump()])
minutes = float(pipeline.predict(row)[0])
return Prediction(predicted_minutes=round(minutes, 1), model_version=MODEL_VERSION)
The code, line by line
| Code | What it does | Why |
import delivery_features | Imports Part 3's feature file | The saved pipeline contains a reference to clean_orders; joblib needs this module importable to rebuild it. # noqa: F401 just tells code checkers "yes, this import is deliberate". |
MODEL_VERSION = "part3-linear-v1" | A label for the model being served | Every prediction says which model made it. When you deploy a better model, you'll know which answers came from which. |
pipeline = joblib.load(...) | Loads the pipeline from disk | It sits outside any function, so it runs once when the server starts, not on every request. Loading takes far longer than predicting. |
app = FastAPI(title=..., version=...) | Creates the API | The title and version appear in the automatic documentation |
class Order(BaseModel): | Describes what a valid order looks like | A Pydantic model: FastAPI uses it to check every incoming request automatically |
order_time: str | This field must be text | A request where it's missing or not text is rejected |
distance_km: float = Field(gt=0, ...) | Must be a number greater than 0 | A negative distance is rejected before it reaches the model. Values in metres (6000) are allowed, because clean_orders fixes them. |
prep_time_min: float | None = Field(default=None, ge=0, ...) | A number ≥ 0, or missing | | None with default=None makes the field optional, because Part 3's pipeline knows how to fill a missing prep time |
examples=[...] | Sample values | Pre-fill the "Try it out" form in the documentation (Step 3) |
class Prediction(BaseModel): | Describes the response | Guarantees every answer has the same shape |
@app.get("/health") | Connects the next function to GET /health | The @ line is a decorator: it registers the function as the handler for that address |
@app.post("/predict", response_model=Prediction) | Connects the next function to POST /predict | response_model makes FastAPI check and document the output too |
def predict(order: Order): | The function that runs for each request | The : Order type hint tells FastAPI to turn the incoming JSON into a checked Order object |
order.model_dump() | Turns the Order back into a plain dictionary | Pydantic object → {"order_time": ..., ...} |
pd.DataFrame([...]) | A one-row table | The pipeline was trained on DataFrames, so it must receive one, with the same column names |
float(pipeline.predict(row)[0]) | Predicts, takes the first (only) value, and makes it a plain Python number | predict returns a NumPy array; JSON needs a normal number |
round(minutes, 1) | One decimal place | 63.8 is more useful to an app than 63.79414… |
The key idea: the API doesn't clean anything itself
Notice there's no cleaning code in app.py. Fixing " old TOWN", converting 6000 metres and filling the missing prep time all happen inside the pipeline, exactly as in Part 3. That's the payoff of Part 3's design: the API's only job is to translate between JSON and the pipeline.
Step 3: Run the API and try it in your browser
One command starts the server. FastAPI then gives you a free, interactive web page where you can send orders and see predictions, with no extra code.
Start the server
In the terminal, inside part4-api:
uvicorn app:app --reload
Output (the important lines):
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Application startup complete.
| Part of the command | Meaning |
uvicorn | The server program |
app:app | "In the file app.py, run the object called app" |
--reload | Restart automatically whenever you save app.py. Handy while developing; leave it out in production. |
127.0.0.1 | Your own computer (also called localhost). Only you can reach it. |
8000 | The port: like a door number on your computer. Different services use different ports. |
Leave this terminal running. The server is now waiting for requests. Press Ctrl + C in that terminal when you want to stop it.
Check it's alive
Open this address in your browser:
http://127.0.0.1:8000/health
You'll see:
{"status":"ok","model_version":"part3-linear-v1"}
Your browser just sent a GET request, and your API answered. Look at the terminal: a new line appears for each request, such as "GET /health HTTP/1.1" 200 OK.
Use the interactive documentation
Now open:
http://127.0.0.1:8000/docs
FastAPI has built a complete documentation page from your code: the title, both endpoints, and the exact fields an order needs. To make a prediction:
- Click POST /predict to expand it.
- Click Try it out. The request body is pre-filled with the
examplesfrom yourOrderclass: the messy Biryani order. - Click Execute.
- Scroll down to Server response. You'll see code 200 and the body:
{
"predicted_minutes": 63.8,
"model_version": "part3-linear-v1"
}
That's the same 63.8 minutes as Part 3. The API is serving the same pipeline, with the same answers.
Try editing the request in the box: change "weather" to "Clear" or "distance_km" to 2, click Execute again, and watch the prediction change. Scroll further down the page to Schemas, which shows the Order and Prediction descriptions that app developers would read to learn how to use your API.
Why this matters: in a real team, this page is the documentation. App developers can learn and test your API without reading any Python, and it updates automatically whenever you change the code.
Step 4: Call the API like an app would
Real apps don't click buttons on **/docs**; they send requests from code. Here's how from Python and from the command line. Each prediction comes back in about 16 milliseconds.
Keep the server running in its terminal and open a second terminal for this step.
From Python
Save this as client.py and run python client.py:
"""Call the Delivery Time API from Python, like an app would."""
import time
import requests
API_URL = "http://127.0.0.1:8000/predict"
order = {
"order_time": "2026-10-03 20:15",
"city_zone": " old TOWN",
"cuisine": "Biryani",
"distance_km": 6000,
"prep_time_min": None,
"weather": "RAINY",
"rider_experience_months": 6,
}
response = requests.post(API_URL, json=order, timeout=5)
print(response.status_code)
print(response.json())
# How fast is it? Time 100 requests
start = time.perf_counter()
for _ in range(100):
requests.post(API_URL, json=order, timeout=5)
elapsed_ms = (time.perf_counter() - start) / 100 * 1000
print(f"Average response time: {elapsed_ms:.1f} ms")
Output:
200
{'predicted_minutes': 63.8, 'model_version': 'part3-linear-v1'}
Average response time: 16.0 ms
| Code | What it does |
requests.post(API_URL, json=order, ...) | Sends a POST request; json=order converts the Python dictionary to JSON for you, including None → null |
timeout=5 | Gives up after 5 seconds instead of waiting forever if the server is down. Always set a timeout in real code. |
response.status_code | 200 means success |
response.json() | Converts the JSON reply back into a Python dictionary |
time.perf_counter() | A precise stopwatch, used to measure the average time per request |
Your response time will differ: it depends on your computer. Anything well under 100 ms feels instant to a customer. Where do those 16 ms go? On our test machine, the whole pipeline took about 9.7 ms per order, of which the linear model itself took just 0.13 ms; the rest was cleaning, imputing and encoding. The remaining 6 ms or so is the web request travelling to the server and back. Data preparation, not the model, is usually the slow part.
Try a different order
A calm, dry weekday-afternoon burger order, 3 km from Central, from a kitchen that reported 12 minutes of prep:
quick_order = {
"order_time": "2026-10-07 15:30",
"city_zone": "Central",
"cuisine": "Burger",
"distance_km": 3.0,
"prep_time_min": 12,
"weather": "Clear",
"rider_experience_months": 24,
}
print(requests.post(API_URL, json=quick_order, timeout=5).json())
{'predicted_minutes': 18.3, 'model_version': 'part3-linear-v1'}
18.3 minutes, against 63.8 for the rainy Saturday-evening biryani across Old Town. The model is responding to the order's details exactly as in Part 3.
From the command line with curl
curl is a command-line tool for web requests, built into macOS, Linux and recent Windows versions. It's how developers quickly test any API:
curl -X POST http://127.0.0.1:8000/predict \
-H "Content-Type: application/json" \
-d '{"order_time": "2026-10-03 20:15", "city_zone": " old TOWN", "cuisine": "Biryani", "distance_km": 6000, "prep_time_min": null, "weather": "RAINY", "rider_experience_months": 6}'
{"predicted_minutes":63.8,"model_version":"part3-linear-v1"}
| Part | Meaning |
-X POST | Use the POST method |
-H "Content-Type: application/json" | A header telling the server "the body is JSON" |
-d '...' | The data: the request body |
(Windows Command Prompt handles quotes differently. If this fails there, use PowerShell, Git Bash, or the Python client above.)
The point: Python, curl, a JavaScript web page or an Android app would all send this same JSON and get back the same answer. That's what makes an API universal.
Step 5: Handle bad input safely
FastAPI already rejects missing or wrongly typed fields. But some bad inputs slip through and produce confident, wrong predictions: a zone spelled "Old-Town" is silently treated as Central, so the answer is off by 7.7 minutes. A few lines of checks turn these silent errors into clear rejections.
What Pydantic catches for free
Because of the Order class, FastAPI checks every request before your code runs. A request with no distance_km:
curl -X POST http://127.0.0.1:8000/predict -H "Content-Type: application/json" \
-d '{"order_time": "2026-10-03 20:15", "city_zone": "Old Town", "cuisine": "Biryani", "weather": "Rain"}'
{"detail":[{"type":"missing","loc":["body","distance_km"],"msg":"Field required", ...}]}
And a negative distance ("distance_km": -3):
{"detail":[{"type":"greater_than","loc":["body","distance_km"],"msg":"Input should be greater than 0", ...}]}
Both come back with status code 422 (Unprocessable Content) and a message saying exactly which field is wrong and why. The same happens for text where a number belongs ("distance_km": "six").
What slips through: the dangerous cases
Pydantic only checks types and ranges. It can't know which zone names or weather words our cleaning rules understand. With the Step 2 version of the API:
| Input | What happens | Why it's a problem |
"city_zone": "Old-Town" | 200 OK: 56.1 minutes | clean_orders doesn't recognise the hyphen, so the zone becomes missing and the imputer fills in the most common zone, Central. The real Old Town answer is 63.8, so the prediction is 7.7 minutes too optimistic, and nobody is told. |
"weather": "Drizzle" | 200 OK: 51.3 minutes | "Drizzle" isn't in the weather table, so it's silently treated as not raining |
"order_time": "next Friday" | 500 Internal Server Error | pd.to_datetime crashes. The app gets an unhelpful error, and it looks like our server is broken. |
The first two are the worst kind of bug: the API looks perfectly healthy while giving wrong answers. That's Part 3's lesson again ("a cleaning step that silently fails is worse than no cleaning"), now with real customers on the other end.
Add the checks
Update app.py. Change the imports at the top:
from fastapi import FastAPI, HTTPException
from delivery_features import clean_orders
(Importing clean_orders also loads the delivery_features module, so the separate import delivery_features line is no longer needed.) Then replace the predict function:
@app.post("/predict", response_model=Prediction)
def predict(order: Order):
row = pd.DataFrame([order.model_dump()])
# Reject values our cleaning rules don't recognise, instead of guessing silently
try:
cleaned = clean_orders(row)
except (ValueError, TypeError):
raise HTTPException(status_code=422, detail="order_time must look like 2026-10-03 20:15")
if cleaned["city_zone"].isna().any():
raise HTTPException(status_code=422, detail=f"Unknown city_zone: {order.city_zone!r}")
if cleaned["is_raining"].isna().any():
raise HTTPException(status_code=422, detail=f"Unknown weather: {order.weather!r}")
minutes = float(pipeline.predict(row)[0])
return Prediction(predicted_minutes=round(minutes, 1), model_version=MODEL_VERSION)
| Code | What it does |
cleaned = clean_orders(row) | Runs Part 3's cleaning rules on the order before predicting, so we can inspect the result |
try: ... except (ValueError, TypeError): | If cleaning crashes (an unreadable date), catch the error instead of letting the server return a 500 |
raise HTTPException(status_code=422, detail=...) | Stops the function and sends the app a 422 with a clear message |
cleaned["city_zone"].isna().any() | True if the zone couldn't be mapped, i.e. .map() produced a missing value |
{order.city_zone!r} | Puts the bad value in the message; !r shows it in quotes, so stray spaces are visible |
The same inputs, now
With --reload running, the server restarts as soon as you save. Send the bad inputs again:
| Input | Response |
"city_zone": "Old-Town" | 422 {"detail":"Unknown city_zone: 'Old-Town'"} |
"weather": "Drizzle" | 422 {"detail":"Unknown weather: 'Drizzle'"} |
"order_time": "next Friday" | 422 {"detail":"order_time must look like 2026-10-03 20:15"} |
| The original messy Biryani order | 200 {"predicted_minutes":63.8, ...}: still accepted, because every messy value in it is recognised |
Now the app knows something is wrong and can react: ask the customer to re-select their area, fall back to a default estimate, and log the new spelling so someone can add it to ZONES.
Why not just guess?
Notice the difference from Part 3. There, a missing prep time is filled in on purpose: "missing" is expected, and the pipeline has a sensible plan for it. An unrecognised zone is different: it means the data contains something our rules have never seen. Guessing hides that. Fill in values you expect to be missing; reject values you don't understand.
Status codes worth knowing
| Code | Name | Meaning | In our API |
| 200 | OK | It worked | A successful prediction |
| 404 | Not Found | No such address | A typo in the URL, such as /predcit |
| 405 | Method Not Allowed | Right address, wrong method | Sending GET to /predict |
| 422 | Unprocessable Content | The request was understood but the data is invalid | Missing fields, negative distance, unknown zone |
| 500 | Internal Server Error | The server crashed | What "next Friday" caused before the fix: always a bug to fix |
Rule of thumb: 4xx codes mean the caller sent something wrong; 5xx codes mean your server has a bug. A well-built API returns 4xx, with a helpful message, for every kind of bad input.
Step 6: Test the API automatically
Clicking around **/docs** proves the API works today. Six short automatic tests prove it still works after every change, in under two seconds, without even starting the server.
Why test?
In the coming parts you'll tune the model (Part 5), add explanations (Part 6) and try new algorithms (Part 7), each time replacing the pipeline file or editing app.py. Any of those changes could quietly break something: a renamed column, a removed check, a new model that gives very different answers. Tests catch that in seconds, before a customer does.
Write the tests
Save this as test_app.py:
"""Automatic tests for the Delivery Time API. Run with: pytest"""
from fastapi.testclient import TestClient
from app import app
client = TestClient(app)
MESSY_ORDER = {
"order_time": "2026-10-03 20:15",
"city_zone": " old TOWN",
"cuisine": "Biryani",
"distance_km": 6000,
"prep_time_min": None,
"weather": "RAINY",
"rider_experience_months": 6,
}
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_predict_matches_part3():
response = client.post("/predict", json=MESSY_ORDER)
assert response.status_code == 200
assert response.json()["predicted_minutes"] == 63.8
def test_missing_field_is_rejected():
order = {k: v for k, v in MESSY_ORDER.items() if k != "distance_km"}
response = client.post("/predict", json=order)
assert response.status_code == 422
def test_negative_distance_is_rejected():
response = client.post("/predict", json={**MESSY_ORDER, "distance_km": -3})
assert response.status_code == 422
def test_unknown_zone_is_rejected():
response = client.post("/predict", json={**MESSY_ORDER, "city_zone": "Old-Town"})
assert response.status_code == 422
assert "Unknown city_zone" in response.json()["detail"]
def test_bad_date_is_rejected():
response = client.post("/predict", json={**MESSY_ORDER, "order_time": "next Friday"})
assert response.status_code == 422
Run them
In the terminal (the server doesn't need to be running):
pytest -v
Output:
test_app.py::test_health PASSED [ 16%]
test_app.py::test_predict_matches_part3 PASSED [ 33%]
test_app.py::test_missing_field_is_rejected PASSED [ 50%]
test_app.py::test_negative_distance_is_rejected PASSED [ 66%]
test_app.py::test_unknown_zone_is_rejected PASSED [ 83%]
test_app.py::test_bad_date_is_rejected PASSED [100%]
============================== 6 passed in 1.49s ===============================
How the tests work
| Code | What it does |
TestClient(app) | A fake browser that sends requests straight to your app in memory: no server, no network, very fast |
def test_...(): | pytest automatically finds and runs every function whose name starts with test_ in files whose names start with test_ |
assert ... | Checks a condition. If it's false, that test fails and pytest shows exactly which line and why. |
{**MESSY_ORDER, "distance_km": -3} | Copies the good order and overrides one field: an easy way to make one bad value at a time |
{k: v for k, v in ... if k != "distance_km"} | Copies the order without one field |
What each test protects
| Test | It would fail if… |
test_health | The server can't start or the health endpoint breaks |
test_predict_matches_part3 | The model, the cleaning or the saved file changes the answer for a known order |
test_missing_field_is_rejected | Someone loosens the Order rules by mistake |
test_negative_distance_is_rejected | The gt=0 check is removed |
test_unknown_zone_is_rejected | Step 5's zone check is removed, and silent guessing comes back |
test_bad_date_is_rejected | Bad dates start causing 500 errors again |
About **test_predict_matches_part3**: when you deliberately deploy a better model in Part 5, this test will fail, because the answer won't be 63.8 any more. That's intended: it forces you to notice that predictions changed and update the expected value on purpose. A test that pins down a known result like this is called a regression test (an unfortunate name clash: it guards against things going backwards, not regression models).
Try it now: delete the two lines that check city_zone in predict, or remove gt=0 from distance_km, then run pytest again and watch a test fail. Put the code back and everything passes again.
Step 7: Package it with Docker
"It works on my machine" isn't good enough for a server. Docker packs your API, the exact library versions and Python itself into one container that runs identically on any computer or cloud.
Theory break: what is a container?
Think of a shipping container. Before standard containers, every cargo was packed differently and loading ships was slow and error-prone. A standard box can go on any ship, train or truck without anyone caring what's inside.
A Docker container does the same for software. It bundles:
- an operating system layer (a small Linux);
- the right Python version;
- the exact versions of FastAPI, scikit-learn, pandas and the rest;
- your files:
app.py,delivery_features.pyand the pipeline.
| Term | Meaning |
| Dockerfile | The recipe: step-by-step instructions to build the container |
| Image | The built, frozen package, made from the Dockerfile |
| Container | A running copy of an image. You can run many containers from one image. |
Why this matters especially for ML: remember the warning since Part 1 to load a model with the same scikit-learn version that saved it? A container makes that automatic: the version is fixed inside the image and travels with the model.
Pin the library versions
Create requirements.txt with exact versions. Use the versions from your environment, especially scikit-learn, which must match the one that saved the pipeline. Ours:
fastapi==0.162.2
uvicorn==0.54.0
scikit-learn==1.9.1
pandas==3.0.5
numpy==2.5.3
joblib==1.6.0
To see your versions, run pip show fastapi uvicorn scikit-learn pandas numpy joblib. Only the API's runtime libraries go here: pytest, requests and Jupyter aren't needed inside the server.
Write the Dockerfile
Save this as Dockerfile (no file extension):
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY delivery_features.py delivery_pipeline.joblib app.py ./
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
| Line | What it does |
FROM python:3.13-slim | Starts from an official image with Python 3.13 on a small Linux. slim keeps the image small. |
WORKDIR /app | Uses /app inside the container as the working folder |
COPY requirements.txt . | Copies the requirements list in first |
RUN pip install --no-cache-dir -r requirements.txt | Installs the pinned libraries. --no-cache-dir avoids storing download files, so the image stays smaller. |
COPY delivery_features.py delivery_pipeline.joblib app.py ./ | Copies the three files the API needs, and nothing else |
EXPOSE 8000 | Documents that the app listens on port 8000 |
CMD [...] | The command that runs when the container starts. --host 0.0.0.0 means "accept requests from outside the container", not just from inside it. |
Why copy **requirements.txt** before the code? Docker builds in layers and reuses unchanged ones. Installing libraries is slow; editing app.py is frequent. With this order, changing app.py only rebuilds the last fast step, not the whole installation.
Build and run it
With Docker Desktop running, in the part4-api folder:
# Build an image called delivery-api (the dot means "use this folder")
docker build -t delivery-api .
# Run it, connecting port 8000 on your computer to port 8000 in the container
docker run -p 8000:8000 delivery-api
Stop any Uvicorn server you started earlier first, or it will already be using port 8000. Then test it exactly as before: open http://127.0.0.1:8000/docs, or run python client.py. You should get the same 63.8 minutes.
| Command part | Meaning |
docker build -t delivery-api . | Build an image from the Dockerfile in this folder and tag (name) it delivery-api |
docker run delivery-api | Start a container from that image |
-p 8000:8000 | Port mapping, your-computer:container: requests to your port 8000 go to the container's port 8000 |
How we tested this step
Our test environment couldn't download Docker's base images, so we couldn't run docker build itself there. Instead we tested the next best thing: a brand-new, empty Python 3.13 environment containing only the files the Dockerfile copies, with only the libraries in requirements.txt installed. The API started and returned 63.8 minutes for the messy order, which confirms that the requirements list and the file list are complete. The Dockerfile itself uses standard, widely used commands. If docker build gives you an error, please share it in the comments and we'll update this guide.
Where to go from here
This image can now run on any cloud that hosts containers: AWS, Google Cloud, Azure, or simpler platforms built for small apps. Each has its own deployment steps, but they all start from exactly this image. Before putting it on the public internet, you'd also add authentication (an API key), HTTPS and request logging; the next section explains why monitoring matters most of all.
Theory break: how to judge a deployed model
Once a model is live, a good test score isn't enough. You also need to know whether it gives the same answers as in testing, answers fast enough, rejects bad input clearly, and is still accurate as the world changes. The last one is the hardest, and it's why live models need monitoring.
Parts 1–3 judged models on a held-back test set. A deployed model is judged on new questions too.
1. Consistency: does the API give the same answers as the notebook?
The most embarrassing deployment bug is an API that quietly gives different answers from the model you tested, because of a different library version, a missing feature step or a mixed-up column. We checked it three ways:
| Where | Answer for the messy Biryani order |
| Part 3's notebook | 63.8 minutes |
The running API (/docs, Python, curl) | 63.8 minutes |
A fresh environment with only requirements.txt installed | 63.8 minutes |
test_predict_matches_part3 now guards this automatically. In real projects, teams check a few hundred saved test orders this way before every release, not just one.
2. Speed (latency): is it fast enough?
Latency is the time from sending a request to getting the answer. Ours averaged 16 ms on our test machine, and only 0.13 ms of that was the model itself.
| Latency | How it feels in an app |
| Under 100 ms | Instant |
| 100 ms–1 second | Noticeable, but acceptable for a one-off estimate |
| Over 1 second | Slow: users notice, especially on every screen |
In production, teams watch not just the average but the slowest 1% of requests (the "p99 latency"), because those are the customers who complain.
3. Errors: are bad inputs handled, and is the server healthy?
Track the share of responses by status code:
- A rising share of 422s means apps are sending data the model doesn't understand: perhaps a new zone name, a new weather option in the app, or a bug in one app version. Log the rejected values; they tell you exactly what to add to
ZONESorWEATHER. - Any 500s mean your server has a bug. The target is zero.
**/health**lets monitoring tools check every minute that the service is up and serving the expectedmodel_version.
4. Accuracy over time: drift
The test set measured accuracy at training time. The world keeps changing: a new restaurant chain, roadworks, a monsoon season, a fleet of electric scooters. When live data stops looking like the training data, accuracy drifts, often slowly and silently. This is called drift:
| Type | What changes | Delivery example | How to spot it |
| Data drift | The inputs | Monsoon: rain jumps from 20% of orders to 60% | Compare live input statistics with training statistics |
| Concept drift | The relationship between inputs and outcome | Roadworks: the same Old Town trip now takes longer | Compare predictions with what actually happened |
To catch concept drift you need the actual delivery times. They arrive after each delivery, so log every prediction alongside its order and, later, the real minutes. Then compute the live MAE every day and compare it with the 4.49 minutes from Part 3.
Why you must monitor by group, not just overall
Here's a simulation using Part 3's test orders. Suppose roadworks suddenly add 8 minutes to every Old Town delivery, and nothing else changes:
| Metric | Before roadworks | After roadworks |
| Overall MAE | 4.49 min | 5.04 min |
| Old Town MAE | 5.75 min | 9.72 min |
| Old Town mean error | +0.8 min | +8.8 min |
The overall MAE rises by about half a minute, which could easily be dismissed as a slow week. But Old Town orders, 14% of the total, are now consistently almost 9 minutes later than promised. Only the group-level numbers reveal it. It's the same lesson as Parts 2 and 3: averages hide problems, so slice your errors by the categories that matter.
The fix is to retrain on recent data that includes the roadworks period. That's why live models are retrained regularly, and why MODEL_VERSION exists.
5. Versioning and rollback
Every response says "model_version": "part3-linear-v1". When Part 5 produces a tuned model, you'll deploy it as "part5-...-v2". If the live MAE gets worse, you can switch back to v1 in minutes (a rollback) and use the logs to compare the two versions' answers.
A checklist for a deployed model
| Question | How to check | Our API |
| Same answers as testing? | Compare saved orders: notebook vs API | 63.8 everywhere; automated test |
| Fast enough? | Measure latency, including the slowest requests | About 16 ms on average |
| Bad input handled clearly? | Tests for each kind of bad input; track the 422 rate | 4 rejection tests pass |
| Server healthy? | /health checks; 500 rate | Health endpoint; zero 500s in tests |
| Still accurate? | Live MAE, overall and by group, once actual times arrive | Needs logging: the next step for a real deployment |
| Can you undo a bad release? | Version every model; keep the previous one | model_version in every response |
Quick self-check
- The API's overall live MAE goes from 4.5 to 5.0 minutes. Should you worry?
- What's the difference between a 422 and a 500 response?
- Why can't you measure the live MAE at the moment of prediction?
Answers: (1) Check by group first. It could be noise, or one zone could be badly off, as in the roadworks example. (2) A 422 means the caller sent invalid data: our API is working correctly by refusing it. A 500 means our server crashed: a bug to fix. (3) The actual delivery time isn't known until the food arrives, so you must log predictions and join them with actual times later.
The complete code
Here's the final **app.py**, with Step 5's checks included. Together with **delivery_features.py**, **delivery_pipeline.joblib**, **test_app.py**, **requirements.txt** and the **Dockerfile** from the steps above, this is the whole service.
"""Delivery ML Series, Part 4: serve the delivery-time pipeline as a web API."""
import joblib
import pandas as pd
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from delivery_features import clean_orders
MODEL_VERSION = "part3-linear-v1"
# Load the pipeline ONCE, when the server starts
pipeline = joblib.load("delivery_pipeline.joblib")
app = FastAPI(title="Delivery Time API", version="1.0")
class Order(BaseModel):
"""One raw order, exactly as the delivery app sends it."""
order_time: str = Field(examples=["2026-10-03 20:15"])
city_zone: str = Field(examples=[" old TOWN"])
cuisine: str = Field(examples=["Biryani"])
distance_km: float = Field(gt=0, examples=[6000])
prep_time_min: float | None = Field(default=None, ge=0, examples=[None])
weather: str = Field(examples=["RAINY"])
rider_experience_months: float | None = Field(default=None, ge=0, examples=[6])
class Prediction(BaseModel):
predicted_minutes: float
model_version: str
@app.get("/health")
def health():
return {"status": "ok", "model_version": MODEL_VERSION}
@app.post("/predict", response_model=Prediction)
def predict(order: Order):
row = pd.DataFrame([order.model_dump()])
# Reject values our cleaning rules don't recognise, instead of guessing silently
try:
cleaned = clean_orders(row)
except (ValueError, TypeError):
raise HTTPException(status_code=422, detail="order_time must look like 2026-10-03 20:15")
if cleaned["city_zone"].isna().any():
raise HTTPException(status_code=422, detail=f"Unknown city_zone: {order.city_zone!r}")
if cleaned["is_raining"].isna().any():
raise HTTPException(status_code=422, detail=f"Unknown weather: {order.weather!r}")
minutes = float(pipeline.predict(row)[0])
return Prediction(predicted_minutes=round(minutes, 1), model_version=MODEL_VERSION)
To run everything:
pytest -v # 6 tests should pass
uvicorn app:app --reload # then open http://127.0.0.1:8000/docs
python client.py # in a second terminal: 63.8 minutes
Common mistakes and how to fix them
| Error or symptom | Cause | Fix |
ModuleNotFoundError: No module named 'delivery_features' | delivery_features.py isn't in the folder you're running from | Copy it from Part 3 into part4-api, and run uvicorn from that folder |
AttributeError: Can't get attribute 'clean_orders' on | The pipeline was saved from a script where clean_orders was defined inline | Re-run Part 3's script with clean_orders imported from delivery_features.py, and save again |
InconsistentVersionWarning: Trying to unpickle estimator ... from version X when using version Y | Different scikit-learn version from the one that saved the pipeline | Install the same version (pip install scikit-learn==X) and pin it in requirements.txt |
[Errno 98] error while attempting to bind on address ... address already in use (wording varies by system) | Another server, often one you started earlier, is already using port 8000 | Stop it with Ctrl + C, or use another port: uvicorn app:app --port 8001 |
requests.exceptions.ConnectionError in client.py | The server isn't running, or it's on a different port | Start Uvicorn in another terminal first; check the port in API_URL |
422 with "Input should be a valid dictionary or object" | The request body was sent as plain text, not JSON | In curl add -H "Content-Type: application/json"; in Python use json=order, not data=order |
405 Method Not Allowed | A GET request (e.g. opening /predict in the browser) to a POST endpoint | Use /docs, the Python client or curl with -X POST |
404 Not Found | A typo in the address, such as /predcit | Check the URL; /docs lists every valid address |
| Predictions look plausible but are wrong for some orders | Unrecognised values silently imputed | Add Step 5's checks, and a test for each |
| The API works locally but not in Docker | Missing --host 0.0.0.0, or no -p 8000:8000 when running | Keep the Dockerfile's CMD as shown, and map the port in docker run |
Deployment habits worth building now: load the model once, version every model, reject what you don't understand, test every bad input you've ever seen, pin library versions, and never trust an overall average when monitoring.
Practice project: serve the electricity model as an API
Turn your Part 3 practice pipeline, the one that predicts a home's monthly electricity use from a messy smart-meter export, into a "Home Energy API". Same steps, different data, and this time you write the code.
Before you start
You need two files from Part 3's practice project:
energy_features.py, containing yourclean_homesfunction (city names including Bombay, Madras, Bangalore and BLR; Yes/No variants;is_summerfrom the reading date;ac_x_size);- your trained pipeline, saved with
joblib.dump(pipeline, "energy_pipeline.joblib"). Use the linear regression pipeline from Task 6 (MAE 33.0 kWh), so your answers match the expected results below.
Your tasks
- 1. Set up (Step 1). Create a
part4-energy-apifolder with your two files. - 2. Describe a home (Step 2). Write a Pydantic
Homeclass with these fields and rules:reading_date(text),city(text),solar_panels(text); home_size_sqm: must be greater than 0;residents: optional (may be missing), at least 1 if given;works_from_home: 0 or 1 *(hint: **ge=0, le=1*);appliance_age_years: optional, 0 or more;ac_hours_per_day: between 0 and 24.
Check your results
| Task | Expected result |
| 4. The May home in "Bombay" with solar | 636.0 kWh |
5. Same home, no solar ("N") | 790.5 kWh: solar saves about 155 units |
| 5. Same home with solar, in December with 1.5 hours of AC | 313.1 kWh |
6. City "Delhi" | 422 {"detail": "Unknown city: 'Delhi'"} (your message may differ) |
6. Solar "maybe" | 422, with a message naming the bad value |
6. ac_hours_per_day: 30 | 422 from Pydantic, with no extra code if you used le=24 |
| 7. Tests | All pass with pytest -v |
Think about it: our pipeline knows only four cities. If the energy company expands to Delhi, your API correctly rejects those homes. But what's the right long-term fix: add "Delhi" to the CITIES table, or retrain? (Hint: Part 3's practice project found that city doesn't change the predictions here, because AC hours already carry the climate effect. Would a new city's homes behave like the training homes?)
Share your energy_app.py, your tests and your answers in the comments.
Frequently asked questions
How do I deploy a machine learning model as an API in Python?
Save the trained pipeline with joblib, then write a FastAPI app that loads it once at startup, checks incoming data with a Pydantic model, converts each request into a one-row DataFrame, and returns pipeline.predict() as JSON. Run it with Uvicorn.
What is FastAPI used for in machine learning?
FastAPI turns Python functions into web endpoints, so apps in any language can request predictions. It automatically validates input data, converts JSON to and from Python, and generates interactive documentation at /docs.
What is the difference between FastAPI and Uvicorn?
FastAPI defines what the API does: its endpoints, input rules and responses. Uvicorn is the server program that runs the FastAPI app and handles the network connections. You need both.
Why load the model outside the prediction function?
Loading a model from disk is slow compared with predicting. Loading it once when the server starts means each request only does the fast part.
Do I need Docker to deploy a model?
No, but it's strongly recommended. Docker packages the exact Python and library versions with your model, which prevents the version mismatches that break saved scikit-learn models, and it runs the same way on any server or cloud.
How do I handle invalid input in a model API?
Describe valid input with Pydantic (types, ranges, required fields) so FastAPI rejects bad requests with a 422. For values that pass those checks but that your model doesn't understand, such as an unknown category, raise an HTTPException with a clear message instead of guessing.
How do I know if my deployed model is still accurate?
Log every prediction, then join it with the actual outcome once it's known. Track the error over time, overall and by important groups, and retrain when it drifts.
Glossary
| Term | Plain-English meaning |
| API | A way for one program to request something from another |
| Endpoint | One address within an API, for one job, such as /predict |
| Request / response | What the caller sends / what the API sends back |
| GET / POST | Request methods: "give me information" / "here's data, process it" |
| JSON | A text format for data that every programming language understands |
| Status code | A number describing the result: 200 OK, 422 invalid input, 500 server error |
| FastAPI | A Python library for building APIs |
| Uvicorn | The server that runs a FastAPI app |
| Pydantic model | A class describing valid data; used to check requests automatically |
| Decorator | The @app.post(...) line that registers a function as an endpoint |
| Port | A numbered "door" on a computer that a service listens on, such as 8000 |
| localhost / 127.0.0.1 | Your own computer |
| Latency | Time from request to response |
| Docker image / container | A packaged app with its environment / a running copy of it |
| Dockerfile | The recipe for building a Docker image |
| Regression test | A test that checks known results stay the same after changes |
| Data drift / concept drift | Inputs change / the relationship between inputs and outcome changes |
| Model versioning | Labelling each deployed model so you can compare and roll back |
| Rollback | Switching back to a previous model version |
What to learn next: the Delivery ML series
This guide is Part 4 of an 8-part series. Every part uses the same food delivery problem and starts where the previous part ended.
| Part | Tutorial | What you'll learn | How it builds on the previous part |
| 1 | How to Build Your First Machine Learning Model | Classification, train/test split, precision and recall, overfitting | — |
| 2 | Predict the Exact Delivery Time | Regression, MAE, RMSE, R², parameters vs hyperparameters | Same orders, predicting minutes |
| 3 | Clean Messy, Real-World Delivery Data | Data cleaning, imputation, one-hot encoding, pipelines | Realistic messy data; one saved pipeline |
| 4 | Deploy Your Model as an API (you are here) | FastAPI, input validation, testing, Docker, monitoring | Serves Part 3's pipeline to any app |
| 5 | Tune Your Model | Hyperparameters, GridSearchCV, cross-validation, thresholds | Tunes the model, then deploys it to this API as version 2 |
| 6 | Explain Your Model's Predictions | Feature importance, SHAP values, fairness checks | Adds a "why?" to each prediction the API returns |
| 7 | When Simple Isn't Enough: Gradient Boosting | XGBoost/LightGBM, automatic interactions | Tests whether boosting fixes the Old Town blind spot |
| 8 | Add Generative AI: Explain Delays to Customers | Calling an LLM API, prompting, guardrails | Turns this API's prediction into a friendly customer message |
Next up: Part 5, Tune Your Model. Keep part4-api as it is. When Part 5 produces a better model, you'll swap the pipeline file, update MODEL_VERSION and the regression test, and redeploy.
Conclusion
You've turned a saved pipeline into a tested, documented web service that any app can call: it takes raw, messy orders, rejects input it doesn't understand, and answers in about 16 milliseconds.
This is the step where machine learning starts creating value: predictions reaching real users, at the moment they're needed. The habits from this part matter as much as the code: version your models, refuse to guess silently, test every failure you've seen, pin your library versions, and monitor by group, because a live model's accuracy is never guaranteed to stay where it started.
Sources
No sources recorded.
Keep exploring
View all →Basics of AI/ML
Build a Neural Network Without PyTorch. What Changes When It Learns?
Basics of AI/ML
How to Use XGBoost and LightGBM in Python: A Beginner's Guide to Gradient Boosting (Food Delivery ML Series, Part 7)
Basics of AI/ML
How to Explain Machine Learning Predictions in Python: A Beginner's Guide to Feature Importance and SHAP (Delivery ML Series, Part 6)
