Back to all articles
Cover for Serving Machine Learning Models with FastAPI: Build a Prediction API — Part 8

Serving Machine Learning Models with FastAPI: Build a Prediction API — Part 8

Sep 9, 2026
·22 min read·
Tutorial
FastAPI
Python
Pydantic
Pydantic
Streamlit
Streamlit

So far, we’ve built a complete FastAPI application and implemented the core CRUD operations.

But FastAPI becomes even more powerful when we use it to serve a machine learning model.

A trained model by itself is not enough to make predictions available to other applications. We need a way for clients to send input data, have the model process that data, and receive a prediction in response.

This is where FastAPI and machine learning come together.

In this part, we’ll build an Insurance Premium Prediction API.

We’ll take a trained Random Forest Classifier, save it as a .pkl file, load it into FastAPI, prepare the required features from user input, and expose the model through a POST /predict endpoint.

Finally, we’ll connect the API to a Streamlit frontend so users can enter their information and receive an insurance premium category prediction.

Overview

Building and Exporting the ML Model

Before connecting a machine learning model to FastAPI, we first need a trained model that can make predictions.

For this project, we are building an Insurance Premium Prediction model using a Random Forest Classifier. The training data comes from insurance.csv, and the target variable is insurance_premium_category.

The model uses the following features:

  • BMI
  • Age Group
  • Lifestyle Risk
  • City Tier
  • Income (LPA)
  • Occupation

These features are prepared from the original user data before training the model.

Creating the Model Pipeline

The project uses a ColumnTransformer to handle categorical and numerical features separately.

Categorical features are:

  • age_group
  • lifestyle_risk
  • occupation
  • city_tier

Numerical features are:

  • bmi
  • income_lpa

The categorical features are converted using OneHotEncoder, while the numerical features are passed through unchanged.

categorical_features = ["age_group", "lifestyle_risk", "occupation", "city_tier"]
numeric_features = ["bmi", "income_lpa"]
 
preprocessor = ColumnTransformer(
    transformers=[
        ("cat", OneHotEncoder(), categorical_features),
        ("num", "passthrough", numeric_features)
    ]
)

Next, we combine the preprocessing step with a Random Forest Classifier using a scikit-learn pipeline:

pipeline = Pipeline(steps=[
    ("preprocessor", preprocessor),
    ("classifier", RandomForestClassifier(random_state=42))
])

This allows preprocessing and prediction to be handled as a single pipeline.

Training the Model

We split the dataset into training and testing sets and then train the pipeline:

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=1
)
 
pipeline.fit(X_train, y_train)

The notebook uses 80% of the data for training and 20% for testing.

Evaluating the Model

After training, we make predictions on the test data and calculate the accuracy:

y_pred = pipeline.predict(X_test)
accuracy_score(y_test, y_pred)

Output

0.9

The trained model achieved an accuracy score of 0.90 on the test set in the notebook.

Exporting the Trained Model

Once the model is trained, we need to save it so that the FastAPI application can load it later without retraining the model.

The project uses Python’s pickle module to save the complete trained pipeline:

import pickle
 
pickle_model_path = "model.pkl"
 
with open(pickle_model_path, "wb") as f:
    pickle.dump(pipeline, f)

This creates the model.pkl file containing the trained pipeline.

Code Execution Explanation

The overall process is:

Dataset → Feature Engineering → Preprocessing → Random Forest → Training → Evaluation → model.pkl

The important part is that we save the entire pipeline, not just the Random Forest classifier. This allows the same preprocessing used during training to remain part of the saved model that FastAPI will later load for predictions.

Feature Engineering for Prediction

A trained machine learning model expects input in a specific format. The data submitted by a user through our API, however, is not exactly the same as the features used during model training.

For example, the API receives:

  • age
  • weight
  • height
  • income_lpa
  • smoker
  • city
  • occupation

But the trained model expects:

  • bmi
  • age_group
  • lifestyle_risk
  • city_tier
  • income_lpa
  • occupation

Therefore, before making a prediction, we need to transform the user’s raw input into the features expected by the model.

Calculating BMI

BMI is derived from the user’s weight and height:

@computed_field
@property
def bmi(self) -> float:
    return self.weight / (self.height**2)

The API does not ask the user to provide BMI directly. Instead, it calculates BMI from weight and height.

Creating Lifestyle Risk

The model also uses a lifestyle_risk feature.

It is derived from two pieces of information:

  1. Whether the user is a smoker
  2. The user’s BMI
@computed_field
@property
def lifestyle_risk(self) -> str:
    if self.smoker and self.bmi > 30:
        return "high"
    elif self.smoker or self.bmi > 27:
        return "medium"
    else:
        return "low"

