Skip to content

Serve a house-price model

  • Case study 1
  • 240 min
  • Optional · 1 case study needed for the certificate

What you will be able to do

Turn a trained model into a service that other programs can call safely. You put a real house-price model behind an API that checks every input and refuses a model file that it does not trust. The API gives a range instead of one exact-looking number, and says which model answered. Then you test it, document it, and prepare its deployment.

Case studyPortfolio piece

Time
About 4 hours
Runs in
VS Code, Python 3.12+
Skills used
  • Serve a saved model
  • Input limits from training data
  • Model file hash check
  • Ranges and warnings
  • Model versioning
  • Golden and contract tests
  • Deployment to FastAPI Cloud

Unzip the starter, open the folder in VS Code, and follow the steps below. They start with a new virtual environment.

The starter is a small FastAPI project with the parts that you know from this course, and a ready-made model. You add the model loading, the endpoints, the tests, the contract and the deployment, part by part. The finished project is the result, for comparison. Every output on this page comes from the finished code.

Before you startWhat to know and to have readyAbout 240 minutes

What you need to know

This case study uses the whole course. The lessons it leans on most:

You do not need the machine learning course. The model is ready-made, and this page explains what you need to know about it. If you did the case study Estimate house prices in the machine learning course, this is the same model: now you serve it.

What you need on your computer

Tested on macOS with Python 3.14.6 and 3.12.13 (the tests, and the retrain of Part 7, on both), FastAPI 0.142.2, scikit-learn 1.9.1, NumPy 2.5.3, SciPy 1.18.1, joblib 1.6.0, pandas 3.0.6, pytest 9.1.1, Postman CLI 1.69.0 and fastapi-cloud-cli 0.26.0. The Windows commands are not tested.

Hannah Osei leads the valuation team of a regional mortgage lender near Seattle. Before the lender gives a loan for a house, a licensed appraiser visits the house and writes its value. That takes days. Hannah’s team wants a first estimate on the day an application arrives, inside the intake tool that the team already uses. The estimate does not replace the appraiser. It tells the team early when the agreed price looks far from the value, so those appraisals go first.

The team’s analyst already has a model. It learned from public house sales in King County, the county of Seattle, and it is saved in one file. But a file on an analyst’s laptop is not something an intake tool can use. Elif Demir, who maintains the intake tool, asks for an API: send the description of a house, get an estimate back. Kenji Mori, a developer on the team, builds it. In this case study, you are Kenji.

Daniel Ruiz from the compliance team adds two conditions before he agrees. The estimate must never be used to decide about a person’s loan. And every answer must say which model made it, so that any estimate can be traced later.

An API with five endpoints, built on the conventions of this course: the error shape, the X-API-Key header, a body-size limit, request IDs, /health and /ready.

Endpoint Key What it does
POST /v1/estimates yes Estimate one house: a price, a range, the model version and warnings
POST /v1/estimates/batch yes Estimate 1 to 100 houses in one request, in order
GET /v1/model no The model card: what the model is for, and how well it did
GET /health no Liveness: the process runs
GET /ready no Readiness: the model is loaded and answers

Each part of the build starts from a problem that a real model API has. The model file can be changed or wrong. Nonsense inputs get confident answers. One number looks more exact than it is. Retraining can silently change the answers. Each part solves one of them. At the end you have a tested, documented project that you can deploy and show to an employer.

You do not train the model, but you must know what it learned, because the API’s rules come from that. The model card in the starter, MODEL_CARD.md, has the details. Here is what matters for the API.

The data is a public record of 21,613 house sales in King County, Washington, from May 2014 to May 2015. It was published on Kaggle by a user called harlfoxem, and it is dataset 42092 on OpenML. Its licence is CC0 1.0: the data is in the public domain. We still give the credit: House Sales in King County, USA (harlfoxem, Kaggle 2016; OpenML 42092). Hannah, Elif, Kenji, Daniel and the lender are invented. The sales are real.

What one house looks like. The model describes each house with 17 numbers. These are the ones that the API asks for:

Field Meaning
bedrooms, bathrooms, floors Counts. 0.5 bathroom is a toilet without a shower; 1.5 floors is a partial upper floor.
sqft_living, sqft_basement, sqft_lot Living area (basement included), basement area, and land area, in square feet. 1 sq ft is about 0.093 m².
waterfront, view On the water or not; the quality of the view, from 0 (none) to 4.
condition, grade The state of the house, 1 to 5; the county’s building grade, 1 to 13 (7 is average).
yr_built, yr_renovated Year built; year of the last renovation.
lat, long Latitude and longitude.
sqft_living15, sqft_lot15 The average living and land area of the 15 nearest houses.

The model is gradient boosting, a method that adds many small decision trees, each one correcting the errors of the ones before. It learned the logarithm of the price, because house prices are skewed and errors are best measured in percent. It learned from the 16,959 sales of houses first sold before March 2015. It was then tested once, on the 4,654 sales from March to May 2015, which it had never seen.

How good it is. On those test sales, the typical error was 12.8% of the price (the mean absolute percentage error, MAPE). Half of the estimates (50.2%) were within 10% of the sale price, and 12.6% were 10% or more too high. A quick valuer’s method, the median price per square foot in the zip code, had a MAPE of 18.5%. So the model is useful, but far from exact. For a typical house of 465,000 dollars, a typical miss is about 60,000 dollars.

Two facts from the test will shape the API. First, the estimates were about 6% too low for the typical house in spring 2015: prices rose after the last sale that the model saw. Second, estimates of 1 million dollars or more were much weaker: only about 4 in 10 were within 10%.

  1. Download the starter project with the button in the box at the top of this page. Unzip it into your projects folder. You get a folder house-price-api.

  2. In VS Code, open the folder: File › Open Folder…, then choose house-price-api.

  3. Open a terminal in VS Code: Terminal › New Terminal. It starts in the project folder.

  4. Make and activate a virtual environment:

    Terminal window
    python3 -m venv .venv
    source .venv/bin/activate

    The prompt starts with (.venv).

  5. Install the exact versions of the packages:

    Terminal window
    python -m pip install -r requirements-lock.txt

    This takes a minute or two. The environment uses about 360 MB, mostly for SciPy, pandas, scikit-learn and NumPy.

  6. Tell VS Code to use this environment. Open the Command Palette (Ctrl+Shift+P, or Cmd+Shift+P on macOS), choose Python: Select Interpreter, then choose the one in .venv.

  7. Run the tests:

    Terminal window
    python -m pytest
Output (last lines)
collected 2 items
tests/test_health.py .. [100%]
============================== 2 passed in 0.01s ===============================

What this tells us. The environment works, and the parts of the API that the starter already has are correct: /health answers, and every response has a request ID. These two tests stay green until the end.

Now start the API in the first terminal. Then open a second terminal (Terminal › New Terminal again) and call it. On Windows, type curl.exe instead of curl.

First terminal
fastapi dev
Second terminal
curl -i http://127.0.0.1:8000/health
curl -s -X POST http://127.0.0.1:8000/v1/estimates
Output
HTTP/1.1 200 OK
date: Thu, 08 Oct 2026 00:29:43 GMT
server: uvicorn
content-length: 15
content-type: application/json
x-request-id: c95bd80c5d7e
{"status":"ok"}
{"error":{"code":"not_found","message":"Not Found","request_id":"ff10bbb726d8","fields":[]}}

What this tells us. The service runs, and it already speaks the course’s error shape: an unknown path gets not_found with a request ID. There is no estimate endpoint yet. In the first terminal, the log also says API_KEY is not set. That is fine on your computer; the deployment sets a key.

Path What it is
model/house_price.joblib, model/model_card.json The trained model (0.51 MB) and its card as data
MODEL_CARD.md, LICENSE The model card for people; the MIT licence of the code
house_price_api/ main.py, config.py, errors.py, models.py and security.py, as at the end of the course, with /health only
tests/ conftest.py, test_health.py, and golden_houses.json: three real houses and the answers that the finished API must give
training/train.py The script that made the model. You run it in Part 7.
requests/, postman/, scripts/ Request bodies for curl, a Postman collection to extend, and a check for the deployed API

Part 1: look at the model before you serve it

Section titled “Part 1: look at the model before you serve it”

A model file is a black box until you check it. Before you write any code, find out two things: is this the file that the analyst says it is, and what does it promise?

The analyst’s card lists a SHA-256 hash of the file. A hash is a fingerprint: a function reads every byte of the file and returns 64 characters. The same file always gives the same hash. A file that differs by one byte gives a completely different one. So two people can compare a file without sending it, only by comparing 64 characters.

Compute the hash of the file that you have, and read three facts from the card:

Terminal
python -c "import hashlib; print(hashlib.sha256(open('model/house_price.joblib', 'rb').read()).hexdigest())"
python -c "import json; card = json.load(open('model/model_card.json')); print(card['model_version'], card['sha256'][:12], card['prices_as_of']); print(card['test']); print(card['range'])"
Output
8d0483d05cfca4752118c5c6ef4c47a9531c7230981cc880c27d85def9dbfdec
kc-hgb-2015-02 8d0483d05cfc 2015-02-28
{'sales': 4654, 'first_sales': '2015-03-01 to 2015-05-27', 'median_error_pct': 6.4, 'mape_pct': 12.8, 'within_10pct': 0.502, 'over_by_10pct_or_more': 0.126}
{'coverage': 0.8, 'low_factor': 0.884, 'high_factor': 1.279}

What this tells us. The hash of your file starts with 8d0483d05cfc, the same as in the card: you have the analyst’s file. The model’s name is kc-hgb-2015-02: King County, gradient boosting, prices up to February 2015. The test results are the ones from the section above. The last line is new: a range. You use it in Part 4.

Python saves a scikit-learn model with joblib, which uses Python’s pickle format. Pickle does not only store numbers. It stores instructions to rebuild Python objects, and those instructions can call any function. So loading a pickle file means running what is in it.

You can see this with a harmless example. Start Python with python in the terminal, paste this, then leave with exit():

import pickle
class Surprise:
def __reduce__(self):
return (print, ("This line ran while the file was loading.",))
data = pickle.dumps(Surprise())
print(len(data), "bytes")
pickle.loads(data)
Output
81 bytes
This line ran while the file was loading.

What this tells us. pickle.loads called print only because the data told it to. A file made by an attacker could call something much worse than print, with the rights of your server. So the rule is: load a pickle only from a file that you trust. The check must come before the load, not after.

Three other things can go wrong with a model file, and none of them is an attack:

  • The file is missing, for example because the deployment left it out.
  • It is another model: someone retrained it and copied the new file, but nobody reviewed it.
  • It was saved with another scikit-learn version. scikit-learn then warns InconsistentVersionWarning: the model may load, but give different or wrong answers.

The idea: compare a fingerprint, then load

Section titled “The idea: compare a fingerprint, then load”

