MLOps for ML practitioners · Article 7

FastAPI & Pydantic: from theory to a working project

What an API actually is, how endpoints work, and how FastAPI plus Pydantic make serving a model clean, validated, and self-documenting.

Part of the MLOps series · read Article 6 first · next up: prerequisites for MLOps beginners

Article 6 covered Flask, a simple, reliable way to serve a model. Now we level up to FastAPI, the framework that's become the default choice for ML APIs in industry. But before touching FastAPI, it's worth stepping back to answer a question we've been using loosely for several articles: what actually is an API?

This article builds from the ground up: what an API is, what endpoints are and why they exist, how an ML model uses all of this to get deployed, then FastAPI and its companion Pydantic, the tool that validates input automatically so bad data never reaches your model. It finishes with a complete, practical heart disease project connecting everything you've built so far in this series.


What this article covers

#Topic
1What is an API? The foundational concept
2What are API endpoints, and why do they exist?
3How an API works: the request-response model
4How ML deployment uses an API
5What is FastAPI, and why prefer it over Flask?
6What is Pydantic, and the problem it solves
7How Pydantic and FastAPI work together
8Practical: a complete heart disease FastAPI project
9Interactive docs, testing, and Docker integration

Part 1What is an API?

API stands for Application Programming Interface, a name that sounds more intimidating than it is. Strip it down and it's simply a defined way for two pieces of software to talk to each other. That's the whole idea: a contract. "Send me a request in this format, and I'll send you a response in that format." Neither side needs to know how the other works internally, they just need to agree on the shape of the conversation.

The restaurant menu analogy

Walk into a restaurant and you don't go into the kitchen, inspect the ingredients, and instruct the chef on technique. There's a menu instead. It lists exactly what you can order and how; you order from it; the kitchen, which you never see, prepares the dish; the waiter brings back exactly what the menu promised.

The menu is the API. It's the agreed interface between you and the kitchen. You don't need to know how the kitchen works, only what's on the menu and how to order.

The wall socket analogy

An electrical wall socket is another angle on the same idea. Any device with the right plug can draw power, a lamp, a laptop charger, a phone. The socket doesn't care what's plugged in, and the device doesn't care how the power plant generates electricity. The socket is a standardized interface between the grid and your devices.

An API is that socket for software: a standard plug point letting different programs connect and exchange information without knowing each other's internals.

Why APIs matter

They're the reason modern software works at all. A weather app showing a forecast is calling a weather service's API. "Log in with Google" on a website is that site calling Google's API. When your ML model serves a prediction to a mobile app, the app is calling your API. APIs let software be built from independent, interchangeable parts that communicate through clear contracts, the same way the shipping containers from Article 4 let global trade run through one standard interface.


Part 2What are API endpoints, and why do they exist?

An API usually offers more than one service. An endpoint is a specific address within it that performs one specific job.

Back to the menu

If the API is the whole menu, each endpoint is a single item on it. A restaurant menu has sections, appetizers, mains, desserts; you order from one specific item, not "the menu" as a whole. Your ML API might have endpoints like /health, "are you running?", /predict, "give me a prediction for this data", and /model-info, "tell me about the current model version." Each is a distinct address doing one well-defined thing.

What problem do endpoints solve?

Organizing and separating a service's different capabilities. Without them you'd have one giant, tangled function trying to do everything. With endpoints, each capability gets its own clear address, clients ask for exactly what they need, and you can update, secure, or monitor each capability independently.

       Your ML API (the whole menu)
                  │
      ┌───────────┼────────────┐
      │           │            │
  /health     /predict    /model-info
  (am I up?)  (predict!)  (which model?)
      │           │            │
   simple      runs the    returns
   check       model       metadata

Anatomy of an endpoint

Two things define one, together: a path, the address, like /predict, and an HTTP method, the kind of action, GET to fetch something, POST to send data and act on it, and others like PUT/DELETE for updating and removing. POST /predict is a complete endpoint: "send data to /predict to get a prediction." For ML, mostly GET for simple reads and POST for predictions, since you're sending model input.

Analogy: the path is the room number; the method is what you intend to do there, visit (GET) or deliver a package (POST).


Part 3How an API works: the request-response model

Every API interaction follows the same loop from Article 6, formalized here since it's the heartbeat of every web API.

CLIENT SERVER (your API) REQUEST method: POST path: /predict headers: Content-Type body: {"age": 63, ...} processes the request: validate → run model → build reply RESPONSE status: 200 OK body: {"prediction": 1, ...}
Fig. 1 — a request has a method, path, headers, and body; a response has a status code and body

The two halves