This converts the raw smoker and calculated bmi values into one of three categories: high, medium, or low.

Creating Age Groups

Instead of passing the numerical age directly as an age category, the application derives an age_group:

@computed_field
@property
def age_group(self) -> str:
    if self.age < 25:
        return "young"
    elif self.age < 45:
        return "adult"
    elif self.age < 60:
        return "middle_aged"
    return "senior"

This transforms the user’s age into one of four categories:

  • young
  • adult
  • middle_aged
  • senior

Converting City into City Tier

The model does not use the city name directly. Instead, the application converts the city into a numerical city_tier.

The project maintains separate lists for Tier 1 and Tier 2 cities:

tier_1_cities = [
    "Mumbai", "Delhi", "Bangalore", "Chennai",
    "Kolkata", "Hyderabad", "Pune"
]

If the city belongs to Tier 1, it returns 1. If it belongs to Tier 2, it returns 2. Otherwise, it returns 3.

Creating the Model Input

Once these derived features are available, the API constructs a DataFrame containing exactly the features required by the trained model:

input_df = pd.DataFrame([{
    'bmi': data.bmi,
    'age_group': data.age_group,
    'lifestyle_risk': data.lifestyle_risk,
    'city_tier': data.city_tier,
    'income_lpa': data.income_lpa,
    'occupation': data.occupation
}])

This is the important transition:

Raw User Input → Derived Features → Model Input

The resulting DataFrame is then ready to be passed to the trained model.

Code Execution Explanation

The feature-engineering process takes information that is easy for a user to provide and converts it into the structured features expected by the machine learning model.

For example:

Age + Weight + Height

       BMI

Age → Age Group
Smoker + BMI → Lifestyle Risk
City → City Tier

  Model Features

This separation is important because the API input format and model feature format do not have to be identical. The API acts as the layer that transforms user-friendly input into model-ready data.

Understanding the Project Flow

Before looking at the FastAPI implementation, it is useful to understand how the different parts of the project work together.

The project is divided into three main steps:

  1. Model Building — Train the insurance premium prediction model and export it as model.pkl.
  2. FastAPI — Load the saved model, accept user input, generate the features required by the model, and return a prediction.
  3. Streamlit Frontend — Provide a user interface where users can enter their information and receive the predicted premium category.

Complete Project Flow

The overall flow can be understood as:

Model Building → model.pkl → FastAPI API → Streamlit Frontend

The model-building step produces the trained model.pkl file. The FastAPI application then loads this file and exposes a /predict endpoint for inference.

When a user interacts with the frontend, the flow becomes:

User Input → Streamlit → FastAPI /predict → Feature Engineering → ML Model → Prediction → Streamlit

The FastAPI application receives fields such as age, weight, height, income, smoking status, city, and occupation. It then derives the features required by the model, creates a Pandas DataFrame, and passes that data to model.predict().

What Happens Inside FastAPI?

The FastAPI layer acts as the bridge between the frontend and the machine learning model.

Its responsibility is to:

  • Load the trained model.pkl.
  • Validate incoming user data using a Pydantic model.
  • Calculate derived features such as BMI, lifestyle risk, age group, and city tier.
  • Prepare those features as a Pandas DataFrame.
  • Send the DataFrame to the trained model.
  • Return the predicted premium category as a JSON response.

This separation is important because the machine learning model does not directly interact with the user interface. FastAPI sits between them and handles the API and prediction logic.

The Three Layers

You can think of the project as three connected layers:

1. Machine Learning Layer

The trained model is exported as: model.pkl

2. API Layer

FastAPI loads the model and exposes: POST /predict

The endpoint receives validated input, prepares the model features, and generates the prediction.

3. Frontend Layer

Streamlit provides the interface through which users enter their information and consume the prediction returned by the API.

This gives us a simple architecture:

Frontend → API → ML Model

And for the response:

ML Model → API → Frontend

This section should stay at the architecture/flow level. We won’t repeat the details of model training or feature engineering here; those are covered in their respective sections.

Why This Flow Matters

A machine learning model by itself is usually not enough for a real application. The model needs a way to receive input from other applications and return predictions.

FastAPI provides that connection.

In this project: Streamlit handles the user interface, FastAPI handles communication and inference, and the trained model handles prediction.

Once this flow is clear, we can move into the FastAPI implementation and see exactly how the prediction endpoint connects these components.

Loading the ML Model in FastAPI

Once the machine learning model has been trained and exported, the next step is to make it available to the FastAPI application.

In our project, the trained model is stored as a model.pkl file. FastAPI loads this file when the application starts, so the model is ready to handle prediction requests.