The fix is a check at start-up, like a bank teller who compares a signature before paying. The code holds the hash of the reviewed file. At start-up, the API computes the hash of the file on disk and compares the two. Only if they are equal does it load the file. A changed file, even by one byte, is never loaded. And because the expected hash is in the code, changing the model needs a change in the code, which a reviewer sees.

What happens when a check fails? The API must not crash, and it must not answer with a broken model. It keeps running, so /health still answers 200, but it is not ready: /ready and every estimate answer 503 model_unavailable. A platform then knows that a restart does not help, and a person reads the reason in the log. This is the liveness and readiness split from Publish safely.

Flow of the start-up checks. The API starts. If the model file does not exist, the API is not ready. If its SHA-256 does not equal MODEL_SHA256 in the code, not ready. If model_card.json names another SHA-256, not ready. Only then does joblib.load run. If the file was saved with another scikit-learn version, not ready. Otherwise the model is loaded: /ready gives 200 and estimates work. Not ready means: the error goes to the log, /ready and estimates give 503 model_unavailable, and /health still gives 200.
Four checks at start-up. The file is loaded only after its hash matches the reviewed one, and any failure makes the API not ready instead of crashing it.
Text description of the diagram

When the API starts, it checks the model file in four steps. First, the file must exist. Second, its SHA-256 must equal MODEL_SHA256 in the code. Third, model_card.json must name the same SHA-256. Only after these three checks does joblib.load run, and the fourth check happens during the load: the file must come from the same scikit-learn version. If all four pass, the model is loaded, /ready gives 200, and estimates work. If any check fails, the reason goes to the log, /ready and the estimate endpoints give 503 model_unavailable, and /health still gives 200.

The reviewed hash goes in config.py, next to the settings. Add MODEL_SHA256 and three settings:

house_price_api/config.py (changes)
PROJECT = Path(__file__).resolve().parent.parent
# The SHA-256 of the reviewed model file. A new model file needs a new value
# here, and a new model_version, in one reviewed change.
MODEL_SHA256 = "8d0483d05cfca4752118c5c6ef4c47a9531c7230981cc880c27d85def9dbfdec"
@dataclass(frozen=True)
class Settings:
api_key: str | None = field(repr=False)
allowed_origins: list[str]
model_path: Path
model_sha256: str
estimate_timeout: float
log_level: str
show_docs: bool
def load_settings() -> Settings:
"""Read the settings from environment variables, with safe defaults."""
origins = os.environ.get("ALLOWED_ORIGINS", "")
return Settings(
api_key=os.environ.get("API_KEY") or None,
allowed_origins=[o.strip() for o in origins.split(",") if o.strip()],
model_path=Path(
os.environ.get("MODEL_PATH", PROJECT / "model" / "house_price.joblib")
),
model_sha256=MODEL_SHA256,
estimate_timeout=float(os.environ.get("ESTIMATE_TIMEOUT", "2.0")),
log_level=os.environ.get("LOG_LEVEL", "INFO").upper(),
show_docs=os.environ.get("SHOW_DOCS", "true").lower() == "true",
)
Setting Why
model_path Where the file is. The default is relative to the project, not to the terminal’s folder, so the API finds it wherever it starts. MODEL_PATH can change it, which you use to test a failure.
model_sha256 The reviewed hash. It is not read from an environment variable: a setting that anyone can change on the server would defeat the check. Only the tests give another value.
estimate_timeout The time limit for the model, as classifier_timeout in Basic protection.

A new module holds everything about the model: the check, the loading and the prediction. Make house_price_api/estimator.py:

house_price_api/estimator.py
import hashlib
import json
import warnings
from dataclasses import dataclass
from pathlib import Path
import joblib
import numpy as np
from sklearn.exceptions import InconsistentVersionWarning
# The model's inputs, in the order that it was trained with.
FEATURES = [
"bedrooms", "bathrooms", "sqft_living", "sqft_lot", "floors", "waterfront",
"view", "condition", "grade", "sqft_above", "sqft_basement", "yr_built",
"yr_renovated", "lat", "long", "sqft_living15", "sqft_lot15",
]
# The typical house of the documentation, in the model's own terms, for the
# readiness check: sqft_above is a column, and 0 means "never renovated".
READY_CHECK_HOUSE = {
"bedrooms": 3, "bathrooms": 1.75, "sqft_living": 1560, "sqft_lot": 9452,
"floors": 1, "waterfront": 0, "view": 0, "condition": 4, "grade": 8,
"sqft_above": 1560, "sqft_basement": 0, "yr_built": 1974, "yr_renovated": 0,
"lat": 47.5197, "long": -122.18, "sqft_living15": 2200, "sqft_lot15": 9985,
}
class ModelUnavailable(Exception):
"""The model cannot answer now. The request may work later."""
@dataclass(frozen=True)
class Estimate:
value: float
low: float
high: float
def file_sha256(path: Path) -> str:
"""The SHA-256 of a file, read in blocks of 1 MB."""
digest = hashlib.sha256()
with path.open("rb") as file:
for block in iter(lambda: file.read(1 << 20), b""):
digest.update(block)
return digest.hexdigest()
class HouseEstimator:
"""The loaded model and its card."""
def __init__(self, model, card: dict):
self.model = model
self.card = card
self.version: str = card["model_version"]
self.prices_as_of: str = card["prices_as_of"]
self.low_factor: float = card["range"]["low_factor"]
self.high_factor: float = card["range"]["high_factor"]
self.usual_values: dict[str, list[float]] = card["usual_values"]
def estimate(self, rows: list[dict[str, float]]) -> list[Estimate]:
"""Estimate the price of each house, with the range around it."""
x = np.array([[row[name] for name in FEATURES] for row in rows], dtype=float)
return [
Estimate(value, value * self.low_factor, value * self.high_factor)
for value in self.model.predict(x).tolist()
]
def rare_values(self, row: dict[str, float]) -> list[str]:
"""The inputs outside the values of 98% of the training houses."""
return [
name
for name, (low, high) in self.usual_values.items()
if not low <= row[name] <= high
]
def load_estimator(model_path: Path, expected_sha256: str) -> HouseEstimator:
"""Check the model file, then load it. Raise ModelUnavailable if anything is wrong."""
if not model_path.is_file():
raise ModelUnavailable(f"no model file at {model_path}")
actual = file_sha256(model_path)
if actual != expected_sha256:
raise ModelUnavailable(
f"{model_path.name} has SHA-256 {actual[:12]}..., "
f"expected {expected_sha256[:12]}..."
)
try:
card = json.loads(model_path.with_name("model_card.json").read_text("utf-8"))
except (OSError, ValueError) as error:
raise ModelUnavailable(f"cannot read model_card.json: {error}") from None
if card.get("sha256") != actual:
raise ModelUnavailable("model_card.json describes a different model file")
# The hash matches, so this is the file that we reviewed. Only now is it
# safe to load it: joblib uses pickle, which can run code from the file.
with warnings.catch_warnings():
warnings.simplefilter("error", InconsistentVersionWarning)
try:
model = joblib.load(model_path)
except InconsistentVersionWarning as warning:
raise ModelUnavailable(
f"the model was saved with scikit-learn "
f"{warning.original_sklearn_version}, this server has "
f"{warning.current_sklearn_version}"
) from None
return HouseEstimator(model, card)

The important parts:

Part Why
FEATURES The model receives 17 numbers with no names, so their order must be the training order. One list, used everywhere, keeps it right.
READY_CHECK_HOUSE A real house for /ready to estimate. It is in the model’s terms: sqft_above is a column, and yr_renovated is 0 for “never”. The API’s own terms come in Part 3.
file_sha256 Reads the file in blocks, so a large model never needs to fit in memory twice.
The three checks, then joblib.load The order is the safety: the file is loaded only after its hash is the reviewed one.
simplefilter("error", InconsistentVersionWarning) Turns the version warning into an exception. A model from another scikit-learn version then makes the API not ready, instead of a warning that nobody reads.
estimate() takes a list One call to predict for many houses. Part 5 uses this for the batch.

Step 3: the start-up, the error and /ready

Section titled “Step 3: the start-up, the error and /ready”

In errors.py, a handler turns ModelUnavailable into the standard 503, as ClassifierUnavailable did in the course:

house_price_api/errors.py (changes)
from house_price_api.estimator import ModelUnavailable
# in add_error_handlers, before the handler for Exception:
@app.exception_handler(ModelUnavailable)
async def model_unavailable(request: Request, exc: ModelUnavailable):
logger.warning("model unavailable: %s id=%s", exc, request.state.request_id)
return error_response(
request,
503,
"model_unavailable",
"The model is not available. Try again later.",
)

In main.py, create_app loads the model once, at start-up, and keeps the result in app.state. Loading it for each request would read and check 0.5 MB every time.

Make these changes in main.py:

  • Add import sys and the estimator import at the top, with the other imports.
  • The new from house_price_api.models import ErrorResponse, Health, Readiness replaces the existing line from house_price_api.models import Health.
  • Put the function memory_mb() at the top level of the file, after logger = ... and the DESCRIPTION text, and before def create_app.
  • Put the other two blocks inside create_app, at the places that their comments name.
house_price_api/main.py (changes)
import sys
from house_price_api.estimator import READY_CHECK_HOUSE, ModelUnavailable, load_estimator
from house_price_api.models import ErrorResponse, Health, Readiness
def memory_mb() -> str:
"""The most memory that this process has used so far, in MB."""
try:
import resource # not on Windows
except ImportError:
return "unknown"
peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
return f"{peak / (1e6 if sys.platform == 'darwin' else 1e3):.0f}"
# in create_app, after the API_KEY warning:
started = time.perf_counter()
try:
app.state.estimator = load_estimator(settings.model_path, settings.model_sha256)
app.state.model_problem = None
logger.info(
"model %s loaded in %.0f ms, memory %s MB",
app.state.estimator.version,
(time.perf_counter() - started) * 1000,
memory_mb(),
)
except ModelUnavailable as problem:
app.state.estimator = None
app.state.model_problem = str(problem)
logger.error("model not loaded: %s", problem)
# in create_app, after /health:
@app.get(
"/ready",
tags=["health"],
summary="Check that the model can estimate prices",
responses={503: {"model": ErrorResponse, "description": "Not ready."}},
)
def ready() -> Readiness:
if app.state.estimator is None:
raise ModelUnavailable(app.state.model_problem)
app.state.estimator.estimate([READY_CHECK_HOUSE])
return Readiness(status="ready")

The log line records how long the load took and how much memory the process used. You need both numbers for the deployment: a free FastAPI Cloud instance has 500 MiB of memory. /ready does more than look at app.state: it asks the model for one real estimate, so it proves that the model answers.

