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().
View the code here
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.
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())200 {'math_score': 78.54}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.
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.
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.
class Prediction(BaseModel):
math_score: floatPutting 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.
@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.
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))200 {'math_score': 69.34}
422
{
"type": "less_than_equal",
"loc": [
"body",
"reading_score"
],
"msg": "Input should be less than or equal to 100",
"input": 150,
"ctx": {
"le": 100
}
}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.
locpoints at the field, readingbodythenreading_score, so a caller can highlight the one input that was wrong rather than the whole form.msgstates the rule in words taken from the constraint you wrote, which is why a tightFieldgives a useful message for free.inputechoes the value, so the offending 150 is in the reply and nobody has to guess what was sent.detailis 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: dict | record: StudentRecord | |
|---|---|---|
| A missing field | KeyError deep in the pipeline | 422 naming the field |
| A score of 150 | Predicted as if valid | 422, the model never runs |
| A string where a number belongs | Fails inside the transformer | 422, or coerced if it parses |
| The caller's error message | Whatever leaked out | The field, the rule and the value |
| The reply's shape | Whatever you returned | Held 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.
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.Related
- Previous: Serving the model with FastAPI
- Next: Dockerising the service
- The same checks on the training side: Data validation
- Reference: Pydantic fields and constraints
- Drop
writing_scorefrom the posted body and read which field the 422 names. - Change
le=100tole=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.