MLOpsThe MLOps lifecycle · MLflow 3.17 · DVC 3.67 · FastAPI · Docker · GitHub Actions · Evidently · Python 3.11+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
50 small wins to finish your pathNext lesson →

Request and response schemas

A request schema is a Pydantic model naming the fields a route accepts and the range each one may hold, so FastAPI checks the body and answers 422 with the failing field before your function is ever called.

Last updated: 19 Oct, 2026 · MLOps

The Serving the model with FastAPI lesson declared the request body as a plain dict, which means any JSON at all is handed straight to the model. The 2023 project videos take the body from an HTML form and read the fields by name, which has the same gap. The FastAPI video closes it with a class that inherits from BaseModel, describing a task before any route touches it, and that is what this page does for a student record.

Pydantic is the data validation library FastAPI reads those classes with. One name changed between the version the video uses and the 2.14.1 release here: the method that turns a model back into a plain dictionary was .dict() and is now model_dump().

Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports this file. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep it in the same folder.
View the code here
app.py
from fastapi import FastAPI
from pydantic import BaseModel, Field

from sp.pipeline.predict_pipeline import PredictPipeline

app = FastAPI(title="Student Performance API")
pipeline = PredictPipeline()


class StudentRecord(BaseModel):
    gender: str
    race_ethnicity: str
    parental_level_of_education: str
    lunch: str
    test_preparation_course: str

    reading_score: int = Field(ge=0, le=100)
    writing_score: int = Field(ge=0, le=100)


class Prediction(BaseModel):
    math_score: float


@app.get("/health")
def health():
    return {"status": "ok"}


@app.post("/predict", response_model=Prediction)
def predict(record: StudentRecord) -> Prediction:
    score = pipeline.predict(record.model_dump())
    return Prediction(math_score=round(score, 2))

What an unchecked body lets through

A reading score is a mark out of 100. Nothing in the dict version of the route says so, so a body claiming 150 is passed to the preprocessor, scaled like any other number, and answered with a score as if it meant something.

Example
from fastapi.testclient import TestClient
from app import app

client = TestClient(app)

# a reading score of 150 is impossible, and a dict body has no rule against it
record = {"gender": "female", "race_ethnicity": "group B",
          "parental_level_of_education": "bachelor's degree", "lunch": "standard",
          "test_preparation_course": "none", "reading_score": 150, "writing_score": 74}
reply = client.post("/predict", json=record)
print(reply.status_code, reply.json())

A 200 and a number. Nothing failed, nothing was logged, and the caller has no way to tell this answer apart from a real one. That is the case a schema exists to stop.

One BaseModel for the request, another that inherits it for the response · from the Build RESTful APIs with FastAPI in Python · 44:04 to 45:37

The clip's models describe an item with a name, a price and a flag; these describe a student record and the score the service sends back, with the response model inheriting the request model in both.

Describing the request with a Pydantic model

The three pieces below replace the dict body in app.py with classes FastAPI can check against. The request model lists every field of a student record with its type, and gives the two numeric features a floor and a ceiling through Field.

python
from pydantic import BaseModel, Field


class StudentRecord(BaseModel):
    gender: str
    race_ethnicity: str
    parental_level_of_education: str
    lunch: str
    test_preparation_course: str

    # ge and le are the floor and the ceiling: a mark outside 0 to 100 is not a mark
    reading_score: int = Field(ge=0, le=100)
    writing_score: int = Field(ge=0, le=100)

Declaring what the route returns

A response model is the same idea pointed the other way. The FastAPI video's reason for it is that Python does not make a function promise what it gives back, so you say it once in a class and the framework holds the route to it. Here the promise is one float under one name.

python
class Prediction(BaseModel):
    math_score: float

Putting both on the route

The request model goes on the parameter and the response model goes in the decorator. Inside the function the validated record is turned back into a plain dictionary, because the prediction pipeline takes one of those and knows nothing about Pydantic.

python
@app.post("/predict", response_model=Prediction)
def predict(record: StudentRecord) -> Prediction:
    # model_dump turns the checked model back into the dict the pipeline wants
    score = pipeline.predict(record.model_dump())
    return Prediction(math_score=round(score, 2))

Posting a good record and a bad one

The same two bodies as before go to the new route. The valid one is unchanged by the schema and still predicts. The impossible one never reaches the model, and the reply says which field broke which rule.

Example
import json

# the valid record still predicts, and the response model shapes the reply
good = client.post("/predict", json=record)
print(good.status_code, good.json())

# the impossible reading score is refused, and the 422 body names the
# field, the rule it broke and the value that broke it
bad = client.post("/predict", json=dict(record, reading_score=150))
print(bad.status_code)
print(json.dumps(bad.json()["detail"][0], indent=1))

Reading the 422 body

  • The status is 422, the code FastAPI uses for a body it understood but could not accept, which is different from a 400 for malformed JSON and a 500 for your code failing.
  • loc points at the field, reading body then reading_score, so a caller can highlight the one input that was wrong rather than the whole form.
  • msg states the rule in words taken from the constraint you wrote, which is why a tight Field gives a useful message for free.
  • input echoes the value, so the offending 150 is in the reply and nobody has to guess what was sent.
  • detail is a list, with one entry per broken rule, so a body with three bad fields comes back with all three at once.

A dict body against a Pydantic model

record: dictrecord: StudentRecord
A missing fieldKeyError deep in the pipeline422 naming the field
A score of 150Predicted as if valid422, the model never runs
A string where a number belongsFails inside the transformer422, or coerced if it parses
The caller's error messageWhatever leaked outThe field, the rule and the value
The reply's shapeWhatever you returnedHeld to the response model

Where a schema earns its place

  • Any public endpoint, where the caller is a program you did not write and the body is whatever it decided to send.
  • A model with bounded features, where a mark, a percentage or an age has limits the training data respected and the request must too.
  • A contract between teams, where the schema is the written agreement on what goes in and what comes back, and it is checked on every call.
Watch out. A schema checks shape and range, not sense. A record with gender set to a category the preprocessor never saw passes str happily and then fails or predicts strangely further in. Give the small category fields a fixed set of allowed values as well, and keep that set next to the schema.yaml the training stage already validates against, so the service and the training agree on what a record is.
Try it yourself
  • Drop writing_score from the posted body and read which field the 422 names.
  • Change le=100 to le=120, post 150 again, and watch the message change with the rule.
  • Post two bad fields at once and count the entries in detail.

Every expert started right here.