A test can do what is hard to do by hand. It can give the API a missing file, a wrong hash, or a model from another scikit-learn version. First, the test settings need the new fields. Replace tests/conftest.py:

tests/conftest.py
import json
from dataclasses import replace
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from house_price_api.config import MODEL_SHA256, PROJECT, Settings
from house_price_api.main import create_app
TEST_SETTINGS = Settings(
api_key=None,
allowed_origins=[],
model_path=PROJECT / "model" / "house_price.joblib",
model_sha256=MODEL_SHA256,
estimate_timeout=2.0,
log_level="WARNING",
show_docs=True,
)
GOLDEN_FILE = Path(__file__).parent / "golden_houses.json"
@pytest.fixture
def make_client():
"""Return a function that makes a test client with changed settings."""
def make(**changes) -> TestClient:
return TestClient(create_app(replace(TEST_SETTINGS, **changes)))
return make
@pytest.fixture
def client(make_client) -> TestClient:
return make_client()
@pytest.fixture
def house() -> dict:
"""The typical golden house, a new copy for each test."""
return json.loads(GOLDEN_FILE.read_text("utf-8"))[0]["house"]

Then make tests/test_model_file.py:

tests/test_model_file.py
import json
import shutil
import joblib
import sklearn.base
from sklearn.dummy import DummyRegressor
from house_price_api.config import MODEL_SHA256, PROJECT
from house_price_api.estimator import file_sha256
MODEL = PROJECT / "model" / "house_price.joblib"
def assert_not_ready(client):
"""Liveness stays 200; readiness gives 503, without internals."""
assert client.get("/health").status_code == 200
response = client.get("/ready")
assert response.status_code == 503
assert response.json()["error"]["code"] == "model_unavailable"
assert "Traceback" not in response.text
def test_ready(client):
assert client.get("/ready").json() == {"status": "ready"}
def test_the_reviewed_hash_matches_the_file_and_the_card():
card = json.loads((PROJECT / "model" / "model_card.json").read_text("utf-8"))
assert file_sha256(MODEL) == MODEL_SHA256 == card["sha256"]
def test_missing_model_file(make_client, tmp_path):
assert_not_ready(make_client(model_path=tmp_path / "house_price.joblib"))
def test_wrong_hash(make_client):
assert_not_ready(make_client(model_sha256="0" * 64))
def test_card_of_another_model(make_client, tmp_path):
shutil.copy(MODEL, tmp_path / "house_price.joblib")
card = json.loads((PROJECT / "model" / "model_card.json").read_text("utf-8"))
card["sha256"] = "0" * 64
(tmp_path / "model_card.json").write_text(json.dumps(card), "utf-8")
assert_not_ready(make_client(model_path=tmp_path / "house_price.joblib"))
def test_other_scikit_learn_version(make_client, tmp_path, monkeypatch):
# Save a small model as if an older scikit-learn had made it.
path = tmp_path / "house_price.joblib"
monkeypatch.setattr(sklearn.base, "__version__", "1.0.0")
joblib.dump(DummyRegressor().fit([[0]], [0]), path)
monkeypatch.undo()
digest = file_sha256(path)
card = {"sha256": digest}
(tmp_path / "model_card.json").write_text(json.dumps(card), "utf-8")
assert_not_ready(make_client(model_path=path, model_sha256=digest))

tmp_path is a pytest fixture: a new empty folder for each test. monkeypatch is another one: it changes a value for one test, and monkeypatch.undo() puts the old value back. The last test pretends to be scikit-learn 1.0.0 while it saves a tiny model, so loading it gives the real version warning. Run the tests:

Terminal
python -m pytest
Output (last lines)
tests/test_health.py .. [ 25%]
tests/test_model_file.py ...... [100%]
============================== 8 passed in 0.14s ===============================

What this tells us. Each way that the model file can be wrong now gives the same, safe result. The process lives, readiness fails with model_unavailable, and no traceback reaches the client. The second test also ties three things together: the file, the hash in the code, and the hash in the card. If one of them changes alone, it fails.

Start the API again with fastapi dev, and call /ready:

Second terminal
curl -i http://127.0.0.1:8000/ready
Output
HTTP/1.1 200 OK
date: Thu, 08 Oct 2026 00:32:18 GMT
server: uvicorn
content-length: 18
content-type: application/json
x-request-id: 0af354953e87
{"status":"ready"}
First terminal (the API's log)
WARNING house_price_api: API_KEY is not set: /v1/estimates accepts requests without a key
INFO house_price_api: model kc-hgb-2015-02 loaded in 265 ms, memory 222 MB
INFO house_price_api: GET /ready 200 32ms id=0af354953e87

What this tells us. The model loads in about a quarter of a second, and the process used at most about 220 MB of memory. Most of that is scikit-learn, SciPy and NumPy themselves, not the 0.5 MB model. That fits in the 500 MiB of a free FastAPI Cloud instance, with room to spare. The first readiness check took 32 ms because it was the model’s first prediction. Later ones are faster, but on a computer with many cores they can still take 5 to 20 ms. The reason: the model starts a thread for each core, even for one house. Part 5 fixes that, and then a check takes 1 or 2 ms.

Now give the API a file that differs by one byte. Stop the server (Ctrl+C), make the copy, and start the API with MODEL_PATH pointing to it:

Terminal window
python -c "import shutil; shutil.copy('model/house_price.joblib', 'changed.joblib'); open('changed.joblib', 'ab').write(b'x')"
MODEL_PATH=changed.joblib fastapi dev
Second terminal
curl -s -i http://127.0.0.1:8000/health
curl -s http://127.0.0.1:8000/ready
Output (the first response shortened to its status line)
HTTP/1.1 200 OK
{"error":{"code":"model_unavailable","message":"The model is not available. Try again later.","request_id":"c8c00376afc8","fields":[]}}
First terminal (the API's log)
ERROR house_price_api: model not loaded: changed.joblib has SHA-256 7d52c6acfc65..., expected 8d0483d05cfc...
WARNING house_price_api: model unavailable: changed.joblib has SHA-256 7d52c6acfc65..., expected 8d0483d05cfc... id=c8c00376afc8

What this tells us. One added byte gave a completely different hash, so the file was never loaded: no code from it ran. The process still answers /health, and /ready says that the model is not available, in the standard shape. The client sees a short message; the log has the details, with the request ID to connect the two. Stop the server and delete changed.joblib. On Windows, also remove the setting with Remove-Item Env:MODEL_PATH.

Part 3: check the inputs against what the model knows

Section titled “Part 3: check the inputs against what the model knows”

Your API could pass any 17 numbers to the model. Start Python again, and paste this to see what the bare model does with nonsense:

import joblib
from house_price_api.estimator import FEATURES, READY_CHECK_HOUSE
model = joblib.load("model/house_price.joblib")
houses = {
"typical house": READY_CHECK_HOUSE,
"the same house in New York": {**READY_CHECK_HOUSE, "lat": 40.71, "long": -74.01},
"with 40 bedrooms": {**READY_CHECK_HOUSE, "bedrooms": 40},
"with -500 sq ft": {**READY_CHECK_HOUSE, "sqft_living": -500, "sqft_above": -500},
}
for name, house in houses.items():
price = model.predict([[house[f] for f in FEATURES]])[0]
print(f"{name:28} {price:>10,.0f}")

After you paste the code, press Enter once more. The Python prompt waits for an empty line to know that the for loop is complete, and only then runs it.

Output
typical house 421,936
the same house in New York 258,159
with 40 bedrooms 391,415
with -500 sq ft 295,154

What this tells us. The model gives a confident, precise-looking price for every one of them. A house of minus 500 square feet is worth 295,154 dollars, it says. It never says “I don’t know”. The reason is how trees work. Each tree splits the inputs into ranges that it saw in training, and a value far outside falls into the last range. For the model, New York is just “far south and east of Seattle”, and 40 bedrooms is “more than 11”. So the API must decide what the model may be asked, before it asks.

The idea: the training data sets the limits

Section titled “The idea: the training data sets the limits”

A good rule comes from the data: accept values that the model has seen, and refuse the others with a clear error. Think of a shop’s scale that weighs up to 15 kg. Put 30 kg on it, and it does not show “30”: it shows an error. A wrong number would be worse than no number.

For each field, the training data has a smallest and a largest value. For example, the living area went from 290 to 13,540 sq ft. The API’s limit is that range, rounded outward to round numbers: 250 to 14,000. Rounding makes the limits easy to document. It changes nothing that matters: a tree gives the same answer at 13,540 and at 14,000.

Three more rules make the input match what the model learned:

  1. Types are strict. "3" (text) for bedrooms is refused, not quietly turned into 3. A client that sends text where a number belongs has a bug, and it is better found now. In Pydantic, strict=True does this.
  2. No codes that look like numbers. In the data, yr_renovated is 0 for “never renovated”. In the API, “never” is null, so no client sends 0 by mistake. The API changes null into 0 for the model.
  3. No field that must agree with another. The model has sqft_above, the area above ground. It is always sqft_living - sqft_basement, so the API computes it instead of asking for it. Then two fields can never disagree.

The place is different. A house in New York is not a wrong value: its latitude is a correct latitude. It is outside the area that the model knows. So it gets its own error code, outside_model_area, which tells the intake tool exactly what to say to the user. The area is the smallest box around every training house: latitude 47.15 to 47.78, longitude −122.52 to −121.31.

Add the request model to house_price_api/models.py. Change the Pydantic import line, and add this above the Health model:

house_price_api/models.py (add)
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
# The area that the model knows: the smallest box around every training house.
MODEL_AREA = {"lat": (47.15, 47.78), "long": (-122.52, -121.31)}
TYPICAL_HOUSE = {
"bedrooms": 3,
"bathrooms": 1.75,
"sqft_living": 1560,
"sqft_lot": 9452,
"floors": 1.0,
"waterfront": False,
"view": 0,
"condition": 4,
"grade": 8,
"sqft_basement": 0,
"yr_built": 1974,
"yr_renovated": None,
"lat": 47.5197,
"long": -122.18,
"sqft_living15": 2200,
"sqft_lot15": 9985,
}
class HouseIn(BaseModel):
"""The description of one house in King County, Washington."""
model_config = ConfigDict(
extra="forbid", strict=True, json_schema_extra={"examples": [TYPICAL_HOUSE]}
)
bedrooms: int = Field(ge=0, le=11, description="Number of bedrooms.")
bathrooms: float = Field(
ge=0,
le=8,
multiple_of=0.25,
description="Number of bathrooms. 0.5 is a toilet without a shower.",
)
sqft_living: int = Field(
ge=250, le=14_000, description="Living area in square feet, basement included."
)
sqft_lot: int = Field(ge=500, le=1_100_000, description="Land area in square feet.")
floors: float = Field(
ge=1, le=3.5, multiple_of=0.5, description="Floors. 1.5 is a partial upper floor."
)
waterfront: bool = Field(description="True if the house is on the water.")
view: int = Field(ge=0, le=4, description="Quality of the view, 0 (none) to 4.")
condition: int = Field(ge=1, le=5, description="Condition, 1 (poor) to 5 (very good).")
grade: int = Field(
ge=1, le=13, description="King County building grade, 1 to 13. 7 is average."
)
sqft_basement: int = Field(
ge=0, le=5_000, description="Basement area in square feet. 0 = no basement."
)
yr_built: int = Field(ge=1900, le=2015, description="Year built.")
yr_renovated: int | None = Field(
default=None,
ge=1900,
le=2015,
description="Year of the last renovation. null = never renovated.",
)
lat: float = Field(ge=-90, le=90, description="Latitude.")
long: float = Field(ge=-180, le=180, description="Longitude.")
sqft_living15: int = Field(
ge=400, le=6_500, description="Average living area of the 15 nearest houses."
)
sqft_lot15: int = Field(
ge=600, le=900_000, description="Average land area of the 15 nearest houses."
)
@field_validator("sqft_basement")
@classmethod
def basement_is_part_of_living_area(cls, value: int, info: ValidationInfo) -> int:
living = info.data.get("sqft_living")
if living is not None and value >= living:
raise ValueError("must be smaller than sqft_living")
return value
@field_validator("yr_renovated")
@classmethod
def renovated_after_built(cls, value: int | None, info: ValidationInfo):
built = info.data.get("yr_built")
if value is not None and built is not None and value < built:
raise ValueError("must not be before yr_built")
return value
def outside_model_area(self) -> list[str]:
"""The location fields outside the area that the model knows."""
return [
name
for name, (low, high) in MODEL_AREA.items()
if not low <= getattr(self, name) <= high
]
def model_inputs(self) -> dict[str, float]:
"""The 17 numbers that the model expects, with its own codes."""
values = self.model_dump()
values["waterfront"] = int(self.waterfront)
values["sqft_above"] = self.sqft_living - self.sqft_basement
values["yr_renovated"] = self.yr_renovated or 0 # the model's code for "never"
return values
Part Meaning
extra="forbid" An unknown field, such as zipcode, is an error, as in the course.
multiple_of=0.25 Bathrooms come in quarters in the data (0.75 is a shower without a bath). 0.3 is refused.
lat: float = Field(ge=-90, le=90) Any real latitude passes validation. The area check comes later, with its own code.
field_validator with info.data A rule about two fields. info.data has the fields above this one that are already valid, which is why sqft_living comes before sqft_basement. The error names the field of the validator.
model_inputs() The bridge between the two languages: the API’s fields become the model’s 17 numbers.

The area check needs its own exception and handler. In errors.py, add the exception below CODES, and a handler after the one for validation errors:

house_price_api/errors.py (add)
class OutsideModelArea(Exception):
"""A house is outside the area that the model knows."""
def __init__(self, fields: list[str]):
super().__init__(", ".join(fields))
self.fields = fields
# in add_error_handlers, after the handler for RequestValidationError:
@app.exception_handler(OutsideModelArea)
async def outside_model_area(request: Request, exc: OutsideModelArea):
return error_response(
request,
422,
"outside_model_area",
"The model knows only houses in King County, Washington.",
exc.fields,
)

Why 422, the same status as a validation error? The request is well formed but cannot be processed, and that is what 422 means. The code is what a program checks, and the code is different.

Part 4: an estimate with a range, a version and warnings

Section titled “Part 4: an estimate with a range, a version and warnings”

Suppose the API answers {"estimate": 421936.2}. Elif’s intake tool shows “421,936 USD”. A loan officer reads it as a fact, to the dollar. But the typical miss of this model is about 12.8%, or 54,000 dollars on this house. Six digits suggest a precision that the model does not have.

Two more things are missing from that answer. Which model said it? If the analyst retrains next month, the same house gets a new number, and nobody can tell later which model gave which estimate. And what are the prices of? This model knows 2014–2015 prices, and a reader in another year must see that.

The idea: a range from the model’s own errors

Section titled “The idea: a range from the model’s own errors”

A weather forecast says “between 18 and 23 degrees”, and people plan well with that. The API can do the same, because the analyst measured the model’s errors on test sales that it never saw.

For each test sale, divide the real price by the estimate. A result of 1.10 means the house sold for 10% more than the estimate. Sort the 4,654 results. The value with 10% of them below it is 0.884, and the value with 90% below it is 1.279. So 80% of the test sales sold between 0.884 and 1.279 times their estimate. These two numbers are quantiles, and the card has them as low_factor and high_factor.

For the typical house of the documentation, the model’s estimate is 421,936 dollars:

Calculation Result (rounded to 1,000)
Low end 421,936 × 0.884 373,000
Estimate 422,000
High end 421,936 × 1.279 540,000

The range is not centred on the estimate. It reaches 28% above and only 12% below. Prices rose in spring 2015 and the model did not see it, so real prices were more often above the estimate than below. The range shows this honestly. A symmetric “±10%” would hide it. Rounding to the nearest 1,000 dollars is part of the same idea: the answer shows no more precision than the model has.

The answer also gets:

  • model_version, for example kc-hgb-2015-02, so every estimate can be traced to one model, as Daniel asked.
  • prices_as_of, the date of the last sale that the model learned from: 2015-02-28.
  • warnings: reasons to trust this estimate less. There are two kinds. high_estimate is for an estimate of 1 million dollars or more, where the model was within 10% only 4 times in 10. rare_value is for an input outside the values of 98% of the training houses (below the 1st or above the 99th percentile), with the field names. A warning is not an error: the estimate is still given, with a reason to check it.

Add these to models.py, below HouseIn:

house_price_api/models.py (add)
class PriceRange(BaseModel):
low: int = Field(description="Lower end, US dollars.")
high: int = Field(description="Upper end, US dollars.")
class EstimateWarning(BaseModel):
code: Literal["high_estimate", "rare_value"] = Field(
description="A stable code that a program can check."
)
message: str = Field(description="A short explanation for a person.")
fields: list[str] = Field(
default=[], description="The inputs that the warning is about, if any."
)
class EstimateOut(BaseModel):
"""A first estimate of the sale price, before an appraisal."""
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"estimate_usd": 422000,
"range_usd": {"low": 373000, "high": 540000},
"model_version": "kc-hgb-2015-02",
"prices_as_of": "2015-02-28",
"warnings": [],
}
]
}
)
estimate_usd: int = Field(
description="The estimated sale price, US dollars, rounded to 1,000."
)
range_usd: PriceRange = Field(
description=(
"8 in 10 test sales sold inside this range around their estimate. "
"It is wider above the estimate, because the model lags a rising market."
)
)
model_version: str = Field(description="The model that made the estimate.")
prices_as_of: str = Field(
description="The last sale date that the model learned from: prices of that time."
)
warnings: list[EstimateWarning] = Field(
description="Reasons to trust this estimate less. Empty if there are none."
)
class Evaluation(BaseModel):
sales: int
first_sales: str
median_error_pct: float
mape_pct: float
within_10pct: float
over_by_10pct_or_more: float
class RangeInfo(BaseModel):
coverage: float
low_factor: float
high_factor: float
class ModelInfo(BaseModel):
"""The model card: what the model is for, and how well it did."""
name: str
model_version: str
sha256: str
prices_as_of: str
intended_use: str
not_for: list[str]
test: Evaluation
range: RangeInfo
model: str
features: list[str]

The field names end in _usd, so nobody wonders about the currency. ModelInfo is the card for GET /v1/model: the parts of model_card.json that a client may need.

Make house_price_api/routes.py:

house_price_api/routes.py
import asyncio
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.concurrency import run_in_threadpool
from house_price_api.errors import OutsideModelArea
from house_price_api.estimator import HouseEstimator, ModelUnavailable
from house_price_api.models import (
ErrorResponse,
EstimateOut,
EstimateWarning,
HouseIn,
ModelInfo,
PriceRange,
)
from house_price_api.security import require_api_key
router = APIRouter(prefix="/v1", tags=["estimates"])
HIGH_ESTIMATE_USD = 1_000_000
ERRORS = {
401: {"model": ErrorResponse, "description": "The API key is missing or wrong."},
413: {"model": ErrorResponse, "description": "The request body is too large."},
422: {
"model": ErrorResponse,
"description": (
"invalid_request: a field is missing or not valid. "
"outside_model_area: the house is outside King County."
),
},
503: {"model": ErrorResponse, "description": "The model is not available."},
}
def to_thousand(dollars: float) -> int:
return int(round(dollars, -3))
def get_estimator(request: Request) -> HouseEstimator:
estimator = request.app.state.estimator
if estimator is None:
raise ModelUnavailable(request.app.state.model_problem)
return estimator
def check_area(houses: list[HouseIn], prefix: str = "") -> None:
fields = [
f"{prefix}{index}.{name}" if prefix else name
for index, house in enumerate(houses)
for name in house.outside_model_area()
]
if fields:
raise OutsideModelArea(fields)
def warnings_for(
estimator: HouseEstimator, inputs: dict[str, float], estimate: float
) -> list[EstimateWarning]:
warnings = []
if estimate >= HIGH_ESTIMATE_USD:
warnings.append(
EstimateWarning(
code="high_estimate",
message=(
"Estimates of 1,000,000 USD or more were within 10% of the "
"sale price only 4 times in 10. Ask for an appraisal first."
),
)
)
rare = estimator.rare_values(inputs)
if rare:
warnings.append(
EstimateWarning(
code="rare_value",
message=(
"Fewer than 1 in 100 training houses had a value this small "
"or this large. The estimate is less certain."
),
fields=rare,
)
)
return warnings
async def estimate_houses(
request: Request, estimator: HouseEstimator, houses: list[HouseIn]
) -> list[EstimateOut]:
rows = [house.model_inputs() for house in houses]
timeout = request.app.state.settings.estimate_timeout
try:
estimates = await asyncio.wait_for(
run_in_threadpool(estimator.estimate, rows), timeout
)
except TimeoutError:
raise ModelUnavailable(f"no answer within {timeout} seconds") from None
return [
EstimateOut(
estimate_usd=to_thousand(estimate.value),
range_usd=PriceRange(
low=to_thousand(estimate.low), high=to_thousand(estimate.high)
),
model_version=estimator.version,
prices_as_of=estimator.prices_as_of,
warnings=warnings_for(estimator, row, estimate.value),
)
for row, estimate in zip(rows, estimates)
]
@router.get(
"/model",
summary="Describe the model",
description="The model card: its version, intended use, limits and test results.",
responses={503: ERRORS[503]},
)
def model_card(
estimator: Annotated[HouseEstimator, Depends(get_estimator)],
) -> ModelInfo:
return ModelInfo.model_validate(estimator.card)
@router.post(
"/estimates",
summary="Estimate the price of one house",
description=(
"A first estimate of the sale price, at 2014-15 prices, with a range "
"and warnings. Not for lending, underwriting or insurance decisions."
),
response_description="The estimate, its range and its warnings",
responses=ERRORS,
dependencies=[Depends(require_api_key)],
)
async def estimate_one(
house: HouseIn,
request: Request,
estimator: Annotated[HouseEstimator, Depends(get_estimator)],
) -> EstimateOut:
check_area([house])
[result] = await estimate_houses(request, estimator, [house])
return result
Part Why
get_estimator A dependency, as in Separate concerns. If the model is not loaded, every route that needs it gives 503, from one place.
check_area Runs after validation, so it only sees valid numbers. The prefix is for the batch in Part 5.
estimate_houses The time limit from the course, around one call for all the houses.
/v1/model without a key The card is public information. A client can check the model version without a key.
The description says “Not for lending…” The limit of use is in the contract, where every developer who reads /docs sees it.

