Machine Learning Essentials

Introduction to FastAPI


Open a mature Flask prediction service and count the lines that do the actual work.

There is one line calling model.predict_proba. Above it sit sixty lines checking that every field is present, that each is the right type, that numbers fall in plausible ranges, that categories are recognised, and that True has not been accepted as the integer 1. Below it sit another dozen assembling a response dictionary.

Then somebody asks what the endpoint accepts. You point at a README written four months ago, which lists a field that was renamed in February and omits two that were added in March. Nobody updated it because updating it is a separate act of discipline from changing the code.

Both problems have the same shape. The information — which fields exist, what types they are, what ranges are valid — is already in your head and already implied by the code. It is just written out three times: once as validation, once as documentation, and once in whatever the model actually expects. Three copies of one truth, drifting apart.

FastAPI's premise is that you write it once, as Python type hints, and the framework derives the rest.

What a type hint buys at request timeRequest arrives as raw JSONPydantic model parses and validates itInvalid fields return 422 with the offending keyHandler receives a typed, checked objectResponse model shapes what goes back out
The validation layer and the documentation are both generated from the same annotations, so neither can drift.

Type hints that do something

Ordinary Python type hints are annotations. They document intent and are checked by external tools, but at runtime they are ignored:

Python
def predict(income: float, age: int) -> float:    ...predict("not a number", "also not")   # runs fine until something breaks

FastAPI, via the Pydantic library, reads those hints at runtime and enforces them. You declare the shape of your input as a class:

Python
from pydantic import BaseModel, Fieldfrom typing import Literalclass CustomerFeatures(BaseModel):    income: float = Field(ge=0, le=1_000_000,                          description="Annual income in GBP")    age: int = Field(ge=18, le=100)    tenure_years: float = Field(ge=0, le=60)    num_products: int = Field(ge=1, le=10)    region: Literal["north", "south", "east", "west"]    has_contract: bool

That class replaces the entire hand-written validator. Every constraint in the sixty lines is now one declaration, and — this is the part that changes how the service feels — the same declaration produces the API documentation.

What the constraints mean

DeclarationEnforces
income: floatMust be a number; a numeric string is coerced, a word is rejected
Field(ge=0, le=1_000_000)Greater or equal to 0, less or equal to 1,000,000
Literal["north", ...]Must be exactly one of these strings
has_contract: boolAccepts true/false; rejects the string "maybe"
age: int = 40Optional, defaults to 40
notes: str | None = NoneOptional, may be explicitly null

For rules that do not fit a constraint, drop into a validator:

Python
from pydantic import field_validator, model_validatorclass CustomerFeatures(BaseModel):    income: float = Field(ge=0, le=1_000_000)    age: int = Field(ge=18, le=100)    tenure_years: float = Field(ge=0, le=60)    @model_validator(mode="after")    def tenure_must_fit_age(self):        if self.tenure_years > self.age - 18:            raise ValueError(                f"tenure_years ({self.tenure_years}) implies the customer "                f"joined before turning 18"            )        return self

That cross-field check is exactly the kind of rule that catches genuinely corrupt upstream data — a record claiming 40 years of tenure for a 25-year-old — and it is the kind that nobody writes when validation is tedious.

A complete service

Python
from contextlib import asynccontextmanagerfrom fastapi import FastAPI, HTTPExceptionfrom pydantic import BaseModel, Fieldfrom typing import Literalimport pandas as pdimport joblibMODEL_VERSION = "2.3.0"THRESHOLD = 0.34FEATURES = ["income", "age", "tenure_years", "num_products",            "region", "has_contract"]state = {}@asynccontextmanagerasync def lifespan(app: FastAPI):    state["model"] = joblib.load("pipeline.joblib")   # once, at startup    yield    state.clear()                                     # on shutdownapp = FastAPI(    title="Churn Prediction API",    description="Estimates the probability that a customer will churn.",    version=MODEL_VERSION,    lifespan=lifespan,)class CustomerFeatures(BaseModel):    income: float = Field(ge=0, le=1_000_000, examples=[52000])    age: int = Field(ge=18, le=100, examples=[34])    tenure_years: float = Field(ge=0, le=60, examples=[2.5])    num_products: int = Field(ge=1, le=10, examples=[2])    region: Literal["north", "south", "east", "west"]    has_contract: boolclass Prediction(BaseModel):    probability: float = Field(ge=0, le=1)    will_churn: bool    threshold: float    model_version: str@app.get("/health")def health() -> dict:    return {"status": "ok", "model_version": MODEL_VERSION,            "model_loaded": "model" in state}@app.post("/predict", response_model=Prediction)def predict(features: CustomerFeatures) -> Prediction:    row = pd.DataFrame([features.model_dump()])[FEATURES]    try:        prob = float(state["model"].predict_proba(row)[0, 1])    except Exception as exc:        raise HTTPException(status_code=500,                            detail="prediction failed") from exc    return Prediction(        probability=round(prob, 4),        will_churn=prob >= THRESHOLD,        threshold=THRESHOLD,        model_version=MODEL_VERSION,    )

