Module 1 — FastAPI: routes, types and automatic documentation
The churn model from course 20 currently lives inside a MLflow registry and gets called from a notebook. A dashboard team, a mobile team and a nightly batch job all want to consume it, and none of them wants to install scikit-learn, load the pickle, or reproduce the preprocessing. What they want is a URL that takes a subscriber and returns a probability. That URL is the whole point of this course, and this first module builds the smallest possible version of it — then names the properties of FastAPI that make it worth choosing for the job.
Why FastAPI, and why now
Python has served HTTP for two decades. Flask is the historical baseline, Django REST framework the batteries-included answer, and the ASGI ecosystem (Starlette, Uvicorn) the modern layer on which FastAPI sits. Three properties of FastAPI matter for machine learning services in particular: it uses standard Python type hints for validation, so the same annotation that documents the code also enforces the contract; it generates OpenAPI documentation on the fly, which means the mobile team can browse http://localhost:8000/docs and see every route, its payload and its response without a wiki page; and it is built on ASGI, so async routes, background tasks and streaming responses work without a plug-in. None of these features are unique in isolation. What is unique is that all three fall out of writing a normal Python function with type hints.
Installing and running the smallest useful application
FastAPI is a plain PyPI package. Uvicorn is the ASGI server that runs it. Both belong in a virtual environment for the reasons discussed in course 02:
# terminal
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "fastapi[standard]==0.115.0" "uvicorn[standard]==0.30.6"
python -c "import fastapi; print(fastapi.__version__)"
The first service takes ten lines. Save the following as app/main.py:
# app/main.py
from fastapi import FastAPI
app = FastAPI(title="Churn scoring API", version="0.1.0")
@app.get("/")
def root() -> dict:
return {"service": "churn-scoring", "status": "ok"}
@app.get("/predict")
def predict(tenure_months: int, monthly_charges: float) -> dict:
# A placeholder rule; module 3 replaces it with the real model.
score = 0.5 - 0.005 * tenure_months + 0.002 * monthly_charges
probability = max(0.0, min(1.0, score))
return {"churn_probability": round(probability, 4)}
Launch it with Uvicorn and open the browser on http://localhost:8000/docs:
# terminal
uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
Three things just happened without any extra code. Uvicorn served an HTTP application. FastAPI translated tenure_months: int into "this query parameter is required and must be an integer, otherwise return 422". And Swagger UI, at /docs, exposed a live playground where anyone with a browser can call the endpoint. The --reload flag restarts the server on file save; drop it in production for the reasons discussed in module 9.
Routes: verbs, paths and parameters
An HTTP route in FastAPI is a Python function with a decorator that names a verb and a path. @app.get("/predict") handles a GET request to that path; @app.post("/predict") would handle a POST. The verbs used in practice for an ML service are three:
GETfor information a caller can safely repeat: a health probe, a model version, a small parameterless prediction.POSTfor a prediction whose payload is a structured object; this is the default for anything the caller sends as JSON.DELETEoccasionally, for invalidating a cached batch result.
Paths can contain path parameters in braces, which FastAPI extracts and validates:
@app.get("/subscribers/{subscriber_id}/score")
def score_one(subscriber_id: str) -> dict:
return {"subscriber": subscriber_id, "churn_probability": 0.42}
Query parameters are function arguments whose type is a scalar and whose name is not in the path. A GET /predict?tenure_months=12&monthly_charges=70.5 reaches the function with the two values already coerced from string to int and float. Missing arguments become required; arguments with a default become optional; complex payloads belong to a POST body, which module 2 covers with Pydantic.
Type hints do double duty
The single design decision that shapes FastAPI is that the type hint is the source of truth. def predict(tenure_months: int, ...) documents the parameter, validates it at the boundary, and describes it in the OpenAPI schema — all in one place. There is no separate schema definition and no separate validation code. That property is what makes an ML service in FastAPI feel small even when it is complete.
A concrete consequence: renaming int to float in the signature changes the runtime behavior (the value is now coerced), the error message on invalid input, and the documentation, in one edit. In a Flask code base, the same change would require touching the route, the request-parsing layer, the schema, and possibly the docs.
The generated documentation
FastAPI serves two documentation UIs out of the box: Swagger UI at /docs and ReDoc at /redoc. Both are backed by an OpenAPI JSON document exposed at /openapi.json. That JSON is not a curiosity: mobile teams generate typed clients from it, security scanners consume it, and API gateways route based on it. Treat the OpenAPI document as a public artifact of the service, not as a development toy.
Practical habits that pay off from day one:
- Give the application a
titleand aversion. Both surface in the docs and in generated clients. - Group routes by tags (
@app.post("/predict", tags=["prediction"])) so a long service stays browsable. - Add a
summaryand adescriptionto non-trivial routes. Both are shown in Swagger UI and in generated SDK method docstrings.
Try it against curl
Every claim in this module can be verified from a terminal. With the server running:
# terminal
curl http://localhost:8000/
# {"service":"churn-scoring","status":"ok"}
curl "http://localhost:8000/predict?tenure_months=12&monthly_charges=70.5"
# {"churn_probability":0.581}
curl "http://localhost:8000/predict?tenure_months=abc&monthly_charges=70.5"
# HTTP/1.1 422 Unprocessable Entity
# {"detail":[{"type":"int_parsing","loc":["query","tenure_months"], ...}]}
The 422 response on the last call is worth pausing on. Nothing in the code raises an exception. FastAPI intercepted the invalid query, wrote a structured JSON explanation of what was wrong, and returned it with the standard HTTP status for "syntactically valid but semantically wrong request". Module 5 will contrast this with 400 and 500, and module 2 will show what the same protection looks like on a full JSON body.
/docs reachable in staging, disable in productionThe documentation UI is a strong ally in staging: any team member can call the service without curl. In production, prefer to disable it (FastAPI(docs_url=None, redoc_url=None)) or gate it behind the same auth as the rest of the service. Even a read-only doc leaks the shape of your ML payloads, which is information attackers use.
In summary
- FastAPI turns a typed Python function into an HTTP route, a validated contract and a documented endpoint, all from the same annotations.
- Use
GETfor parameterless or idempotent calls,POSTfor JSON bodies. Path parameters go in braces; query parameters are function arguments; the body is a Pydantic model (module 2). - Uvicorn runs the application (
uvicorn app.main:app);--reloadis for development only. The/docsand/redocUIs are backed by the machine-readable/openapi.json. - Invalid input produces a structured 422 without any manual code. That guarantee is what makes the rest of this course possible.
Next module: Pydantic. The int and float of this module become a full Subscriber schema with constraints, default values and clean error messages.