The request, client to server, carries the method and path, headers (metadata, like what format the body is in), and the body, the actual data, the patient's features as JSON. The response, server to client, carries a status code, a number signaling what happened, and the body, the result, the prediction as JSON.

Status codes, just the ones you need

CodeMeaningWhen
200OKEverything worked
422Unprocessable EntityThe input data was invalid (FastAPI sends this automatically)
404Not FoundThe endpoint address doesn't exist
500Internal Server ErrorYour code crashed

Analogy: status codes are like a delivery confirmation: "delivered" (200), "wrong address" (404), "package damaged or unreadable" (422), "the warehouse caught fire" (500).


Part 4How ML deployment uses an API

Deploying a model means making it available for others to use, and an API is the standard way to do that.

Client web / mobile / code POST Your ML API server 1. Endpoint receives data 2. Validate the input (Pydantic) 3. Format for the model 4. model.predict() 5. Return the prediction model.pkl loaded once JSON
Fig. 2 — the full picture: a client's request flows through validation and the model, back out as JSON

A production ML API is more than a prediction function. Several components work together: the model artifact, your trained model.pkl, versioned with DVC from Article 3, loaded once at startup; the endpoints, /predict for inference, /health for monitoring; input validation, checking incoming data before it reaches the model, where Pydantic does the work; preprocessing, transforming raw input into the format the model expects; a response formatter, packaging the prediction into clean JSON; and the web server itself, FastAPI plus a production server like Uvicorn or Gunicorn handling the networking.

This article's job is showing how FastAPI ties the endpoints, formatting, and server together, and how Pydantic handles validation elegantly.


Part 5What is FastAPI, and why prefer it over Flask?

FastAPI is a modern Python web framework for building APIs. Like Flask, you define endpoints with Python functions. Unlike Flask, it was designed specifically for APIs, with powerful features built in.

FeatureFlaskFastAPI
Define endpoints with functions✅✅
Automatic input validation❌ (manual)✅ (via Pydantic)
Automatic interactive API docs❌✅ (built-in)
Data type checking❌✅
Async support (high concurrency)Limited✅ Native
Helpful auto error messages❌✅

The two standout features for ML are automatic input validation and automatic interactive documentation. With Flask, a client sending "age": "sixty-three" might crash your model with a confusing error deep inside scikit-learn. With FastAPI and Pydantic, bad input gets caught at the door, with a clear message about exactly what was wrong, before your model ever sees it.1

Analogy: Flask is a capable but bare kitchen, you do all the quality control yourself. FastAPI comes with an inspector at the door checking every order, plus a printed, always-current menu customers can browse interactively.

A minimal FastAPI app

from fastapi import FastAPI

# create the application (just like Flask)
app = FastAPI()

# define an endpoint with a decorator
@app.get("/health")
def health():
    return {"status": "healthy"}

Two differences from Flask stand out: the decorator is @app.get(...), the HTTP method is part of the decorator itself (Flask used methods=["GET"]); for POST you'd write @app.post(...). And you return a plain Python dictionary, FastAPI converts it to JSON automatically. No jsonify needed.

Running a FastAPI app

FastAPI has no built-in app.run(). You run it with Uvicorn instead, a fast server built for it:

pip install fastapi uvicorn

# "main:app" means the 'app' object in main.py
uvicorn main:app --host 0.0.0.0 --port 8000 --reload

main:app means file main.py, the FastAPI object named app; --host 0.0.0.0 makes it reachable from outside, the same reasoning as Article 6; --reload auto-restarts on code changes, for development only, drop it in production.


Part 6What is Pydantic, and the problem it solves

Pydantic is a Python library for data validation. You define the shape of your data, what fields exist, what types they should be, and it checks that incoming data matches automatically.

The problem it solves

A client is supposed to send {"age": 63, "chol": 233}. But clients make mistakes: age as text instead of a number, a missing field, a misspelled field name. Without validation, bad data flows straight into your model and causes a cryptic crash, or worse, a silently wrong prediction. With Flask you'd write defensive checks by hand for every field, tedious and easy to get wrong. Pydantic does that checking for you: declare what valid data looks like once, and it enforces that on every request.

How you define data with Pydantic

A class inheriting from BaseModel, with each field's type declared:

from pydantic import BaseModel

class PatientData(BaseModel):
    age: int
    sex: int
    cp: int
    trestbps: int
    chol: int
    fbs: int
    restecg: int
    thalach: int
    exang: int
    oldpeak: float
    slope: int
    ca: int
    thal: int

This class is a schema, a precise description of what valid patient data looks like. age: int means age must be a whole number; oldpeak: float means it can have decimals.

