Skip to main content

Module 2 — Input validation with Pydantic

Module 1 showed that FastAPI validates scalar query parameters for free. A real prediction service does not take a handful of scalars — it takes a subscriber: a dozen fields with types, ranges and categorical values that only some combinations of which make sense. This module replaces the loose int and float of module 1 with a Pydantic model that names every field, enforces its constraints, documents itself in Swagger UI, and produces the structured 422 responses that a good client can act on.

Pydantic in one paragraph

Pydantic is the validation library FastAPI is built on. A model is a Python class that inherits from BaseModel and declares its fields with type hints. When FastAPI receives a request whose body is annotated with a Pydantic model, it parses the JSON, coerces every field to the declared type, runs constraints, and hands your route a fully validated object. Failures never reach your function: they surface as a 422 Unprocessable Entity with a per-field list of what went wrong. That contract — "your function sees valid data or does not run at all" — is the single reason to write Pydantic models around every prediction route.

A first request model for the churn service

Replace the two query parameters of module 1 with a body:

# app/schemas.py
from typing import Literal
from pydantic import BaseModel, Field


class Subscriber(BaseModel):
subscriber_id: str = Field(..., min_length=1, max_length=64)
tenure_months: int = Field(..., ge=0, le=240)
monthly_charges: float = Field(..., ge=0.0, le=1000.0)
contract_type: Literal["month_to_month", "one_year", "two_years"]
payment_method: Literal["bank_transfer", "credit_card", "mailed_check"] = "credit_card"
is_senior: bool = False

Every line encodes a decision the previous team probably made on the whiteboard and then forgot:

  • subscriber_id: str = Field(..., min_length=1) — the field is required (the ...) and cannot be an empty string. Half of the "why does this row score wrong" tickets in ML services come from an empty or None identifier that the caller assumed was optional.
  • tenure_months: int = Field(..., ge=0, le=240) — the bounds match what actually exists in the training data; a tenure_months of 500 is not a value the model was trained to see, and refusing it at the boundary beats emitting a nonsense probability.
  • contract_type: Literal[...] — the three values are the exact strings the training pipeline expects. A caller sending "Month-to-Month" will get a 422 that names the field and the allowed values, not a KeyError at inference time.
  • payment_method: ... = "credit_card" — a default is a promise. Provide one only when the model treats the default and the missing value the same way; otherwise, force the caller to be explicit.
  • is_senior: bool = False — for a boolean, False is safer than None because it matches the majority class and avoids introducing a "missing" branch in preprocessing.

Now wire the model to a route:

# app/main.py
from fastapi import FastAPI
from app.schemas import Subscriber

app = FastAPI(title="Churn scoring API", version="0.2.0")


@app.post("/predict", tags=["prediction"])
def predict(subscriber: Subscriber) -> dict:
# Placeholder until module 3 wires the real model.
score = 0.30 + 0.10 * (subscriber.contract_type == "month_to_month")
return {"subscriber_id": subscriber.subscriber_id, "churn_probability": round(score, 4)}

The function's argument is a Subscriber — a typed Python object with subscriber.tenure_months as an integer, not a dict you have to defensively index. The IDE autocompletes fields, mypy catches typos, and the response your caller sees on a bad payload is precise.

Response models: a contract in the other direction

A Subscriber is what the caller sends. A Prediction is what the service returns. Declaring it with response_model= gives FastAPI a schema to validate against on the way out, which prevents leaking internal fields:

from pydantic import BaseModel, Field
from typing import Literal


class Prediction(BaseModel):
subscriber_id: str
churn_probability: float = Field(..., ge=0.0, le=1.0)
risk_bucket: Literal["low", "medium", "high"]
model_version: str


@app.post("/predict", response_model=Prediction, tags=["prediction"])
def predict(subscriber: Subscriber) -> Prediction:
prob = 0.30 + 0.10 * (subscriber.contract_type == "month_to_month")
bucket = "low" if prob < 0.33 else "medium" if prob < 0.66 else "high"
return Prediction(
subscriber_id=subscriber.subscriber_id,
churn_probability=round(prob, 4),
risk_bucket=bucket,
model_version="v0.2.0-placeholder",
)