Loading the Saved Model

The project uses Python’s pickle module to load the previously exported model:

import pickle
 
with open('model.pkl', 'rb') as f:
    model = pickle.load(f)

Here:

  • pickle is used to deserialize the saved Python model.
  • 'model.pkl' is the file containing the trained model.
  • 'rb' opens the file in read-binary mode.
  • pickle.load(f) reconstructs the saved model and stores it in the model variable.

The FastAPI application is then initialized:

app = FastAPI()

This gives us two important objects:

  • model → Trained ML model loaded from model.pkl
  • app → FastAPI application

The important point here is that the model is loaded once when the application initializes, rather than being loaded inside every prediction request.

model.pkl → Load Model → FastAPI Application

At this stage, FastAPI has access to the trained model, but no prediction has been made yet.

Creating the Pydantic Input Model

Before the FastAPI endpoint can make a prediction, it needs a structured way to receive and validate user input.

For this project, we use a Pydantic model called UserInput. It defines the fields that the API expects from the client and adds validation rules to those fields.

Defining the Input Schema

The UserInput model contains the information required from the user:

class UserInput(BaseModel):
 
    age: Annotated[int, Field(..., gt=0, lt=120, description='Age of the user')]
    weight: Annotated[float, Field(..., gt=0, description='Weight of the user')]
    height: Annotated[float, Field(..., gt=0, lt=250, description='Height of the user')]
    income_lpa: Annotated[float, Field(..., gt=0, description='Annual salary of the user in lpa')]
    smoker: Annotated[bool, Field(..., description='Is user a smoker')]
    city: Annotated[str, Field(..., description='The city that the user belongs to')]
    occupation: Annotated[
        Literal[
            'retired',
            'freelancer',
            'student',
            'government_job',
            'business_owner',
            'unemployed',
            'private_job'
        ],
        Field(..., description='Occupation of the user')
    ]

This model expects seven input fields:

  • age
  • weight
  • height
  • income_lpa
  • smoker
  • city
  • occupation

The Field() definitions also provide validation constraints and descriptions. For example, age must be greater than 0 and less than 120, while height must be greater than 0 and less than 250.

Restricting Occupation Values

The occupation field uses Literal to restrict the accepted values:

Literal[
    'retired',
    'freelancer',
    'student',
    'government_job',
    'business_owner',
    'unemployed',
    'private_job'
]

This means the API does not accept arbitrary occupation strings. The incoming value must match one of the defined categories.

Why Use Pydantic Here?

The Pydantic model creates a clear boundary between external user input and the internal prediction logic.

Instead of allowing the prediction endpoint to receive unstructured data, FastAPI can validate the request against UserInput first.

The flow becomes:

Client Input → UserInput Validation → Validated Data → Feature Engineering → Prediction

The computed fields inside this model are responsible for deriving the additional features required by the trained model. We’ll examine that process separately in the Feature Engineering for Prediction section rather than repeating it here.

This keeps the responsibilities clear:

  • UserInput → defines and validates incoming data.
  • Computed fields → derive model features.
  • Prediction endpoint → sends those features to the loaded ML model.

Using Computed Fields for Feature Engineering

The API receives raw user information, but the trained machine learning model expects a different set of features.

For example, the user provides:

  • age
  • weight
  • height
  • smoker
  • city
  • income_lpa
  • occupation

The model, however, works with features such as:

  • bmi
  • age_group
  • lifestyle_risk
  • city_tier
  • income_lpa
  • occupation

Instead of asking the user to provide these derived values manually, the UserInput Pydantic model calculates them automatically using computed fields.

Calculating BMI

BMI is calculated from the user’s weight and height:

@computed_field
@property
def bmi(self) -> float:
    return self.weight / (self.height ** 2)

Whenever the model accesses data.bmi, the value is calculated from the validated weight and height.

Calculating Lifestyle Risk

The application also derives a lifestyle_risk feature using the user's smoking status and BMI:

@computed_field
@property
def lifestyle_risk(self) -> str:
    if self.smoker and self.bmi > 30:
        return "high"
    elif self.smoker or self.bmi > 27:
        return "medium"
    else:
        return "low"

The logic produces three possible categories:

  • High — the user is a smoker and has a BMI above 30.
  • Medium — the user is a smoker or has a BMI above 27.
  • Low — neither condition is satisfied.

Creating an Age Group

The raw age value is converted into an age_group:

@computed_field
@property
def age_group(self) -> str:
    if self.age < 25:
        return "young"
    elif self.age < 45:
        return "adult"
    elif self.age < 60:
        return "middle_aged"
    return "senior"

This converts a numerical age into one of four categories:

  • young
  • adult
  • middle_aged
  • senior

