🚀 Section 5 · Deployment & MLOps Basics 🔴 Advanced MODULE 22

Building an ML API with FastAPI

⏱️ 35 min read
📖 FastAPI + Pydantic + joblib
🧩 4 Quiz Questions
🏗️ 1 Challenge · 3 tasks
Your progress in Section 550%
🎯 What you'll build: Lesson 21 ended with a trained pipeline saved to student_score_model.joblib — but a .joblib file on a laptop isn't a product anyone else can use. This lesson wraps that file in a real, running web API using FastAPI: a Pydantic model defines exactly what input the API expects, the saved pipeline gets loaded once at startup, and a POST /predict endpoint turns a JSON request into a prediction. By the end you'll have a working app.py you can run locally with uvicorn.

What Is FastAPI?

FastAPI is a modern Python web framework built specifically for creating APIs quickly, with a strong focus on correctness and developer experience. It's built on top of Starlette (for the actual web/ASGI handling) and Pydantic (for data validation), which is where its two headline features come from.

Async support
Endpoints can be defined with async def for I/O-bound work, or plain def — FastAPI runs sync functions in a threadpool automatically, so blocking calls like model.predict() work fine either way.
📄
Automatic interactive docs
FastAPI generates an OpenAPI schema from your code automatically, and serves it as a live, testable Swagger UI at /docs (and ReDoc at /redoc) — with zero extra configuration.
Built-in request validation
Pydantic models describe exactly what a request body should look like — FastAPI validates every incoming request against that shape and rejects bad input automatically, before your code even runs.
🐍
Plain Python type hints
There's no special query language — request/response shapes are described using standard Python type hints (str, float, int, list), which is also what powers the validation and docs.

For serving a scikit-learn model, this combination is a very natural fit: Pydantic describes the exact features a model expects, FastAPI rejects malformed requests before they ever reach model.predict(), and the automatic docs give anyone testing the API a working form to try it from, with no separate tool required.

Installing & a Minimal App Skeleton

FastAPI needs an ASGI server to actually run — uvicorn is the standard choice used throughout this lesson.

terminal
BASH
pip install fastapi uvicorn

The smallest possible FastAPI app — no model yet, just the framework wiring:

main.py
PYTHON
from fastapi import FastAPI

app = FastAPI(title="Student Score Predictor API")

@app.get("/")
def root():
    return {"message": "Student Score Predictor API is running"}
terminal
BASH
uvicorn main:app --reload

Visiting http://127.0.0.1:8000/ in a browser returns the JSON message. Visiting http://127.0.0.1:8000/docs shows the automatically generated, interactive Swagger UI — already working, from four lines of application code.

Pydantic Models for Request Validation

A raw JSON request body has no guaranteed shape on its own — Pydantic's BaseModel fixes that by letting you declare exactly which fields are expected and what type each one must be. FastAPI reads this declaration and automatically validates every incoming request against it.

schemas.py
PYTHON
from pydantic import BaseModel

class StudentInput(BaseModel):
    study_hours: float
    attendance: float
    prev_score: float
    assignments_done: int

class PredictionOutput(BaseModel):
    predicted_score: float
    confidence: str

These match the exact features the pipeline was trained on in Lesson 21's Section 5: study_hours, attendance, prev_score, and assignments_done. If a client sends a request missing prev_score, or sends attendance as the text "high" instead of a number, FastAPI rejects it automatically with a 422 Unprocessable Entity response — predict() never even runs on bad input.

📝
Two separate models, one purpose each
StudentInput describes what comes IN (the request body); PredictionOutput describes what goes OUT (the response). Keeping them as two distinct BaseModel classes — rather than one shared shape — makes the API's contract explicit and is exactly what FastAPI's response_model= parameter (Section 5) expects.

Loading the Model at Startup

The joblib-saved pipeline from Lesson 21 needs to load exactly ONCE, when the application starts — not on every incoming request. Loading it at module level (right after the imports, before any endpoint is defined) achieves this: it runs a single time when uvicorn starts the app, and the resulting object stays in memory for every request afterward.

main.py (continued)
PYTHON
from fastapi import FastAPI
from pydantic import BaseModel
import joblib
import pandas as pd

app = FastAPI(title="Student Score Predictor API")

# Loaded ONCE, when the module is imported / the server starts
model = joblib.load("student_score_model.joblib")
⚠️
Don't call joblib.load() inside the endpoint function
Putting joblib.load(...) inside def predict(...) would reload the entire pipeline from disk on EVERY single request — needlessly slow, and pointless given the model never changes while the server is running. Loading once at module level is the standard pattern.

The /predict Endpoint

With the model loaded and the input/output schemas defined, the endpoint itself is short: accept a validated StudentInput, convert it into the shape the pipeline expects, call .predict(), and return a PredictionOutput.

main.py — complete app
PYTHON
from fastapi import FastAPI
from pydantic import BaseModel
import joblib
import pandas as pd

app = FastAPI(title="Student Score Predictor API")
model = joblib.load("student_score_model.joblib")

class StudentInput(BaseModel):
    study_hours: float
    attendance: float
    prev_score: float
    assignments_done: int

class PredictionOutput(BaseModel):
    predicted_score: float
    confidence: str