Run it with uvicorn app:app --reload.

Notice what is absent. There is no code checking whether income is present, whether it is a number, or whether it is negative. There is no code parsing JSON. The function signature says it takes a CustomerFeatures, and by the time the body runs, that is what it has — validated, coerced, and typed.

What a bad request gets back

Bash
curl -X POST http://localhost:8000/predict \  -H "Content-Type: application/json" \  -d '{"income": -500, "age": 34, "tenure_years": 2.5,       "num_products": 2, "region": "atlantis", "has_contract": true}'
JSON
{  "detail": [    {      "type": "greater_than_equal",      "loc": ["body", "income"],      "msg": "Input should be greater than or equal to 0",      "input": -500,      "ctx": {"ge": 0.0}    },    {      "type": "literal_error",      "loc": ["body", "region"],      "msg": "Input should be 'north', 'south', 'east' or 'west'",      "input": "atlantis",      "ctx": {"expected": "'north', 'south', 'east' or 'west'"}    }  ]}

Status 422, both errors reported at once, each naming the offending field and the value received. That response is more useful than what most hand-written validators produce, and you wrote none of it.

Documentation that cannot go stale

Visit /docs and there is a browsable interface listing every endpoint, every field, its type, its constraints, and its example — with a button that sends a real request and shows the real response. Visit /openapi.json and there is a machine-readable specification that client-generation tools consume directly.

Neither was written by hand. Both are derived from the same class that performs validation, which means they cannot describe a field the code does not have. Rename tenure_years and the docs rename it in the same commit, because they are the same statement.

The value is not that FastAPI generates documentation. It is that the documentation and the validation are the same declaration, so they cannot disagree.

Declaring the response too

response_model=Prediction is easy to skip and worth keeping. It does three things: it documents the output shape, it validates that your handler actually returned that shape, and it filters out anything not declared.

That third behaviour is a security property. If a future change makes the handler return an internal object carrying a customer ID or a raw feature vector, the response model strips it rather than leaking it to the caller.

Path and query parameters

The same typing applies to values in the URL.

Python
from fastapi import Query, Path@app.get("/customers/{customer_id}/risk")def customer_risk(    customer_id: int = Path(ge=1, description="Internal customer ID"),    explain: bool = Query(False, description="Include feature contributions"),    top_k: int = Query(5, ge=1, le=20),) -> dict:    ...

A request to /customers/abc/risk returns a 422 explaining that customer_id must be an integer, before the function runs. Path parameters identify a resource; query parameters modify how it is returned. Keeping to that convention makes an API predictable to people who have never read its docs.

async def: what it buys, and what it does not

FastAPI supports asynchronous handlers, and this is the most commonly misapplied feature in the framework.

Asynchronous code helps when a handler spends its time waiting — for a database, an HTTP call, a file read. While one request waits, the event loop serves others. It does nothing for work that keeps the CPU busy.

model.predict_proba is CPU-bound. It does not wait; it computes. So:

Handler styleWhat FastAPI doesEffect on a CPU-bound prediction
def predict(...)Runs it in a thread poolCorrect — the event loop stays free
async def predict(...)Runs it directly on the event loopBlocks every other request for the duration
async def + await on I/OYields while waitingCorrect, for genuinely I/O-bound work

The counter-intuitive conclusion: for a scikit-learn endpoint, plain def is the right choice and async def makes throughput worse. Writing async def because it sounds faster is a reliable way to build a service that serialises every request behind the slowest one.

Where async def earns its place is a handler that fetches features from a database or calls another service before predicting — the awaits release the loop while the network does its work.

Batching, which matters more than async

The single largest throughput win for a prediction API is usually not concurrency but batching. Vectorised libraries predict 500 rows in barely more time than one, because the per-call overhead dominates.