Determining the City Tier

The application also derives a city_tier from the user's city:

@computed_field
@property
def city_tier(self) -> int:
    if self.city in tier_1_cities:
        return 1
    elif self.city in tier_2_cities:
        return 2
    else:
        return 3

The application maintains separate lists for Tier 1 and Tier 2 cities. Cities that do not belong to either list are assigned Tier 3.

From Raw Input to Model Features

These computed fields allow the API to transform user-friendly input into the feature representation expected by the trained model.

The transformation can be summarized as:

Raw User Input → Computed Features → Model-Ready Features

For example:

Age + Weight + Height

   BMI + Age Group
 
Smoker + BMI

 Lifestyle Risk
 
City

City Tier

The resulting features are later assembled into a Pandas DataFrame containing:

input_df = pd.DataFrame([{
    'bmi': data.bmi,
    'age_group': data.age_group,
    'lifestyle_risk': data.lifestyle_risk,
    'city_tier': data.city_tier,
    'income_lpa': data.income_lpa,
    'occupation': data.occupation
}])

This DataFrame represents the final input that will be passed to the trained model.

The important idea is that feature engineering happens inside the API, so the client only needs to provide the raw information. FastAPI and Pydantic take care of preparing the derived features required for prediction.

Client provides raw data → Pydantic validates it → Computed fields derive features → API prepares model input

Building the Prediction Endpoint

With the ML model loaded and the UserInput model handling incoming data, we can now connect everything through a FastAPI endpoint.

The purpose of this endpoint is simple:

Receive user input → Prepare model features → Generate prediction → Return the result

Our project uses a POST endpoint at /predict for this purpose.

Defining the Endpoint

The prediction endpoint is defined as:

@app.post('/predict')
def predict_premium(data: UserInput):
    ...

Here:

  • @app.post('/predict') tells FastAPI to handle POST requests sent to /predict.
  • data: UserInput tells FastAPI to validate the request body using the UserInput Pydantic model.
  • data contains the validated user information.

This means the endpoint does not need to manually validate every incoming field. FastAPI and Pydantic handle that before the prediction logic runs.

Preparing the Model Input

After validation, the endpoint creates a Pandas DataFrame containing the features required by the trained model:

input_df = pd.DataFrame([{
    'bmi': data.bmi,
    'age_group': data.age_group,
    'lifestyle_risk': data.lifestyle_risk,
    'city_tier': data.city_tier,
    'income_lpa': data.income_lpa,
    'occupation': data.occupation
}])

At this point, the raw request data has already been transformed into the model-ready feature structure.

Notice that the endpoint does not recalculate BMI, age group, lifestyle risk, or city tier itself. Those values are obtained from the computed fields defined earlier.

This keeps the endpoint focused on connecting the prepared data to the model.

Generating the Prediction

The prepared DataFrame is passed to the loaded model:

prediction = model.predict(input_df)[0]

The model generates a prediction, and [0] extracts the prediction for the single input row.

Finally, the API returns the prediction as a JSON response:

return JSONResponse(
    status_code=200,
    content={'predicted_category': prediction}
)

The client therefore receives a response containing the predicted premium category.

Complete Endpoint

Putting the endpoint logic together:

@app.post('/predict')
def predict_premium(data: UserInput):
 
    input_df = pd.DataFrame([{
        'bmi': data.bmi,
        'age_group': data.age_group,
        'lifestyle_risk': data.lifestyle_risk,
        'city_tier': data.city_tier,
        'income_lpa': data.income_lpa,
        'occupation': data.occupation
    }])
 
    prediction = model.predict(input_df)[0]
 
    return JSONResponse(
        status_code=200,
        content={'predicted_category': prediction}
    )

The endpoint now connects the three pieces we have built so far:

User Input → Pydantic Validation → Prepared Features → ML Model → JSON Response

The FastAPI layer is therefore acting as the interface between the application and the trained machine learning model.

Preparing Input Data for the Model

After validating the incoming request and generating the required computed features, the next step is to prepare the data in the exact structure expected by the trained model.

The prediction endpoint converts the validated UserInput object into a Pandas DataFrame containing the six features used for prediction.

Creating the Model Input

The input DataFrame is created as follows:

input_df = pd.DataFrame([{
    'bmi': data.bmi,
    'age_group': data.age_group,
    'lifestyle_risk': data.lifestyle_risk,
    'city_tier': data.city_tier,
    'income_lpa': data.income_lpa,
    'occupation': data.occupation
}])

Here, the values come from the validated data object:

  • data.bmi → calculated BMI
  • data.age_group → derived age category
  • data.lifestyle_risk → derived lifestyle risk
  • data.city_tier → derived city classification
  • data.income_lpa → user's annual income
  • data.occupation → user's occupation