Analogy: Pydantic is a strict form with labeled boxes. Each box says what belongs in it, "Age, numbers only." Write letters in the age box, or leave it blank, and the form gets rejected before anyone processes it.

Adding constraints with Field

from pydantic import BaseModel, Field

class PatientData(BaseModel):
    age: int = Field(..., ge=0, le=120)        # ge = >=0, le = <=120
    sex: int = Field(..., ge=0, le=1)          # must be 0 or 1
    chol: int = Field(..., gt=0)               # must be greater than 0
    oldpeak: float = Field(..., ge=0)

... means the field is required; ge is greater-than-or-equal, le less-than-or-equal, gt greater-than. Pydantic now rejects an age of 200 or a negative cholesterol automatically.2 That's genuinely valuable for ML: it stops physically impossible or out-of-range input from ever reaching your model.


Part 7How Pydantic and FastAPI work together

FastAPI is built on Pydantic. Use a Pydantic model as the type of an endpoint's parameter, and FastAPI automatically reads the incoming JSON, validates it against your schema, sends back a clear 422 error if it's invalid, no code required from you, and hands your function a clean, typed object if it's valid.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class PatientData(BaseModel):
    age: int
    chol: int

@app.post("/predict")
def predict(patient: PatientData):    # ← the Pydantic model as the parameter type
    # if we reach this line, the data is guaranteed valid
    # 'patient' is a clean object: patient.age, patient.chol
    return {"received_age": patient.age, "received_chol": patient.chol}

The line def predict(patient: PatientData): is doing a lot of the work by itself. Annotating the parameter with the Pydantic class tells FastAPI: the body of this request must match the PatientData schema. FastAPI handles validation from there.

Compare the two approaches:

# Flask — manual validation (verbose, easy to forget cases)
@app.route("/predict", methods=["POST"])
def predict():
    data = request.get_json()
    if "age" not in data:
        return jsonify({"error": "age is required"}), 400
    if not isinstance(data["age"], int):
        return jsonify({"error": "age must be an integer"}), 400
    # ... repeat for all 13 fields ...

# FastAPI — automatic validation (declare once, done)
@app.post("/predict")
def predict(patient: PatientData):
    # validation already happened; just use the data
    ...

Analogy: Flask makes you personally inspect every order at the counter, item by item. FastAPI plus Pydantic is an automated inspection gate: describe the rules once, and every order gets checked before it reaches you.


Part 8Practical: a complete heart disease FastAPI project

Reusing the heart disease model from earlier articles, connecting directly to your Git (Articles 1–2), DVC (Article 3), and Docker (Articles 4–5) work.

heart_disease_fastapi/
├── Dockerfile
├── requirements.txt
├── train.py                 ← trains and saves the model
├── main.py                  ← the FastAPI app
├── schemas.py               ← Pydantic models (input/output schemas)
├── data/
│   └── heart.csv
└── models/
    └── model.pkl

Step 8.1 — requirements

scikit-learn==1.4.0
pandas==2.2.0
numpy==1.26.4
joblib==1.3.2
fastapi==0.110.0
uvicorn==0.29.0
pydantic==2.6.0

These are illustrative pins from when this article was written; FastAPI and Pydantic ship new releases often, so expect to bump these. The Pydantic v2 syntax used throughout this article (model_dump(), model_config) is stable across the whole 2.x line, so the code itself doesn't change.

Step 8.2 — training script

"""train.py — Train the heart disease model and save it."""
import pandas as pd
import joblib
import os
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

df = pd.read_csv("data/heart.csv")
X = df.drop(columns=["target"])
y = df["target"]

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

model = RandomForestClassifier(n_estimators=100, max_depth=5, random_state=42)
model.fit(X_train, y_train)

acc = accuracy_score(y_test, model.predict(X_test))
print(f"Model trained. Test accuracy: {acc:.4f}")

os.makedirs("models", exist_ok=True)
joblib.dump(model, "models/model.pkl")
print("Model saved to models/model.pkl")

Step 8.3 — the Pydantic schemas

schemas.py defines what valid input and output look like:

"""schemas.py — Pydantic models for request validation and response shape."""
from pydantic import BaseModel, Field


