Where a model's real life usually starts

A model that predicts well in a notebook, on a held-out test set, has proven exactly one thing: it works on data shaped like its training data. Nothing about that guarantees it behaves reasonably when a real caller, another service, a form on a website, a script someone else wrote, sends it something it's never seen before, a negative value, a missing field, a string where a number belongs. A real API needs an answer for that before the model itself ever runs.

Wrapping a real, trained model

from fastapi import FastAPI
from pydantic import BaseModel, Field
import pickle

app = FastAPI()
with open("model.pkl", "rb") as f:
    model = pickle.load(f)

class PredictRequest(BaseModel):
    square_feet: float = Field(gt=0, description="must be positive")
    age_years: float = Field(ge=0, description="must be non-negative")

class PredictResponse(BaseModel):
    predicted_price: float

@app.post("/predict", response_model=PredictResponse)
def predict(req: PredictRequest):
    price = model.predict([[req.square_feet, req.age_years]])[0]
    return PredictResponse(predicted_price=round(float(price), 2))

gt=0 and ge=0 aren't comments, they're real, enforced rules. A request that violates either one never reaches the predict function body at all.

A real, valid request

requests.post(url, json={"square_feet": 1800, "age_years": 10})
200 {'predicted_price': 177973.42}

The model ran, produced a real prediction, everything behaves as expected.

A real, invalid request

requests.post(url, json={"square_feet": -500, "age_years": 10})
422
{'detail': [{'type': 'greater_than', 'loc': ['body', 'square_feet'],
             'msg': 'Input should be greater than 0', 'input': -500}]}

A negative square footage never reaches the model. FastAPI rejected it before predict() ran a single line, with a real, specific error naming exactly which field failed and why. No if square_feet <= 0: raise ... was written anywhere in the route itself, the validation rule lives entirely in the Field(gt=0) declaration on the request model.

A different kind of invalid request

requests.post(url, json={"square_feet": 1800})
422
{'detail': [{'type': 'missing', 'loc': ['body', 'age_years'],
             'msg': 'Field required'}]}

A missing field, caught the same way, before the model function runs. age_years having no default in PredictRequest is what makes it required; nothing about "what if this field is missing" needed to be handled by hand inside the route.

What a notebook never has to think about

Inside a notebook, the shape of the input is whatever the last cell happened to produce, already correct by construction, because the same person who trained the model also wrote the input. A real API has no such guarantee. Every caller is a real, separate piece of code that can send anything the HTTP protocol allows, and the model itself has no idea whether square_feet: -500 is a real data entry error, a unit mismatch, or a deliberate attempt to see what happens. The validation layer's entire job is deciding what counts as a request the model should even be allowed to see, and Pydantic's field constraints are where that decision actually lives, declared once, enforced on every request, instead of scattered across manual checks that are easy to forget in one code path and not another.

The takeaway

A model returning correct predictions on clean, well-formed input is necessary and not close to sufficient for something that's actually going to be called by code outside your control. The real, checkable difference above, two malformed requests, two specific 422 responses, zero lines of manual validation logic, is what separates a model that works in a notebook from one that's actually safe to expose behind a real endpoint.