If the placeholder is ever replaced by a function that returns a debugging blob with a raw_features field, response_model will drop it before the JSON leaves the process. That is a small but real security property: your production responses only ever contain what the contract advertises.

Constraints beyond scalar bounds

Field constraints handle the easy 80 %. The remaining 20 % is captured by field validators and model validators, which are Python functions decorated to run before or after the automatic parsing:

from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Literal


class Subscriber(BaseModel):
subscriber_id: str = Field(..., min_length=1, max_length=64)
tenure_months: int = Field(..., ge=0, le=240)
monthly_charges: float = Field(..., ge=0.0, le=1000.0)
total_charges: float = Field(..., ge=0.0)
contract_type: Literal["month_to_month", "one_year", "two_years"]

@field_validator("subscriber_id")
@classmethod
def strip_and_lower(cls, v: str) -> str:
return v.strip().lower()

@model_validator(mode="after")
def total_ge_monthly(self) -> "Subscriber":
# A minimum consistency check the model would otherwise silently accept.
if self.total_charges < self.monthly_charges:
raise ValueError("total_charges must be at least equal to monthly_charges")
return self

Two habits matter here. Prefer field constraints for anything expressible as a range, a length or an enum: they are cheaper and generate better docs. Reserve validators for cross-field consistency and for normalization — trimming whitespace, lowercasing, canonicalizing a phone number. Never call a database or an external service from a validator; validation is synchronous and per-request, and a slow validator becomes a per-request timeout.

Examples in the documentation

Nothing convinces an API consumer faster than a working example next to the schema. Pydantic exposes json_schema_extra for exactly that:

class Subscriber(BaseModel):
model_config = {
"json_schema_extra": {
"examples": [
{
"subscriber_id": "sub-000123",
"tenure_months": 12,
"monthly_charges": 75.5,
"contract_type": "month_to_month",
"payment_method": "credit_card",
"is_senior": False,
}
]
}
}
# ... fields ...

Swagger UI will pre-fill the "Try it out" form with that payload, which turns your API doc into a working demo. The mobile team can copy the example, hit "Execute", and see the response in the browser without writing a line of code.

Reading a 422 like a developer

A validation error looks like this:

# HTTP/1.1 422 Unprocessable Entity
# {
# "detail": [
# {
# "type": "greater_than_equal",
# "loc": ["body", "tenure_months"],
# "msg": "Input should be greater than or equal to 0",
# "input": -3
# }
# ]
# }

Three fields matter. loc is a JSON path: ["body", "tenure_months"] means "the field tenure_months inside the JSON body". A nested error would look like ["body", "subscribers", 2, "monthly_charges"]. type is a stable, machine-readable tag (greater_than_equal, int_parsing, missing) that a client can switch on to surface a localized message. msg is the human-friendly explanation. A well-designed client never displays msg to a non-technical user — it maps type to a translated sentence.

A 422 is a client bug, not a server error

The natural instinct on a first 422 in production is to alert an on-call engineer. Do not. A 422 means the caller sent a payload that does not match the contract. The right action is to log the shape of the failure (never the payload itself, which may contain PII), surface a client-side error message, and let the caller fix its request. Module 8 will show how to keep 422 counts on a separate dashboard from real 500s.

In summary

  • Model every request body and every response with a Pydantic class. The type hint drives validation, coercion, documentation and IDE support in one place.
  • Encode business rules with Field constraints (ranges, lengths, Literal) first, and reserve field_validator / model_validator for cross-field consistency and normalization.
  • Set response_model= to strip internal fields before they leave the process — a small but real security property.
  • A 422 means the caller violated the contract; log its shape, do not page on it, and never expose the raw payload.

Next module: loading the model at startup, so the placeholder score of this module is replaced by a real MLflow-loaded model without paying the load cost on every request.