class PatientData(BaseModel):
    """Schema for a single patient's input features."""
    age: int = Field(..., ge=0, le=120, description="Age in years")
    sex: int = Field(..., ge=0, le=1, description="1 = male, 0 = female")
    cp: int = Field(..., ge=0, le=3, description="Chest pain type (0-3)")
    trestbps: int = Field(..., gt=0, description="Resting blood pressure")
    chol: int = Field(..., gt=0, description="Serum cholesterol in mg/dl")
    fbs: int = Field(..., ge=0, le=1, description="Fasting blood sugar > 120")
    restecg: int = Field(..., ge=0, le=2, description="Resting ECG results")
    thalach: int = Field(..., gt=0, description="Max heart rate achieved")
    exang: int = Field(..., ge=0, le=1, description="Exercise-induced angina")
    oldpeak: float = Field(..., ge=0, description="ST depression")
    slope: int = Field(..., ge=0, le=2, description="Slope of ST segment")
    ca: int = Field(..., ge=0, le=4, description="Number of major vessels")
    thal: int = Field(..., ge=0, le=3, description="Thalassemia type")

    # an example shown in the interactive docs
    model_config = {
        "json_schema_extra": {
            "examples": [{
                "age": 63, "sex": 1, "cp": 3, "trestbps": 145, "chol": 233,
                "fbs": 1, "restecg": 0, "thalach": 150, "exang": 0,
                "oldpeak": 2.3, "slope": 0, "ca": 0, "thal": 1
            }]
        }
    }


class PredictionResponse(BaseModel):
    """Schema for the prediction we send back."""
    prediction: int
    probability: float
    interpretation: str

Both the input (PatientData) and output (PredictionResponse) get defined. Having a response schema lets FastAPI document and validate what your API returns too.

Step 8.4 — the FastAPI app

main.py is the heart of the project:

"""main.py — FastAPI app serving heart disease predictions."""
import joblib
import pandas as pd
from fastapi import FastAPI
from schemas import PatientData, PredictionResponse

# ── Create the app with metadata (shows up in the docs) ──
app = FastAPI(
    title="Heart Disease Prediction API",
    description="Predicts heart disease risk from patient health data.",
    version="1.0.0",
)

# ── Load the model ONCE at startup ──
model = joblib.load("models/model.pkl")

# The exact feature order the model was trained on
FEATURES = ["age", "sex", "cp", "trestbps", "chol", "fbs", "restecg",
            "thalach", "exang", "oldpeak", "slope", "ca", "thal"]


@app.get("/health")
def health():
    """Confirm the API is running."""
    return {"status": "healthy"}


@app.post("/predict", response_model=PredictionResponse)
def predict(patient: PatientData):
    """Predict heart disease for one patient."""
    # 'patient' is already validated by Pydantic. Convert it to a dict.
    data = patient.model_dump()

    # Build a DataFrame in the exact feature order the model expects
    input_df = pd.DataFrame([[data[f] for f in FEATURES]], columns=FEATURES)

    # Predict (convert numpy types to plain Python types for JSON)
    prediction = int(model.predict(input_df)[0])
    probability = float(model.predict_proba(input_df)[0][1])

    return PredictionResponse(
        prediction=prediction,
        probability=round(probability, 4),
        interpretation="Heart disease likely" if prediction == 1
                        else "Heart disease unlikely",
    )

Walking through main.py

app = FastAPI(title=..., ...) creates the app. title, description, and version automatically show up in the interactive documentation, a touch Flask doesn't offer.

model = joblib.load(...) loads the model once at startup, outside any function, same rule as Article 6: never load per request.

@app.get("/health") is a simple GET endpoint for monitoring; it returns a dict, auto-converted to JSON.

@app.post("/predict", response_model=PredictionResponse) is the prediction endpoint, worth two notes: patient: PatientData means FastAPI validates the incoming JSON against the schema automatically, and if it's invalid the client gets a clear 422 with your function never running; response_model=PredictionResponse tells FastAPI the shape of the response, which it then documents and validates too.

patient.model_dump() converts the validated Pydantic object back into a plain dictionary so it can be rearranged for the model. (This is Pydantic v2 syntax; older v1 code used .dict().)

The rest mirrors what you already know: arrange features in order, predict, convert numpy types, return.


Part 9Interactive docs, testing, and Docker

The standout feature: automatic interactive docs

uvicorn main:app --host 0.0.0.0 --port 8000 --reload

Open http://localhost:8000/docs and FastAPI has generated a complete, interactive documentation page, with no extra code from you.3 Both endpoints, the exact input schema with your field descriptions and example, and a "Try it out" button that sends real requests from the browser.

This page, called Swagger UI, is generated from your Pydantic schemas and endpoint definitions. Your API documents itself, stays in sync automatically, and lets anyone test it without writing code.

Analogy: remember the restaurant menu? FastAPI prints an accurate, always-current one automatically, and even lets customers place test orders straight from it to see what comes back.

There's also http://localhost:8000/redoc for an alternative documentation style.