The resulting DataFrame represents one user’s prediction input.

Conceptually:

Raw User Input

Pydantic Validation

Computed Features

Pandas DataFrame

ML Model

Why Use a DataFrame?

The trained model is used with tabular data, so the API prepares the incoming values in a Pandas DataFrame before passing them to the model.

This also keeps the prediction input organized by feature name, rather than passing individual values separately.

The DataFrame contains:

  • bmi
  • age_group
  • lifestyle_risk
  • city_tier
  • income_lpa
  • occupation

Once this input_df is prepared, it can be directly passed to the loaded model:

prediction = model.predict(input_df)[0]

So this section’s responsibility is specifically transforming the validated API data into the tabular format required for inference. The actual prediction step belongs to the next stage.

Validated Input → Feature Values → DataFrame → Model-Ready Input

Generating Real-Time Predictions

Once the input data has been prepared in the format expected by the model, the final step is to generate a prediction.

The trained model is already loaded when the FastAPI application starts. The /predict endpoint can therefore use that model directly whenever a client sends a request.

Making the Prediction

The prediction is generated with:

prediction = model.predict(input_df)[0]

Here:

  • input_df contains the prepared features for the user.
  • model.predict() passes those features to the trained machine learning model.
  • [0] extracts the prediction from the returned result because the DataFrame contains a single input row.

The important point is that no model training happens during an API request. The API uses the already-trained model to perform inference.

Returning the Prediction

After generating the prediction, the API returns it to the client as a JSON response:

return JSONResponse(
    status_code=200,
    content={'predicted_category': prediction}
)

A successful response has the following structure:

{
  "predicted_category": "High"
}

The actual category is produced by the trained model.

Complete Prediction Flow

At this point, the complete inference process is:

POST Request → Pydantic Validation → Computed Features → DataFrame → model.predict() → JSON Response

This is what makes the model available for real-time inference through an API.

The FastAPI application has now completed its core responsibility: receiving user data, preparing it for the model, generating a prediction, and returning that prediction to the client.

Testing the API

After building the prediction endpoint, the next step is to test whether the API correctly accepts user input and returns a prediction.

Sending a Prediction Request

Our FastAPI application exposes the following endpoint:

POST /predict

The request body should contain the fields defined by the UserInput model:

{
  "age": 30,
  "weight": 70,
  "height": 1.75,
  "income_lpa": 10,
  "smoker": false,
  "city": "Jaipur",
  "occupation": "private_job"
}

FastAPI validates this request against the UserInput model before the prediction logic executes.

What Happens During the Request?

Once the request reaches /predict, the API:

  1. Receives the user’s input.
  2. Validates the data using Pydantic.
  3. Uses the computed fields to generate the required features.
  4. Creates the model input DataFrame.
  5. Passes the DataFrame to the trained model.
  6. Returns the predicted category.

The complete request flow is:

POST /predict → Validate Input → Generate Features → Prepare DataFrame → model.predict() → Return Prediction

Example Response

The endpoint returns the prediction in JSON format:

{
  "predicted_category": "High"
}

The response is returned with an HTTP 200 status code.

The exact category depends on the input values and the trained model’s prediction.

Testing Through FastAPI Documentation

FastAPI automatically provides interactive API documentation, so the /predict endpoint can be tested directly through the Swagger UI.

You can:

  1. Open the /docs page.
  2. Select POST /predict.
  3. Click Try it out.
  4. Enter the JSON request body.
  5. Execute the request.
  6. Inspect the returned prediction.

This provides a convenient way to verify the complete API flow before connecting a frontend.

API Test: Client Request → FastAPI → ML Model → Prediction Response

Once the endpoint works correctly, the same API can be consumed by the Streamlit frontend to provide a user-facing prediction interface.

Connecting FastAPI with Streamlit

The FastAPI prediction API can now be connected to a user-facing application using Streamlit.

In this project, Streamlit acts as the frontend, while FastAPI remains responsible for receiving the input, preparing the features, running the ML model, and returning the prediction.

The overall architecture is:

User → Streamlit → FastAPI → ML Model → FastAPI → Streamlit → User

Configuring the API URL

The Streamlit application stores the FastAPI endpoint URL:

API_URL = "http://localhost:8000/predict"

This is the endpoint that Streamlit will call when the user requests a prediction.

Collecting User Input

The frontend provides input controls for the same fields expected by the FastAPI UserInput model:

age = st.number_input("Age", min_value=1, max_value=119, value=30)
weight = st.number_input("Weight (kg)", min_value=1.0, value=65.0)
height = st.number_input("Height (m)", min_value=0.5, max_value=2.5, value=1.7)
income_lpa = st.number_input("Annual Income (LPA)", min_value=0.1, value=10.0)
smoker = st.selectbox("Are you a smoker?", options=[True, False])
city = st.text_input("City", value="Mumbai")
occupation = st.selectbox(
    "Occupation",
    ['retired', 'freelancer', 'student', 'government_job',
     'business_owner', 'unemployed', 'private_job']
)

These controls allow the user to provide all the raw values required by the API.

Sending Data to FastAPI

When the user clicks the prediction button, Streamlit collects the input into a Python dictionary:

input_data = {
    "age": age,
    "weight": weight,
    "height": height,
    "income_lpa": income_lpa,
    "smoker": smoker,
    "city": city,
    "occupation": occupation
}

It then sends this data to the FastAPI endpoint using the requests library:

response = requests.post(API_URL, json=input_data)
result = response.json()

The json=input_data argument sends the dictionary as the JSON request body that FastAPI expects.

Displaying the Prediction

After receiving the API response, Streamlit checks whether the request was successful and whether the response contains predicted_category:

if response.status_code == 200 and "predicted_category" in result:
    prediction = result["predicted_category"]
    st.success(
        f"Predicted Insurance Premium Category: **{prediction}**"
    )

If the API returns an error, the frontend displays the status code and response:

else:
    st.error(f"API Error: {response.status_code}")
    st.write(result)

The application also handles a connection failure:

except requests.exceptions.ConnectionError:
    st.error(
        "Could not connect to the FastAPI server. "
        "Make sure it's running."
    )

Complete Frontend-to-API Flow

The complete interaction is now:

User enters details

 Streamlit collects the input

 requests.post() sends JSON to /predict

 FastAPI validates and prepares the input

 ML model generates the prediction

 FastAPI returns predicted_category

 Streamlit displays the result

This completes the connection between the frontend, FastAPI application, and machine learning model.

The important separation is:

  • Streamlit → User interface
  • FastAPI → API and inference layer
  • ML model → Prediction

This architecture allows the same FastAPI prediction service to be consumed by other clients as well, rather than tying the model directly to the Streamlit interface.

Complete Code Example

Now that we have covered the individual parts of the project, we can put the complete implementation together.

The project uses two main application files:

  • app.py → FastAPI backend that loads the trained model and generates predictions.
  • frontend.py → Streamlit frontend that collects user input and communicates with the FastAPI API.

FastAPI Backend — app.py

from fastapi import FastAPI
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, computed_field
from typing import Literal, Annotated
import pickle
import pandas as pd
 
# import the ml model
with open('model.pkl', 'rb') as f:
    model = pickle.load(f)
 
app = FastAPI()
 
tier_1_cities = [
    "Mumbai",
    "Delhi",
    "Bangalore",
    "Chennai",
    "Kolkata",
    "Hyderabad",
    "Pune"
]
 
tier_2_cities = [
    "Jaipur", "Chandigarh", "Indore", "Lucknow", "Patna",
    "Ranchi", "Visakhapatnam", "Coimbatore", "Bhopal",
    "Nagpur", "Vadodara", "Surat", "Rajkot", "Jodhpur",
    "Raipur", "Amritsar", "Varanasi", "Agra", "Dehradun",
    "Mysore", "Jabalpur", "Guwahati", "Thiruvananthapuram",
    "Ludhiana", "Nashik", "Allahabad", "Udaipur",
    "Aurangabad", "Hubli", "Belgaum", "Salem", "Vijayawada",
    "Tiruchirappalli", "Bhavnagar", "Gwalior", "Dhanbad",
    "Bareilly", "Aligarh", "Gaya", "Kozhikode", "Warangal",
    "Kolhapur", "Bilaspur", "Jalandhar", "Noida", "Guntur",
    "Asansol", "Siliguri"
]
 