Python
class BatchRequest(BaseModel):    items: list[CustomerFeatures] = Field(min_length=1, max_length=1000)class BatchResponse(BaseModel):    predictions: list[Prediction]    count: int@app.post("/predict/batch", response_model=BatchResponse)def predict_batch(payload: BatchRequest) -> BatchResponse:    frame = pd.DataFrame([item.model_dump() for item in payload.items])[FEATURES]    probs = state["model"].predict_proba(frame)[:, 1]    return BatchResponse(        predictions=[            Prediction(probability=round(float(p), 4),                       will_churn=bool(p >= THRESHOLD),                       threshold=THRESHOLD,                       model_version=MODEL_VERSION)            for p in probs        ],        count=len(probs),    )

Every item is validated individually, and max_length=1000 caps the damage from a caller who sends a million rows. That cap is not optional — an unbounded batch endpoint is a memory-exhaustion vector.

Testing

Python
import pytestfrom fastapi.testclient import TestClientfrom app import app@pytest.fixture(scope="module")def client():    with TestClient(app) as c:    # the with-block runs lifespan startup, so the model loads        yield cdef test_health(client):    r = client.get("/health")    assert r.status_code == 200    assert r.json()["model_loaded"] is Truedef test_valid_prediction(client):    r = client.post("/predict", json={        "income": 52000, "age": 34, "tenure_years": 2.5,        "num_products": 2, "region": "north", "has_contract": True,    })    assert r.status_code == 200    body = r.json()    assert 0.0 <= body["probability"] <= 1.0    assert body["model_version"] == "2.3.0"def test_rejects_negative_income(client):    r = client.post("/predict", json={        "income": -1, "age": 34, "tenure_years": 2.5,        "num_products": 2, "region": "north", "has_contract": True,    })    assert r.status_code == 422    assert r.json()["detail"][0]["loc"] == ["body", "income"]def test_rejects_unknown_region(client):    r = client.post("/predict", json={        "income": 52000, "age": 34, "tenure_years": 2.5,        "num_products": 2, "region": "atlantis", "has_contract": True,    })    assert r.status_code == 422

TestClient makes real requests through the full application stack without starting a server, so the tests exercise validation, serialisation, and the handler together. There is no separate mocking layer to keep in sync. Note the with block: a TestClient created without it never runs the lifespan handler, so the model is never loaded and every prediction test fails with a 500.

Worth adding alongside these: a test asserting the model's output on a fixed input matches a recorded value. That one catches library upgrades silently changing predictions, which no amount of schema validation will notice.

Running it properly

Bash
# developmentuvicorn app:app --reload# production: several worker processes, managed by uvicorn itselfuvicorn app:app --host 0.0.0.0 --port 8000 --workers 4
Bash
# DockerfileFROM python:3.12-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY app.py pipeline.joblib ./EXPOSE 8000CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

Each worker holds its own copy of the model, so four workers with a 200 MB pipeline need roughly 800 MB before serving anything. Size the worker count against memory, not just cores.

Older guides run FastAPI under gunicorn with --worker-class uvicorn.workers.UvicornWorker. That module is deprecated in current uvicorn; if you want gunicorn as the process manager, install the separate uvicorn-worker package and use --worker-class uvicorn_worker.UvicornWorker.

Flask or FastAPI

FlaskFastAPI
Input validationHand-writtenDeclared once, enforced automatically
Error responsesWhatever you writeStructured 422 with field-level detail
DocumentationWritten separately, goes staleGenerated from the code
Async supportLimitedNative
Editor autocompletionDicts — noneTyped models — full
Ecosystem ageVery mature, hugeNewer, growing fast
Learning curveSlightly gentlerRequires understanding type hints
Best forSmall services; existing Flask codebasesNew model-serving APIs

For a new prediction service, FastAPI is the better default, mainly because validation is the part of a model API that is most tedious to write, most consequential when skipped, and most likely to be skipped precisely because it is tedious.

What this means when you build something

Define the input schema before writing the handler, and be specific: real ranges, real category lists, real cross-field rules. Every constraint you declare is a class of malformed request that can never reach your model, and a line of documentation you never have to maintain. Writing income: float when you could write income: float = Field(ge=0, le=1_000_000) throws away the main benefit of the framework.

Load the model once in a lifespan handler, keep the whole preprocessing pipeline inside the saved artefact, use plain def for the prediction endpoint, and add a batch route with an explicit cap. Then point whoever asked for the model at /docs and let them try it themselves — which is a considerably better answer than a README and an apology.