Testing the API

With curl:

# health check
curl http://localhost:8000/health

# prediction
curl -X POST http://localhost:8000/predict \
  -H "Content-Type: application/json" \
  -d '{"age": 63, "sex": 1, "cp": 3, "trestbps": 145, "chol": 233, "fbs": 1, "restecg": 0, "thalach": 150, "exang": 0, "oldpeak": 2.3, "slope": 0, "ca": 0, "thal": 1}'

Response

{
  "prediction": 1,
  "probability": 0.78,
  "interpretation": "Heart disease likely"
}

Watch validation work, send a bad request, age as text:

curl -X POST http://localhost:8000/predict \
  -H "Content-Type: application/json" \
  -d '{"age": "old", "sex": 1, "cp": 3, "trestbps": 145, "chol": 233, "fbs": 1, "restecg": 0, "thalach": 150, "exang": 0, "oldpeak": 2.3, "slope": 0, "ca": 0, "thal": 1}'

FastAPI rejects it automatically with a clear 422:4

{
  "detail": [{
    "type": "int_parsing",
    "loc": ["body", "age"],
    "msg": "Input should be a valid integer, unable to parse string as an integer",
    "input": "old"
  }]
}

Zero validation code written. Pydantic and FastAPI handled all of it, and the error even names the exact field, age, and what was wrong with it.

Dockerizing the FastAPI app

This connects directly to Articles 4 and 5. The Dockerfile is nearly identical, only the final command changes, since we now run with Uvicorn instead of python app.py:

FROM python:3.11-slim

ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

WORKDIR /app

RUN apt-get update && \
    apt-get install -y --no-install-recommends libgomp1 && \
    rm -rf /var/lib/apt/lists/*

# Install dependencies first (layer caching, from Article 4)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy the project files
COPY data/ ./data/
COPY train.py schemas.py main.py ./

# Train the model during the build
RUN python train.py

# Document the port
EXPOSE 8000

# Run with Uvicorn (the FastAPI server)
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

The only real change from Article 5's Dockerfile is the final CMD: instead of python app.py, Uvicorn launches pointing at the FastAPI app. Layer caching, EXPOSE, and base images all carry over unchanged.

docker build -t heart-disease-fastapi:v1 .
docker run -d -p 8000:8000 --name heart-fastapi heart-disease-fastapi:v1

Visit http://localhost:8000/docs and your containerized, self-documenting, input-validating ML API is live.

Production note, from Article 6: for heavier traffic, run Uvicorn with multiple workers, managed by Gunicorn:

CMD ["gunicorn", "main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000"]

This combines Gunicorn's process management with Uvicorn's speed, the standard production setup for FastAPI.5


Summary

You've now got both APIs and FastAPI for ML deployment covered. An API is a standard contract for software to communicate, the menu, the wall socket. Endpoints are specific addresses for specific jobs, which is what keeps a service organized instead of tangled. The request-response model, methods, headers, body, status codes, is the heartbeat under every web API. FastAPI adds automatic validation and self-generating docs on top of what Flask offers, which is why it's become the default for serious ML deployments. Pydantic lets you declare your data's shape once and get validation for free, the strict labeled form. Together, they make endpoints where bad data never reaches your model. And you've built a complete, working project: schemas, app, interactive docs, testing, and a Docker setup that carries over almost unchanged from Article 5.

Flask vs. FastAPI: which to use?

Both are genuinely good. Reach for Flask when you want maximum simplicity and minimal dependencies for a small service. Reach for FastAPI when you want automatic validation, self-documenting endpoints, and async performance, which describes most serious ML deployments today. FastAPI has become the common default for ML APIs largely because of Pydantic's validation and the interactive docs that come for free.

ToolRoleArticle
Git / GitHubVersion and share code1, 2
DVCVersion data and models3
DockerPackage the app and environment4, 5
FlaskSimple model API6
FastAPI + PydanticValidated, documented model API7 (this one)

The end-to-end workflow reads cleanly now: write model code with Git, version data and trained models with DVC, wrap the model in a validated, self-documenting API with FastAPI and Pydantic, package it into a portable container with Docker, and serve it in production with Uvicorn and Gunicorn.

Worth exploring next: background tasks, for handling slow operations without blocking the response; dependency injection, FastAPI's way of sharing resources like the model cleanly across endpoints; authentication, protecting your API with keys or OAuth before it goes public; serving multiple model versions behind different endpoints; and pushing your container to AWS, GCP, or Azure to expose it to the world.

You can now turn any trained model into a professional, production-ready API, validated, documented, containerized, and ready for real users. That's the deployment story this series set out to teach.