@app.get("/health")
def health():
    return {"status": "ok", "model_loaded": model is not None}

@app.post("/predict", response_model=PredictionOutput)
def predict(student: StudentInput):
    # Build a one-row DataFrame with the SAME column names used in training
    features = pd.DataFrame([{
        "StudyHours": student.study_hours,
        "Attendance": student.attendance,
        "PrevScore": student.prev_score,
        "AssignmentsDone": student.assignments_done
    }])

    pred = model.predict(features)[0]
    confidence = "high" if pred > 80 else "medium" if pred > 60 else "low"

    return PredictionOutput(predicted_score=round(float(pred), 1), confidence=confidence)
GET
/health
Quick check that the server is up and the model loaded successfully.
POST
/predict
Accepts a StudentInput JSON body, returns a PredictionOutput with the predicted score.
💡
response_model=PredictionOutput does double duty
It validates that whatever the function returns actually matches the PredictionOutput shape before sending it back, AND it documents the exact response shape in the auto-generated /docs page — callers can see precisely what JSON to expect without reading the source code.

Running the Server & Testing It

With main.py saved (and student_score_model.joblib from Lesson 21 in the same folder), start the server:

terminal
BASH
uvicorn main:app --reload

The --reload flag restarts the server automatically whenever the code changes — useful during development, and something to drop for a real production deployment. With the server running, POST a request to /predict using Python's requests library:

test_api.py
PYTHON
import requests

payload = {
    "study_hours": 5.0,
    "attendance": 88,
    "prev_score": 70,
    "assignments_done": 8
}

response = requests.post("http://127.0.0.1:8000/predict", json=payload)
print(response.json())
# {'predicted_score': 81.4, 'confidence': 'high'}

The exact same request can also be sent straight from the browser at http://127.0.0.1:8000/docs — expand POST /predict, click "Try it out", fill in the form FastAPI generated from StudentInput, and execute it. No separate API-testing tool is required to try the endpoint for the first time.

The Complete API, Start to Finish

Every piece from this lesson combined into one runnable main.py.

main.py — COMPLETE PROGRAM
PYTHON
from fastapi import FastAPI
from pydantic import BaseModel
import joblib
import pandas as pd

# 1. Create the app
app = FastAPI(title="Student Score Predictor API", version="1.0")

# 2. Load the trained pipeline ONCE, at startup (Lesson 21's saved file)
model = joblib.load("student_score_model.joblib")

# 3. Define request/response schemas with Pydantic
class StudentInput(BaseModel):
    study_hours: float
    attendance: float
    prev_score: float
    assignments_done: int

class PredictionOutput(BaseModel):
    predicted_score: float
    confidence: str

# 4. Health check endpoint
@app.get("/health")
def health():
    return {"status": "ok", "model_loaded": model is not None}

# 5. Prediction endpoint
@app.post("/predict", response_model=PredictionOutput)
def predict(student: StudentInput):
    features = pd.DataFrame([{
        "StudyHours": student.study_hours,
        "Attendance": student.attendance,
        "PrevScore": student.prev_score,
        "AssignmentsDone": student.assignments_done
    }])
    pred = model.predict(features)[0]
    confidence = "high" if pred > 80 else "medium" if pred > 60 else "low"
    return PredictionOutput(predicted_score=round(float(pred), 1), confidence=confidence)

# Run with: uvicorn main:app --reload
🧩 Knowledge Check — Lesson 22
4 questions on building an ML API with FastAPI.
1. What two headline features is FastAPI most known for?
2. What library/class is used to define and validate the expected request body?
3. Where should the trained model be loaded with joblib.load()?
4. What command actually starts a FastAPI app defined as `app = FastAPI()` inside main.py?
💪
Try It Yourself — Lesson 22
Extend the ML API · Advanced Level

Use the app, model, StudentInput, and PredictionOutput objects from Section 7 as your starting point for each task below.

Task 1: Add a GET /health endpoint (if you haven't already) 🩺

Confirm the /health endpoint from Section 5 returns {"status": "ok", "model_loaded": true} when the model loads successfully. Test it in the browser at /docs.
Task 2: Add a GET /model/info endpoint 📋

Create a new endpoint that returns metadata about the loaded model — at minimum its type(model).__name__ and the list of feature names it expects. This is a common real-world pattern for letting API consumers introspect what they're calling.
Task 3: Add a POST /predict/batch endpoint 📦

Define a new Pydantic model wrapping a list[StudentInput], accept a batch of students in one request, and return a list of predictions. Think about what should happen if the list is empty.
💡 Show hints if you're stuck
  • Task 2: from sklearn.pipeline import Pipeline — if model is a Pipeline, model.named_steps lets you inspect each step.
  • Task 3: class BatchInput(BaseModel): students: list[StudentInput], then loop over request.students inside the endpoint.
  • Task 3: Consider returning an empty list (not an error) if request.students is empty — a reasonable, simple design choice.
Finished this lesson?
Mark it complete to track your progress through Section 5.
🎉

Lesson 22 Complete!

You've turned a saved .joblib model into a real, running API with request validation, automatic docs, and a working /predict endpoint. Lesson 23 covers what happens after that API ships — keeping it healthy over time.

Module 22 of 24 Section 5 — Deployment & MLOps Basics