Last, in main.py, import the router and include it, as in the course:

house_price_api/main.py (changes)
from house_price_api.routes import router
# in create_app, after add_error_handlers(app):
app.include_router(router)

This is the path that each estimate request takes now:

Decision chain for POST /v1/estimates. Body size over 64 KB: 413 body_too_large. API key wrong: 401 unauthorized. Types and limits of HouseIn not met: 422 invalid_request with fields. House outside King County: 422 outside_model_area with fields. Model not loaded or too slow: 503 model_unavailable. Otherwise 200 with the estimate, range, model_version and warnings.
Each layer refuses early with its own status and code. Only a valid house inside the model's area reaches the model.
Text description of the diagram

A request to POST /v1/estimates passes five checks in order. A body over 64 KB gets 413 body_too_large. A missing or wrong key gets 401 unauthorized. A body that does not match the types and limits of HouseIn gets 422 invalid_request, with the field names. A house outside King County gets 422 outside_model_area, with lat, long or both. A model that is not loaded, or does not answer within the time limit, gives 503 model_unavailable. Only then does the API answer 200, with the estimate, the range, the model version and any warnings. The body limit is 16 KB until Part 5 changes it.

Start the API with fastapi dev. The folder requests/ has the three golden houses: real houses from the test months, which the model never saw. Send the typical house first:

Second terminal (on Windows: curl.exe, with the @requests/... part in quotes)
curl -s -X POST http://127.0.0.1:8000/v1/estimates -H "Content-Type: application/json" -d @requests/typical-house.json
Output
{"estimate_usd":422000,"range_usd":{"low":373000,"high":540000},"model_version":"kc-hgb-2015-02","prices_as_of":"2015-02-28","warnings":[]}

What this tells us. This house sold on 2015-04-26 for 425,000 dollars. The estimate, 422,000, missed by less than 1%, and the sale price is well inside the range. There is no warning: an ordinary house that the model knows well. The answer has everything that Daniel asked for: the model, and the time of the prices.

Send waterfront-house.json the same way:

Output
{"estimate_usd":2156000,"range_usd":{"low":1906000,"high":2757000},"model_version":"kc-hgb-2015-02","prices_as_of":"2015-02-28","warnings":[{"code":"high_estimate","message":"Estimates of 1,000,000 USD or more were within 10% of the sale price only 4 times in 10. Ask for an appraisal first.","fields":[]},{"code":"rare_value","message":"Fewer than 1 in 100 training houses had a value this small or this large. The estimate is less certain.","fields":["sqft_basement"]}]}

What this tells us. A waterfront house with 4,640 sq ft, sold for 2,200,000 dollars. The model was close this time (2,156,000), but the API cannot know that in advance. The range is 850,000 dollars wide, and two warnings say why to be careful. The estimate is above 1 million, and the 1,780 sq ft basement is larger than in 99% of training houses. The intake tool can show both, and send the house to an appraiser first.

Then large-lot-house.json:

Output
{"estimate_usd":374000,"range_usd":{"low":331000,"high":479000},"model_version":"kc-hgb-2015-02","prices_as_of":"2015-02-28","warnings":[{"code":"rare_value","message":"Fewer than 1 in 100 training houses had a value this small or this large. The estimate is less certain.","fields":["sqft_lot","sqft_lot15"]}]}

What this tells us. A house on 273,556 sq ft (about 2.5 hectares) of land, in an area of large lots. It sold for 350,000; the estimate is 374,000, 7% too high. The warning names both unusual fields, so a person knows where to look. The estimate is still given: rare is not wrong.

Last, two wrong requests, outside-king-county.json and bedrooms-as-text.json:

Output
{"error":{"code":"outside_model_area","message":"The model knows only houses in King County, Washington.","request_id":"ebe35f796154","fields":["lat","long"]}}
{"error":{"code":"invalid_request","message":"The request is not valid.","request_id":"b5593f0c7830","fields":["bedrooms"]}}

What this tells us. The bare model valued the New York house at 258,159 dollars. Now it gets no price at all, and a code that the intake tool can explain. "bedrooms": "three" is refused, and the error names the field. Both errors have the course’s shape and a request ID.

Open http://127.0.0.1:8000/v1/model in your browser. It shows the card as JSON: the version, the hash, the intended use, the not_for list, the test results and the range factors. A client can check, at any time, which model it is talking to.

The problem: one request per house is slow

Section titled “The problem: one request per house is slow”

Once a month, after the model is retrained, Hannah’s team wants to estimate every open application again: about 2,000 houses. One request per house means 2,000 round trips over the network. But the model itself is fast with many houses. One call to predict for 100 houses costs about the same as for one, because most of the time goes into starting the call.

So the API gets a batch endpoint: POST /v1/estimates/batch with 1 to 100 houses. It answers one estimate per house, in the same order. Order matters, because the answer has no other way to say which estimate belongs to which house.

Add to models.py:

house_price_api/models.py (add)
# below HouseIn:
class HouseBatchIn(BaseModel):
"""1 to 100 houses, estimated in one request."""
model_config = ConfigDict(extra="forbid")
houses: list[HouseIn] = Field(min_length=1, max_length=100)
# below EstimateOut:
class EstimateBatchOut(BaseModel):
estimates: list[EstimateOut] = Field(description="One estimate per house, in order.")

Add EstimateBatchOut and HouseBatchIn to the import in routes.py, and add the route at the end:

house_price_api/routes.py (add)
@router.post(
"/estimates/batch",
summary="Estimate the prices of 1 to 100 houses",
description="The same as /v1/estimates for each house. The estimates keep the order of the houses.",
response_description="One estimate per house, in order",
responses=ERRORS,
dependencies=[Depends(require_api_key)],
)
async def estimate_batch(
batch: HouseBatchIn,
request: Request,
estimator: Annotated[HouseEstimator, Depends(get_estimator)],
) -> EstimateBatchOut:
check_area(batch.houses, prefix="houses.")
return EstimateBatchOut(estimates=await estimate_houses(request, estimator, batch.houses))

The route reuses everything: the same validation per house, the same area check, the same estimate_houses. With the prefix, an error names the house, as in the course’s final assignment. houses.2.lat is the latitude of the third house: the list starts at 0.

Try a batch of three houses, then a batch of 100. The second command makes a file hundred.json with the typical house 100 times, indented as a person would write it:

Second terminal (on Windows: curl.exe, with the @... parts in quotes)
curl -s -X POST http://127.0.0.1:8000/v1/estimates/batch -H "Content-Type: application/json" -d @requests/three-houses.json
python -c "import json; h = json.load(open('requests/typical-house.json')); json.dump({'houses': [h] * 100}, open('hundred.json', 'w'), indent=2); print(len(open('hundred.json').read()), 'bytes')"
curl -s -X POST http://127.0.0.1:8000/v1/estimates/batch -H "Content-Type: application/json" -d @hundred.json
Output (the three estimates shortened)
{"estimates":[{"estimate_usd":422000,...},{"estimate_usd":2156000,...},{"estimate_usd":374000,...}]}
39220 bytes
{"error":{"code":"body_too_large","message":"The body is larger than 16384 bytes.","request_id":"11973df02340","fields":[]}}

What this tells us. Three houses work, in order. But 100 houses, which the contract allows, are 39 KB, and the course’s 16 KB limit refuses them. Two promises of the API contradict each other. The course’s final assignment asks the same question about its batch of tickets: change a limit, or document it.

The limit exists to refuse bodies that no valid request needs. So the right limit follows from the largest valid request. 100 houses are 25 KB of compact JSON (no spaces), 29 KB with spaces, and 39 KB indented. The next power of two above that is 64 KB. That still refuses anything much larger, so the protection stays. Change it in main.py, with the reason in a comment:

house_price_api/main.py (change)
# A batch of 100 houses is 25 to 39 KB of JSON, with or without spaces and
# indentation. 64 KB leaves room for the largest valid request, and no more.
MAX_BODY_BYTES = 65_536

Send hundred.json again. The answer now has 100 estimates, the first one 422,000. Delete hundred.json when you are done.

A small server has a small part of one processor: the free FastAPI Cloud instance has 0.1 CPU. scikit-learn’s gradient boosting uses OpenMP, a library that splits work across threads. By default it starts one thread per processor core, which helps for training. For one house, the work is too small to split, and starting and waiting for the threads costs more than the prediction.

We measured it with the finished API on the test computer, an Apple M4 with 10 cores. The check script from Part 9 sent 50 single estimates each time:

Threads Median time of one estimate Batch of 100
10 (the default on this computer) 5 to 12 ms, from run to run 6 ms
1 2 ms 3 ms

So the API uses one thread. The setting is the environment variable OMP_NUM_THREADS, and it must be set before scikit-learn starts its threads. The earliest place in the project is the package’s __init__.py, which Python runs before any module of the package:

house_price_api/__init__.py
"""First price estimates for houses in King County, Washington."""
import os
# The model predicts with one thread. This must happen before scikit-learn
# starts: on a small server, more threads make each prediction slower.
os.environ.setdefault("OMP_NUM_THREADS", "1")

setdefault keeps a value that the environment already has, so you can still try another value. On a server with 0.1 CPU, many threads would only take turns on the same small share.

pytest tests prove the rules, fast, on every change. For a model API, four kinds of tests matter:

  1. Golden cases. A few real inputs with their exact expected answers. If a package update or a retrain changes an answer, a golden test fails, and a person decides if the change is right. tests/golden_houses.json has three: the houses of Part 4.
  2. Boundaries. The values just inside and just outside each limit. bedrooms 11 passes, 12 fails.
  3. Wrong types and shapes. Text for a number, 0 for false, a missing field, an unknown field.
  4. Failed dependencies. No model, the wrong model: Part 2 did these.

Make tests/test_estimates.py. The beginning has the golden and boundary tests:

tests/test_estimates.py (beginning)
import json
from pathlib import Path
import pytest
GOLDEN = json.loads((Path(__file__).parent / "golden_houses.json").read_text("utf-8"))
@pytest.mark.parametrize("golden", GOLDEN, ids=[g["name"] for g in GOLDEN])
def test_golden_houses(client, golden):
response = client.post("/v1/estimates", json=golden["house"])
assert response.status_code == 200
body = response.json()
expected = golden["expected"]
assert body["estimate_usd"] == expected["estimate_usd"]
assert body["range_usd"] == expected["range_usd"]
assert [w["code"] for w in body["warnings"]] == expected["warnings"]
assert body["model_version"] == "kc-hgb-2015-02"
assert body["prices_as_of"] == "2015-02-28"
@pytest.mark.parametrize(
("field", "value"),
[
("bedrooms", 0),
("bedrooms", 11),
("sqft_living", 14_000),
("yr_built", 1900),
("yr_built", 2015),
("floors", 3.5),
("bathrooms", 0.25),
],
)
def test_limits_are_accepted(client, house, field, value):
house[field] = value
assert client.post("/v1/estimates", json=house).status_code == 200
@pytest.mark.parametrize(
("field", "value"),
[
("bedrooms", -1),
("bedrooms", 12),
("sqft_living", 14_001),
("yr_built", 1899),
("yr_built", 2016),
("floors", 1.25),
("bathrooms", 0.3),
("bedrooms", "three"),
("bedrooms", "3"),
("bedrooms", 2.5),
("waterfront", "no"),
("waterfront", 0),
("yr_renovated", 1950),
("sqft_basement", 1560),
],
)
def test_invalid_value_names_the_field(client, house, field, value):
house[field] = value
response = client.post("/v1/estimates", json=house)
assert response.status_code == 422
assert response.json()["error"]["code"] == "invalid_request"
assert response.json()["error"]["fields"] == [field]

("yr_renovated", 1950) is refused because the typical house was built in 1974. ("sqft_basement", 1560) is refused because the living area is 1,560 sq ft: the basement cannot be all of it.

The rest of the file tests the area, the batch, the protections and the model card. Copy it from below, or from tests/test_estimates.py in the finished project.

Show the rest of tests/test_estimates.py
tests/test_estimates.py (continued)
def test_rare_value_names_the_fields(client):
large_lot = GOLDEN[2]["house"]
[warning] = client.post("/v1/estimates", json=large_lot).json()["warnings"]
assert warning["fields"] == ["sqft_lot", "sqft_lot15"]
def test_missing_and_unknown_fields(client, house):
del house["grade"]
house["zipcode"] = 98006
response = client.post("/v1/estimates", json=house)
assert response.status_code == 422
assert sorted(response.json()["error"]["fields"]) == ["grade", "zipcode"]
@pytest.mark.parametrize(
("changes", "fields"),
[
({"lat": 40.71, "long": -74.0}, ["lat", "long"]),
({"lat": 47.9}, ["lat"]),
({"long": -121.2}, ["long"]),
],
)
def test_outside_model_area(client, house, changes, fields):
response = client.post("/v1/estimates", json={**house, **changes})
assert response.status_code == 422
assert response.json()["error"]["code"] == "outside_model_area"
assert response.json()["error"]["fields"] == fields
def test_batch_keeps_the_order(client):
houses = [g["house"] for g in reversed(GOLDEN)]
response = client.post("/v1/estimates/batch", json={"houses": houses})
assert response.status_code == 200
estimates = [e["estimate_usd"] for e in response.json()["estimates"]]
assert estimates == [g["expected"]["estimate_usd"] for g in reversed(GOLDEN)]
@pytest.mark.parametrize("count", [0, 101])
def test_batch_size_limits(client, house, count):
response = client.post("/v1/estimates/batch", json={"houses": [house] * count})
assert response.status_code == 422
assert response.json()["error"]["fields"] == ["houses"]
def test_batch_of_100_is_accepted(client, house):
response = client.post("/v1/estimates/batch", json={"houses": [house] * 100})
assert response.status_code == 200
assert len(response.json()["estimates"]) == 100
def test_batch_names_the_house_and_the_field(client, house):
wrong = {**house, "grade": 14}
away = {**house, "lat": 40.71}
response = client.post("/v1/estimates/batch", json={"houses": [house, wrong]})
assert response.json()["error"]["fields"] == ["houses.1.grade"]
response = client.post("/v1/estimates/batch", json={"houses": [house, house, away]})
assert response.json()["error"]["code"] == "outside_model_area"
assert response.json()["error"]["fields"] == ["houses.2.lat"]
def test_body_too_large(client, house):
response = client.post(
"/v1/estimates/batch",
json={"houses": [house], "note": "x" * 70_000},
)
assert response.status_code == 413
assert response.json()["error"]["code"] == "body_too_large"
def test_api_key_is_required_when_set(make_client, house):
client = make_client(api_key="test-key")
assert client.post("/v1/estimates", json=house).status_code == 401
assert client.post("/v1/estimates/batch", json={"houses": [house]}).status_code == 401
response = client.post("/v1/estimates", json=house, headers={"X-API-Key": "test-key"})
assert response.status_code == 200
assert client.get("/v1/model").status_code == 200, "the card needs no key"
def test_no_model_gives_503_everywhere_but_health(make_client, tmp_path, house):
client = make_client(model_path=tmp_path / "house_price.joblib")
for response in (
client.post("/v1/estimates", json=house),
client.post("/v1/estimates/batch", json={"houses": [house]}),
client.get("/v1/model"),
):
assert response.status_code == 503
assert response.json()["error"]["code"] == "model_unavailable"
def test_model_card(client):
card = client.get("/v1/model").json()
assert card["model_version"] == "kc-hgb-2015-02"
assert card["test"]["mape_pct"] == 12.8
assert "lending, underwriting or insurance decisions about a person" in card["not_for"]
def test_an_error_has_the_request_id(client, house):
error = client.post("/v1/estimates", json={**house, "lat": 40.71})
assert error.json()["error"]["request_id"] == error.headers["X-Request-ID"]
Terminal
python -m pytest
Output (last lines)
tests/test_estimates.py ....................................... [ 82%]
tests/test_health.py .. [ 87%]
tests/test_model_file.py ...... [100%]
============================== 47 passed in 0.85s ==============================

What this tells us. 47 tests run in under a second. They cover every rule of the contract, every error code, and every way the model file can fail. The golden tests pin the exact answers of this model. In Part 7, they are what tells you that a retrained model answers differently.

Part 7: document the contract, and version the model

Section titled “Part 7: document the contract, and version the model”

This API will change in two different ways, and a client must be able to tell them apart.

  • A new model. Every month, the analyst retrains with the latest sales. The numbers change, but the shape of every request and answer stays the same. Elif’s intake tool must keep working without any change.
  • A new contract. Someone renames estimate_usd to price_usd. Now every client breaks, even if the model is the same.

The first is normal, and model_version makes it visible. The second must never happen by accident.

As in Keep the documentation true, the starter has house_price_api/export_contract.py. Make tests/test_contract.py:

tests/test_contract.py
import json
from pathlib import Path
CONTRACT = Path(__file__).parent.parent / "contract" / "openapi.json"
def test_openapi_matches_the_reviewed_contract(client):
current = json.dumps(client.get("/openapi.json").json(), indent=2) + "\n"
reviewed = CONTRACT.read_text(encoding="utf-8")
assert current == reviewed, (
"The API contract changed. If the change is intended, run "
"python -m house_price_api.export_contract and review the difference."
)
def test_documented_example_gives_the_documented_result(client):
schemas = client.get("/openapi.json").json()["components"]["schemas"]
request_example = schemas["HouseIn"]["examples"][0]
response_example = schemas["EstimateOut"]["examples"][0]
response = client.post("/v1/estimates", json=request_example)
assert response.status_code == 200
assert response.json() == response_example

Export the contract, read it, and run the tests:

Terminal
python -m house_price_api.export_contract
python -m pytest
Output (last lines of each command)
Wrote contract/openapi.json
...
============================== 49 passed in 0.92s ==============================

Before the Wrote line, the export prints the start-up log twice: the module makes the app once on import, and export_contract makes it once more. Open contract/openapi.json (about 22 KB) and read it as a stranger would. The limits of every field are there, such as "maximum": 11 for bedrooms, and so are the two 422 codes and the “Not for lending” sentence. The second test proves that the documented example, the typical house, really gives the documented answer.

Try the “small clean-up”: in models.py and routes.py, rename estimate_usd to price_usd everywhere. In VS Code, use Edit › Replace in Files, limited to those two files. The API still works. Run the tests:

Output (shortened)
E AssertionError: The API contract changed. If the change is intended, run python -m house_price_api.export_contract and review the difference.
E Skipping 9435 identical leading characters in diff, use -v to show
E - "estimate_usd": {
E ? ^^^ ^^^
E + "price_usd": {
...
FAILED tests/test_contract.py::test_openapi_matches_the_reviewed_contract - A...
FAILED tests/test_estimates.py::test_golden_houses[typical] - KeyError: 'esti...
...
========================= 5 failed, 44 passed in 0.98s =========================

What this tells us. The rename is a breaking change, and two kinds of test caught it. The contract test shows the exact line. The golden tests fail because a client that looks for estimate_usd finds nothing. Rename it back in both files, and run the tests again: 49 passed.

If a rename is really needed, it goes into a new version of the path: /v2/estimates with the new name. /v1/estimates stays next to it, and keeps working until every client has moved. That is Rule 3: a version in the path.

Now the other kind of change. training/train.py is the analyst’s script that made the model. It downloads the data (0.7 MB) into data/ and checks its SHA-256. Then it trains, tests once, and writes the model file and its card. Run it:

Terminal
python training/train.py
Output
Downloading https://github.com/Nextia-Intelligence/nextia-learning-labs/raw/main/C05/case-studies/data/king_county_house_sales.csv.gz
train 16959 sales | test 4654 sales
trained in 3.5 s
test: {'median_error_pct': 6.4, 'mape_pct': 12.8, 'within_10pct': 0.502, 'over_by_10pct_or_more': 0.126}
range: 0.884 to 1.279 times the estimate (80% of test sales inside)
wrote model/house_price.joblib (0.51 MB) and model/model_card.json
sha256 8d0483d05cfca4752118c5c6ef4c47a9531c7230981cc880c27d85def9dbfdec

What this tells us. The new file has exactly the same hash as the reviewed one. With the same data, the same settings (random_state=0) and the package versions of requirements-lock.txt, training is reproducible, byte for byte. So python -m pytest still passes, and the API loads the file. This makes the model trustworthy: anyone can rebuild it from the public data and check that it is the same file.

The Python version does not change the file either, but only because the script asks for it. joblib saves the model with Python’s pickle format. Its protocol (the version of the format) is 4 by default on Python 3.12, and 5 on Python 3.14. A different protocol gives different bytes, so a different hash, and the API would refuse the file. So train.py fixes the protocol, and the card records it:

training/train.py (part)
joblib.dump(model, MODEL_FILE, compress=3, protocol=PICKLE_PROTOCOL) # PICKLE_PROTOCOL = 5

We checked it: on Python 3.12.13 and on 3.14.6, the script writes the same model file and the same model_card.json.

A real retrain, with next month’s sales, would give a new file and a new hash. The API would then refuse to load it, with the log line from Part 2, until someone makes a reviewed change with three parts:

  1. A new MODEL_VERSION in training/train.py, for example kc-hgb-2015-03, and the new model file and card.
  2. The new MODEL_SHA256 in config.py.
  3. The new expected answers: in golden_houses.json, and in the example of EstimateOut, so the contract file changes too.

The third part looks like a contract change, but read the difference: only example values change, not a field, a type or a limit. No client breaks. The golden tests make the new answers visible, so a reviewer sees how much each golden house moved before the new model goes live.

Common mistake

After an update, /ready gives 503 and the log talks about scikit-learn

Symptom: you upgraded the packages, for example with pip install --upgrade scikit-learn. Now the API starts and /health gives 200, but /ready gives 503. The log says model not loaded: the model was saved with scikit-learn 1.9.1, this server has a newer version.

Cause: a scikit-learn model is safe to load only with the version that saved it. A new version may change the inside of the model objects. The API refused it, as designed.

Fix:

  1. To keep this model, go back to the pinned versions: python -m pip install -r requirements-lock.txt.
  2. To use the new scikit-learn, train the model again with it: python training/train.py. Then make the reviewed change of Step 3: new version, new hash, new golden answers. Then change the version pins in pyproject.toml, requirements.txt and the lock file together.
  3. Never silence the warning. A model that loads with a warning can give wrong answers without any error.

pytest tests the code in memory. The Postman collection tests a running API over real HTTP. So the same file checks your computer and, later, the deployed service, as in Verify and clean up. The starter’s collection, postman/house-price-api.postman_collection.json, has two requests in the folder health. It also has the course’s collection-level tests: every response is JSON and has an X-Request-ID header, and every error has the standard shape.

Import it in Postman (Import, then the file), with postman/local.postman_environment.json. Add a folder estimates with these seven requests. Each request body is in requests/:

Request Method and path Body Tests
Describe the model GET {{baseUrl}}/v1/model none 200; model_version is kc-hgb-2015-02; not_for mentions lending
Estimate a typical house POST {{baseUrl}}/v1/estimates typical-house.json 200; 422,000; range 373,000 to 540,000; no warnings; under 500 ms
Estimate a waterfront house POST .../v1/estimates waterfront-house.json 200; the warnings include high_estimate
Estimate three houses at once POST .../v1/estimates/batch three-houses.json 200; estimates 422,000, 2,156,000 and 374,000, in that order
Reject a house outside King County POST .../v1/estimates outside-king-county.json 422; outside_model_area; fields lat and long
Reject bedrooms written as text POST .../v1/estimates bedrooms-as-text.json 422; invalid_request; fields bedrooms
Reject a missing key POST .../v1/estimates typical-house.json Auth: No Auth. 401; unauthorized

The collection’s authorization sends X-API-Key: {{apiKey}} with every request, except the one set to No Auth. Export the collection back into postman/, as in Tests and repeatable runs. The finished project has the complete file, if you want to compare your tests.

Run it from the terminal, against the API in production mode with a key, as the server will run it:

Terminal window
API_KEY=local-dev-key SHOW_DOCS=false fastapi run
Second terminal
postman collection run postman/house-price-api.postman_collection.json -e postman/local.postman_environment.json --env-var "apiKey=local-dev-key"

The Postman CLI may first ask you to sign in. The output can start with the line No authorization data found, followed by Running your collection.... Then you can ignore that line: the CLI runs a local file without an account, as in Tests and repeatable runs. If the CLI stops instead, sign in once, then run the same command again:

Second terminal
postman login

postman login signs you in to your Postman account. It opens your browser or asks for a Postman API key. Follow its instructions.

Output (start and end)
Running your collection...
House Price Estimate API
Folder health
Sub-folder Check that the service runs
GET http://127.0.0.1:8000/health [200 OK, 168 B, 36 ms]
Pass response is JSON
Pass has a request ID
Pass status is 200
...
| requests | 9 | 0 |
| assertions | 44 | 0 |

What this tells us. Nine requests and 44 checks pass over real HTTP, and the key is checked for real. The same file, with the Deployed environment, is the smoke check after the deployment. Keep the API running for the next part.

The deployment follows Publish safely and Verify and clean up. A model API adds three questions. Does the model file reach the server? Are the package versions the same there? Is the server big enough?

Before the deployment: what goes to the server

Section titled “Before the deployment: what goes to the server”

fastapi deploy sends the project folder. It always leaves out .venv, __pycache__, .git and every .env file. It reads .gitignore only inside a Git repository. We checked this with fastapi-cloud-cli 0.26.0: in a folder without Git, it also selected data/, although .gitignore lists it. It also reads .fastapicloudignore, which has the same syntax, with or without Git.

The server needs the code, the model and pyproject.toml. It does not need the tests, the training script, the downloaded data or the Postman files. Make .fastapicloudignore in the project folder:

.fastapicloudignore
# Files that the server does not need. fastapi deploy leaves them out,
# with or without Git.
data/
training/
tests/
postman/
requests/
scripts/
contract/

With this file, the CLI’s own file filter selects 17 files, 0.55 MB. They are house_price_api/, model/house_price.joblib, model/model_card.json, pyproject.toml, the README, the model card, the licence and the two requirements files. Without the file, it selects 36 files, 1.34 MB. Check three more things:

  • The model must not be ignored. If .gitignore, or a global Git setting on your computer, lists *.joblib or model/, the deployment has no model. The deployed /ready then says model_unavailable, and the log says no model file at .... In a Git repository, run git check-ignore -v model/house_price.joblib: it must print nothing.

  • One package, named. The server builds your project as a Python package. This project has two top-level folders, house_price_api/ (the code) and model/ (data files), and the build tool cannot guess which one is the package. Our first live deployment failed here, with Python package discovery failed: The package builder found multiple top-level packages. The fix is three lines in pyproject.toml. The starter already has them, so you will not see this error yourself. To see it, remove the three lines for a moment and run pip wheel . --no-deps; then put them back. The lines are:

    pyproject.toml
    [tool.setuptools]
    # The project has two top-level folders, house_price_api/ (the code) and
    # model/ (data files). Name the package, so the build does not guess.
    packages = ["house_price_api"]
  • The versions on the server. The server installs the packages from pyproject.toml. The starter pins all five there: fastapi[standard]==0.142.2, scikit-learn==1.9.1, numpy==2.5.3, scipy==1.18.1 and joblib==1.6.0. Without the pins, the server would install the newest scikit-learn, and the version check from Part 2 would make it not ready. That is the check working, but it is better to avoid it.

The project has a script, scripts/check_deployment.py, for the check after the deployment. Run it now against your local API, which still runs from Part 8, to see what it does. It reads the key from an environment variable, never from the command line:

Terminal window
export HOUSE_API_KEY=local-dev-key
python scripts/check_deployment.py http://127.0.0.1:8000
Output
1. The first request (includes the start of a sleeping app)
PASS GET /health is 200 (2 ms)
2. Readiness and the model
PASS GET /ready is 200 ({"status": "ready"})
PASS GET /v1/model is 200
PASS the server runs the reviewed model file (kc-hgb-2015-02)
3. Golden houses
PASS typical: 422,000 USD (got 422000, 2 ms)
PASS waterfront: 2,156,000 USD (got 2156000, 2 ms)
PASS large_lot: 374,000 USD (got 374000, 2 ms)
4. Wrong requests
PASS no key gives 401 (unauthorized)
PASS New York gives 422 outside_model_area
PASS bedrooms as text gives 422 invalid_request
PASS 101 houses give 422
INFO GET /docs is 404 (404 when SHOW_DOCS=false)
5. Speed (one connection, kept open)
50 single estimates: median 2 ms, 90th percentile 2 ms, slowest 2 ms
PASS a batch of 100 gives 100 estimates
3 batches of 100 houses: median 3 ms
All checks passed.

What this tells us. The script checks what the Postman collection checks, and two more things. It compares the hash that the server reports in /v1/model with the hash in your own model/model_card.json. So you know that the server runs the reviewed model, not only a model. And it measures speed. On your computer, an estimate takes about 2 ms. On a server with 0.1 CPU, with the internet in between, expect much more. Stop the API.

These are the course’s steps, with this project’s names. The app and its key are created before the first deployment, so the first running version is already protected.

  1. Sign in: fastapi login.

  2. Create the app, and link the folder to it: fastapi cloud apps create --name house-price-api --link. If the name is taken, add your name, for example house-price-api-yourname. The command asks you to choose a team, even if you have only one. To answer without the question, find the team’s ID with fastapi cloud teams list and add --team-id <ID>.

  3. Make a new key, and store it as a secret, without typing it:

    Terminal window
    KEY=$(python -c "import secrets; print(secrets.token_urlsafe(32))")
    printf %s "$KEY" | fastapi cloud env set API_KEY --secret --value-stdin --no-redeploy
    fastapi cloud env set SHOW_DOCS false --no-redeploy
    echo "$KEY"

    Copy the key that the last command shows, then close this terminal.

  4. Open a new terminal, activate the environment, and deploy: fastapi deploy. Write down the address that it prints, which ends in .fastapicloud.dev.

  5. Read the start-up log: fastapi cloud logs --no-follow --since 10m. Find the line model kc-hgb-2015-02 loaded in ... ms, memory ... MB. If it says model not loaded, read the reason, and see the failure case below.

Give the key to the terminal without typing it into a command, as in Verify and clean up. Then run the check script and the Postman collection against the address. Replace the address with yours:

Terminal window
read -s HOUSE_API_KEY && export HOUSE_API_KEY
python scripts/check_deployment.py https://house-price-api.fastapicloud.dev
postman collection run postman/house-price-api.postman_collection.json -e postman/deployed.postman_environment.json --env-var "baseUrl=https://house-price-api.fastapicloud.dev" --env-var "apiKey=$HOUSE_API_KEY"

The script runs first on purpose. If the app was stopped because nobody used it (scale to zero), the script’s first request wakes it up. The time of that request shows how long the start takes: the cold start. Then the collection’s “under 500 ms” test measures a warm app.

Then show a controlled failure on the server, as the course did with CLASSIFIER_MODE. Point the app to a model file that does not exist. Setting a variable redeploys the app:

Terminal
fastapi cloud env set MODEL_PATH /tmp/no-model.joblib

When the new deployment is ready, /health gives 200 and /ready gives 503 model_unavailable. The log says no model file at /tmp/no-model.joblib. Remove the setting, which redeploys the normal model, and run the check script again:

Terminal
fastapi cloud env delete MODEL_PATH --yes

Without --yes, the command asks you to confirm, and in a script it stops with Aborted. without deleting anything. Our live run found this: the app kept pointing to the missing model until the command ran again with --yes.

  1. Open FastAPI Cloud’s dashboard, select the app, and delete it in the app’s settings. The CLI has no command to delete an app.
  2. In the project folder, remove the link: fastapi cloud unlink.
  3. Remove the key from your terminal (unset HOUSE_API_KEY, or Remove-Item Env:HOUSE_API_KEY on Windows) and from the Postman Deployed environment.
  4. Check that the address no longer answers 200: curl -i https://house-price-api.fastapicloud.dev/health.

Common mistake

Deployed, but /ready gives 503: no model file

Symptom: the deployment succeeds and /health gives 200, but /ready gives 503 model_unavailable. The log says model not loaded: no model file at a path that ends in model/house_price.joblib.

Cause: the model file did not go to the server, because a rule ignored it. The rule can be in .gitignore (in a Git repository), in a global Git setting, or in .fastapicloudignore. A template .gitignore with *.joblib or *.pkl is the usual reason.

Fix:

  1. Run git check-ignore -v model/house_price.joblib. It prints the file and the line that ignores the model. Remove that line, or add !model/house_price.joblib to .fastapicloudignore.
  2. Deploy again with fastapi deploy, and check /ready.
  3. Do not “fix” it by removing the hash check. The check found a real problem.

We deployed the finished project once to FastAPI Cloud, with the steps of Part 9, on the free Hobby plan (0.1 CPU, 500 MiB per copy of the app). Then we deleted it. These are the real results, measured from the test computer over the internet.

Measure Result
First fastapi deploy Failed: Python package discovery failed. The project had two top-level folders. Fixed with [tool.setuptools] packages = ["house_price_api"], as in Part 9.
Second fastapi deploy Uploaded 515 KB, built, and printed the address in about one minute.
Start-up log model kc-hgb-2015-02 loaded in 198 ms, memory 179 MB: about a third of the 500 MiB limit.
Check script All checks passed. The server runs the reviewed model file.
One estimate, warm (50 requests) Median 44 ms, 90th percentile 67 ms, slowest 127 ms. The server’s own log shows 3 ms for each; the rest is the network.
A batch of 100 houses, warm Median 62 ms.
Postman collection, Deployed environment 9 requests, 44 assertions, 0 failed.
Controlled failure with MODEL_PATH /health 200; /ready and POST /v1/estimates 503 model_unavailable. After env delete MODEL_PATH --yes, /ready was 200 again within 30 seconds.
Cold start Not measured. After 20 minutes without requests the app was still running: the first request took 0.22 s, and the log showed no new start. The platform does not document when it stops an idle app. A restart after a settings change was ready within 30 seconds, and the model loaded in 193 ms.
Clean-up App deleted in the dashboard. The team then listed no apps, and the address answered 404.

What this tells us. The model fits the smallest instance with room to spare, and the server answers in a few milliseconds. Most of the time that a user waits is the network, so a batch of 100 houses costs about as much as one house. The live run also found two problems that no local test could find: the package that the build could not guess, and a command that silently asked for confirmation. Both are now in Part 9. This is why the course deploys once for real, and does not stop at local tests.

A price API looks neutral: numbers in, a number out. It is not, because of where its answers go. Daniel’s conditions are part of the product, not paperwork.

Not for decisions about people. The estimate is a first number for a valuation team. It must not decide whether a person gets a loan, at what rate, or an insurance premium. Lending and insurance decisions in the United States fall under laws such as the Fair Housing Act and the Equal Credit Opportunity Act. A model used for them needs a validation that this one did not have. The API says so in four places: the card’s not_for list, the endpoint’s description in the contract, the README and MODEL_CARD.md.

Location can stand in for people. The model uses latitude and longitude. In the United States, where a house is correlates with the income and race of the people who live there. This comes from a long history of segregation and lending discrimination. A model can learn those patterns from prices without any column about people. The analyst found similar errors across three large areas of the county. That is a first check, not a fairness review. A real deployment needs that review, with better data, by people qualified to do it.

The prices are old. The model knows 2014–2015 prices, and every answer says prices_as_of: 2015-02-28. For today’s prices, it would be wrong by much more than its error.

What the API does about it:

  • It refuses what the model does not know (invalid_request, outside_model_area), instead of guessing.
  • It gives a range and warnings, so nobody reads one number as a fact.
  • It names the model in every answer, so every estimate can be traced and reviewed.
  • It logs no house descriptions. The log has the method, the path, the status, the time and the request ID. No input identifies a person or an address.
  • It loads only the reviewed model file.

What it does not do, and a production service would need: a rate limit per client, and monitoring of the estimates against later appraisals. The model card has the rule for that: stop if fewer than 45% of last month’s estimates were within 10%. A production service also needs a record of who asked for which estimate, under the lender’s own data rules.

This project shows an employer that you can put a model into production responsibly. It is not only an endpoint: it has the checks, the versioning, the tests and the limits of use. Publish it as a repository, under your name.

What to put in the repository:

house-price-api/
README.md what it is, how to run and check it, the data and its licence
MODEL_CARD.md training window, test results, intended use, what it is not for
LICENSE MIT, for the code
pyproject.toml the pinned versions (requirements-lock.txt for a local set-up)
house_price_api/ the API: estimator.py, models.py, routes.py, errors.py, main.py
model/ house_price.joblib and model_card.json, both kept in Git
training/train.py rebuilds the model, byte for byte, from the public data
tests/ the 49 tests, with golden_houses.json
contract/openapi.json the reviewed contract
postman/ the collection, and its two environments without keys
scripts/ check_deployment.py

To publish it:

  1. Start from your project folder, or from the finished project. Make sure that .gitignore lists .venv/ and data/.
  2. Make the repository: git init, then git add . and git commit -m "House price estimate API".
  3. Check with git ls-files that model/house_price.joblib and model/model_card.json are in it, and that no .env file is.
  4. Push it to GitHub as a public repository. Keep the LICENSE file (MIT): it lets others reuse your code. The data keeps its own licence (CC0), which the README states.
  5. Rewrite the README’s first paragraph in your words. Add the results of your own checks: the test count, the Postman run, and, if you deployed, the measured cold start and speed.

The README is the page that most people read. A README template:

# House price estimate API
A FastAPI service that gives a first estimate of house prices in King County, WA,
with an 80% range, the model version and warnings. Not for lending decisions.
- Model: gradient boosting on 2014-15 public sales (CC0); test MAPE 12.8%, 50% within 10%.
- Safety: loads the model only after a SHA-256 check; refuses inputs outside the training
range and houses outside the county; 503 (not a crash) when the model is unavailable.
- Versioning: model_version in every answer; a reviewed OpenAPI contract with a test;
training is reproducible byte for byte.
- Tests: 49 pytest tests (golden houses, limits, failures), a Postman collection,
a deployment check script.

How to describe it in an interview, in two sentences: “I built a FastAPI service for a house-price model, with input limits from the training data, a hash check on the model file, and a price range from the model’s test errors. It has 49 tests, with golden cases that catch any change in the model’s answers, and I deployed it with a secret key and a smoke test.”

Go further:

  1. A monitoring endpoint. Store each estimate with its request ID and model version, and add the appraisal value when it arrives. Report the share within 10% each month.
  2. Two models side by side. Load a new model next to the old one. Answer with the old one, and log both estimates for a month. Compare them before you switch. This is a shadow deployment.
  3. A second city. Train the same model on another open sales dataset, and serve it under its own model_version and area check. Compare what transfers.
  4. A credit-default API with reasons. A lender may also want a default risk score with the main reasons for each applicant. For example: a logistic regression, and an endpoint that lists the inputs that moved the score most. This is a much harder case to do responsibly. It decides about people, and it falls under the credit laws above. Lenders in the United States must also give applicants the main reasons for a refusal. Build it only as a study project, with public research data, and never present it as ready for real decisions.

Check your understanding

The model file's hash is in config.py, in the code. Why not put it in an environment variable on the server, which is easier to change?

Show the answer

The check protects against a file that nobody reviewed, including a file changed by someone with access to the server. Anyone who can change the server’s files can usually change its environment variables too. That person could then set the hash of their own file. In the code, the expected hash changes only through a reviewed change, together with the model version and the golden answers. So a new model always passes a person’s review. The tests can still give another hash, through the test settings, because they never run on the server.

Check your understanding

Elif asks: the range is 373,000 to 540,000 dollars, and the estimate is 422,000. Why is the range not centred on the estimate, and how sure can she be that a house sells inside it?

Show the answer

The range comes from the model’s errors on the test months. 80% of test houses sold between 0.884 and 1.279 times their estimate. It reaches further above the estimate because prices rose in spring 2015, after the last sale that the model saw. So real prices were more often above the estimate than below. For houses like the test sales, about 8 in 10 sell inside the range. That is a measured rate, not a promise for one house. For today’s market, which has moved far from 2015, the range says nothing.

Check your understanding

The analyst retrains the model with March sales. Which files change, which tests fail before you update them, and does any client need to change its code?

Show the answer

The model file and model_card.json change, with a new model_version and hash. MODEL_SHA256 in config.py must change, or the API refuses the file. The hash test, the golden tests and the contract test fail until you update the hash, golden_houses.json and the EstimateOut example. The contract test fails because the documented example’s numbers change. No client changes its code: every field, type and limit is the same. Clients see a new model_version in the answers, which is the point.

Recap

  • A model file is code: joblib uses pickle, which can run anything on load. Check the file’s SHA-256 against the reviewed hash in the code before you load it. Treat a scikit-learn version warning as “not ready”.
  • A failed model check makes the API not ready (503 model_unavailable), not dead: /health stays 200, and the log says why.
  • A model answers anything, so the API decides what it may be asked. It uses limits from the training data, strict types and no codes that look like numbers. Places that the model never saw get their own error, outside_model_area.
  • One exact-looking number misleads. The answer has a range from the model’s test errors, rounded values, prices_as_of, warnings, and the model_version that made it.
  • A retrain changes the model version and the example values, not the contract. A renamed field needs /v2. Golden tests and the contract test tell the two apart.
  • The batch made a limit and a promise collide; the body limit now follows from the largest valid request. One OpenMP thread made each estimate faster.
  • On the server, pin the package versions and make sure that the model file is uploaded. Set the key as a secret first, verify with the check script and the collection, and delete the app afterwards.

Next, the second case study: a read-only API over a real database, with filters, stable pagination and daily statistics.

Next: Build a data API for city service requests