# pydantic model to validate incoming data
class UserInput(BaseModel):
 
    age: Annotated[
        int,
        Field(..., gt=0, lt=120, description='Age of the user')
    ]
 
    weight: Annotated[
        float,
        Field(..., gt=0, description='Weight of the user')
    ]
 
    height: Annotated[
        float,
        Field(..., gt=0, lt=250, description='Height of the user')
    ]
 
    income_lpa: Annotated[
        float,
        Field(..., gt=0, description='Annual salary of the user in lpa')
    ]
 
    smoker: Annotated[
        bool,
        Field(..., description='Is user a smoker')
    ]
 
    city: Annotated[
        str,
        Field(..., description='The city that the user belongs to')
    ]
 
    occupation: Annotated[
        Literal[
            'retired',
            'freelancer',
            'student',
            'government_job',
            'business_owner',
            'unemployed',
            'private_job'
        ],
        Field(..., description='Occupation of the user')
    ]
 
    @computed_field
    @property
    def bmi(self) -> float:
        return self.weight / (self.height ** 2)
 
    @computed_field
    @property
    def lifestyle_risk(self) -> str:
        if self.smoker and self.bmi > 30:
            return "high"
        elif self.smoker or self.bmi > 27:
            return "medium"
        else:
            return "low"
 
    @computed_field
    @property
    def age_group(self) -> str:
        if self.age < 25:
            return "young"
        elif self.age < 45:
            return "adult"
        elif self.age < 60:
            return "middle_aged"
        return "senior"
 
    @computed_field
    @property
    def city_tier(self) -> int:
        if self.city in tier_1_cities:
            return 1
        elif self.city in tier_2_cities:
            return 2
        else:
            return 3
 
@app.post('/predict')
def predict_premium(data: UserInput):
 
    input_df = pd.DataFrame([{
        'bmi': data.bmi,
        'age_group': data.age_group,
        'lifestyle_risk': data.lifestyle_risk,
        'city_tier': data.city_tier,
        'income_lpa': data.income_lpa,
        'occupation': data.occupation
    }])
 
    prediction = model.predict(input_df)[0]
 
    return JSONResponse(
        status_code=200,
        content={'predicted_category': prediction}
    )

This is the complete FastAPI application used in the project. It loads model.pkl, validates incoming data, generates the required computed features, prepares the DataFrame, performs inference, and returns the predicted category.

Streamlit Frontend — frontend.py

import streamlit as st
import requests
 
API_URL = "http://localhost:8000/predict"
 
st.title("Insurance Premium Category Predictor")
st.markdown("Enter your details below:")
 
# Input fields
age = st.number_input(
    "Age",
    min_value=1,
    max_value=119,
    value=30
)
 
weight = st.number_input(
    "Weight (kg)",
    min_value=1.0,
    value=65.0
)
 
height = st.number_input(
    "Height (m)",
    min_value=0.5,
    max_value=2.5,
    value=1.7
)
 
income_lpa = st.number_input(
    "Annual Income (LPA)",
    min_value=0.1,
    value=10.0
)
 
smoker = st.selectbox(
    "Are you a smoker?",
    options=[True, False]
)
 
city = st.text_input(
    "City",
    value="Mumbai"
)
 
occupation = st.selectbox(
    "Occupation",
    [
        'retired',
        'freelancer',
        'student',
        'government_job',
        'business_owner',
        'unemployed',
        'private_job'
    ]
)
 
if st.button("Predict Premium Category"):
 
    input_data = {
        "age": age,
        "weight": weight,
        "height": height,
        "income_lpa": income_lpa,
        "smoker": smoker,
        "city": city,
        "occupation": occupation
    }
 
    try:
        response = requests.post(
            API_URL,
            json=input_data
        )
 
        result = response.json()
 
        if (
            response.status_code == 200
            and "predicted_category" in result
        ):
            prediction = result["predicted_category"]
 
            st.success(
                f"Predicted Insurance Premium Category: **{prediction}**"
            )
 
        else:
            st.error(
                f"API Error: {response.status_code}"
            )
 
            st.write(result)
 
    except requests.exceptions.ConnectionError:
        st.error(
            "❌ Could not connect to the FastAPI server. "
            "Make sure it's running."
        )

The frontend collects the user’s information, sends it as JSON to http://localhost:8000/predict, reads the API response, and displays the predicted insurance premium category.

How the Complete Application Fits Together

With both files running, the application works as:

Streamlit Input → POST /predict → Pydantic Validation → Computed Features → Pandas DataFrame → ML Model → Prediction → JSON Response → Streamlit Result

Output

After running both the FastAPI backend and the Streamlit frontend, the application provides a complete prediction workflow.

When the user enters their details in the Streamlit interface and clicks Predict Premium Category, the frontend sends the data to the FastAPI /predict endpoint.

The FastAPI application processes the request and returns the predicted category:

{
  "predicted_category": "High"
}

The Streamlit frontend then displays the result to the user:

Predicted Insurance Premium Category: High

This response is generated from the prediction returned by the trained model through the FastAPI endpoint.

Complete Output Flow

The final application flow is:

User Input → Streamlit → FastAPI /predict → Feature Preparation → ML Model → Prediction → FastAPI Response → Streamlit Output

A successful API request returns HTTP 200 along with the predicted_category field.

If the FastAPI server is unavailable, the Streamlit application displays a connection error instead.

This completes the end-to-end machine learning prediction application using FastAPI and Streamlit.

Code Execution Explanation

When the application runs, the complete prediction process is divided between the Streamlit frontend and the FastAPI backend.

First, the user enters their details in the Streamlit interface and clicks Predict Premium Category. Streamlit collects those values into input_data and sends them as a JSON POST request to the FastAPI /predict endpoint.

On the FastAPI side, the request is received through the UserInput Pydantic model. The raw values are validated, and the computed fields generate the additional features required by the model, including BMI, lifestyle risk, age group, and city tier.

The endpoint then creates a Pandas DataFrame containing the model-ready features:

  • bmi
  • age_group
  • lifestyle_risk
  • city_tier
  • income_lpa
  • occupation

This DataFrame is passed to the already-loaded ML model:

prediction = model.predict(input_df)[0]

The model returns a predicted premium category, which FastAPI sends back as JSON:

{
  "predicted_category": "High"
}

Finally, Streamlit reads the predicted_category value and displays it to the user. If the API cannot be reached, the frontend displays a connection error instead.

The complete execution flow is therefore:

User Input → Streamlit → POST /predict → Pydantic Validation → Feature Engineering → DataFrame → ML Model → Prediction → JSON Response → Streamlit Output

This completes the end-to-end flow from user input to real-time machine learning prediction.

Key Takeaways

ConceptSummary
ML Model ServingA trained machine learning model can be exported and served through a FastAPI application.
Pydantic ValidationPydantic models validate incoming user data before it reaches the prediction logic.
Computed FieldsFastAPI can generate derived features such as BMI, lifestyle risk, age group, and city tier from raw input.
Feature PreparationThe validated and engineered features are converted into a Pandas DataFrame that matches the model’s expected input.
Real-Time PredictionThe saved model generates predictions through model.predict() without retraining during each request.
REST APIA POST /predict endpoint provides a simple interface for sending data to the ML model.
Frontend IntegrationStreamlit can consume the FastAPI endpoint and display the prediction through a user-friendly interface.
End-to-End ArchitectureThe project demonstrates how Frontend → FastAPI → Feature Engineering → ML Model → Prediction → Frontend can work together as a complete ML application.

Conclusion

Serving a machine learning model through FastAPI provides a practical way to turn a trained model into an accessible API service.

In this project, we built an Insurance Premium Category Predictor where FastAPI validates user input with Pydantic, generates the required features through computed fields, prepares the data for the trained model, and returns a real-time prediction through the /predict endpoint. A Streamlit frontend then consumes this API and presents the prediction to the user.

The complete architecture can be summarized as:

User Input → Streamlit → POST /predict → Pydantic Validation → Feature Engineering → DataFrame → ML Model → Prediction → JSON Response → Streamlit Output

This demonstrates an important step in moving from training a machine learning model to actually serving it as part of an application.

What’s Next?

Now that we have built an end-to-end machine learning prediction API with FastAPI, the next step is to make the API more structured, maintainable, and production-ready.

In the next part, we’ll improve the architecture of our Insurance Premium Prediction API by separating responsibilities and adding several practical API improvements.

You’ll learn:

  • Project Structure: How to organize the FastAPI application into separate folders for configuration, schemas, and machine learning logic.
  • Field Validation: How to normalize the city input before using it for feature engineering.
  • Health Check Endpoint: How to add a /health endpoint to verify that the API is running and the ML model is loaded.
  • Model Versioning: How to expose the ML model version through the API.
  • Separation of Logic: How to move Pydantic schemas, city-tier configuration, and ML prediction logic into dedicated modules.
  • Error Handling: How to use try/except to handle unexpected errors during prediction.
  • Confidence Scores: How to return the model’s confidence along with the predicted category and class probabilities.
  • Response Models: How FastAPI’s response_model can define, validate, document, and control the structure of API responses.

By the end of the next part, our API will move beyond simply making predictions and become a more organized, testable, and maintainable FastAPI application.

Series: FastAPI for Machine Learning — Part 8 of 12

Enjoyed the read? Connect with me.

"Simplicity is the soul of efficiency."

— Austin Freeman

Currently Building

Synaptic RAG Platform

About

Shubham Nagar
Shubham NagarFull Stack AI Engineer

Full Stack AI Engineer with 3+ years experience building production-ready web apps. From Next.js frontends to LangChain RAG pipelines, I care about code quality and crafting architectures that scale.

Designed & Built by Shubham Nagar. © 2026 All rights reserved.

Built with Next.js • TypeScript • Tailwind • MDX • Vercel

Serving Machine Learning Models with FastAPI: Build a Prediction API — Part 8